hitbox_http/predicates/body/jq.rs
1//! JQ expression support for body predicates.
2//!
3//! Provides [`JqExpression`] for compiling and applying jq filters to JSON bodies,
4//! and [`JqOperation`] for matching against jq query results.
5
6use std::fmt::Debug;
7
8use hitbox::predicate::PredicateResult;
9use hyper::body::Body as HttpBody;
10use jaq_core::{
11 Ctx, Filter, Native, RcIter,
12 load::{Arena, File, Loader},
13};
14use jaq_json::{self, Val};
15use serde_json::Value;
16
17use crate::BufferedBody;
18
19/// A compiled jq expression for querying JSON bodies.
20///
21/// Wraps a jaq filter that can be compiled once and reused for multiple requests.
22/// This avoids the overhead of parsing and compiling the jq expression on each request.
23///
24/// # Examples
25///
26/// ```
27/// use hitbox_http::predicates::body::JqExpression;
28///
29/// // Compile a jq expression
30/// let expr = JqExpression::compile(".user.id").unwrap();
31///
32/// // Apply to JSON data
33/// let json = serde_json::json!({"user": {"id": 42}});
34/// let result = expr.apply(json);
35/// assert_eq!(result, Some(serde_json::json!(42)));
36/// ```
37///
38/// # Errors
39///
40/// [`compile`](Self::compile) returns `Err` if the jq expression is invalid.
41#[derive(Clone)]
42pub struct JqExpression(Filter<Native<Val>>);
43
44impl JqExpression {
45 /// Compiles a jq expression into a reusable filter.
46 ///
47 /// # Arguments
48 ///
49 /// * `expression` — A jq filter expression (e.g., `.user.id`, `.items[] | .name`)
50 ///
51 /// # Errors
52 ///
53 /// Returns `Err` if the expression cannot be parsed or compiled.
54 pub fn compile(expression: &str) -> Result<Self, String> {
55 let program = File {
56 code: expression,
57 path: (),
58 };
59 let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs()));
60 let arena = Arena::default();
61 let modules = loader
62 .load(&arena, program)
63 .map_err(|e| format!("Failed to load jq program: {:?}", e))?;
64 let filter = jaq_core::Compiler::default()
65 .with_funs(jaq_std::funs().chain(jaq_json::funs()))
66 .compile(modules)
67 .map_err(|e| format!("Failed to compile jq program: {:?}", e))?;
68 Ok(Self(filter))
69 }
70
71 /// Applies the filter to a JSON value and returns the result.
72 ///
73 /// Returns `None` if the filter produces `null` or no output.
74 /// If the filter produces multiple values, they are returned as a JSON array.
75 pub fn apply(&self, input: Value) -> Option<Value> {
76 let inputs = RcIter::new(core::iter::empty());
77 let out = self.0.run((Ctx::new([], &inputs), Val::from(input)));
78 let results: Result<Vec<_>, _> = out.collect();
79 match results {
80 Ok(values) if values.eq(&vec![Val::Null]) => None,
81 Ok(values) if !values.is_empty() => {
82 let mut values: Vec<Value> = values.into_iter().map(|v| v.into()).collect();
83 if values.len() == 1 {
84 values.pop()
85 } else {
86 Some(Value::Array(values))
87 }
88 }
89 _ => None,
90 }
91 }
92}
93
94impl Debug for JqExpression {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 f.debug_struct("JqExpression").finish_non_exhaustive()
97 }
98}
99
100/// Operations for matching jq query results.
101///
102/// Used with [`JqExpression`] to check if a JSON body matches certain criteria.
103///
104/// # Variants
105///
106/// - [`Eq`](Self::Eq) — The jq result must equal the specified value
107/// - [`Exist`](Self::Exist) — The jq result must be non-null
108/// - [`In`](Self::In) — The jq result must be one of the specified values
109#[derive(Debug, Clone)]
110pub enum JqOperation {
111 /// Match if the jq result equals this value.
112 Eq(Value),
113 /// Match if the jq result is non-null (path exists).
114 Exist,
115 /// Match if the jq result is one of these values.
116 In(Vec<Value>),
117}
118
119impl JqOperation {
120 /// Checks if the jq operation matches the body.
121 ///
122 /// Collects the entire body, parses it as JSON, applies the jq filter,
123 /// and checks if the result satisfies this operation.
124 ///
125 /// Returns [`Cacheable`](PredicateResult::Cacheable) if the operation is satisfied,
126 /// [`NonCacheable`](PredicateResult::NonCacheable) otherwise.
127 ///
128 /// # Caveats
129 ///
130 /// - The entire body is buffered into memory for JSON parsing
131 /// - Returns `NonCacheable` if the body is not valid JSON
132 pub async fn check<B>(
133 &self,
134 filter: &JqExpression,
135 body: BufferedBody<B>,
136 ) -> PredicateResult<BufferedBody<B>>
137 where
138 B: HttpBody + Unpin,
139 B::Data: Send,
140 {
141 // Collect the full body to parse as JSON
142 let body_bytes = match body.collect().await {
143 Ok(bytes) => bytes,
144 Err(error_body) => return PredicateResult::NonCacheable(error_body),
145 };
146
147 // Parse body as JSON
148 let json_value: Value = match serde_json::from_slice(&body_bytes) {
149 Ok(v) => v,
150 Err(_) => {
151 // Failed to parse JSON - non-cacheable
152 return PredicateResult::NonCacheable(BufferedBody::Complete(Some(body_bytes)));
153 }
154 };
155
156 // Apply the jq filter
157 let found_value = filter.apply(json_value);
158
159 // Check if the operation matches
160 let matches = match self {
161 JqOperation::Eq(expected) => {
162 found_value.as_ref().map(|v| v == expected).unwrap_or(false)
163 }
164 JqOperation::Exist => found_value.is_some(),
165 JqOperation::In(values) => found_value
166 .as_ref()
167 .map(|v| values.contains(v))
168 .unwrap_or(false),
169 };
170
171 let result_body = BufferedBody::Complete(Some(body_bytes));
172 if matches {
173 PredicateResult::Cacheable(result_body)
174 } else {
175 PredicateResult::NonCacheable(result_body)
176 }
177 }
178}