fraiseql_core/validation/
input_processor.rs1use std::collections::HashSet;
10
11use serde_json::{Map, Value};
12
13use super::id_policy::{IDPolicy, validate_id};
14
15#[derive(Debug, Clone)]
17pub struct InputProcessingConfig {
18 pub id_policy: IDPolicy,
20
21 pub validate_ids: bool,
23
24 pub id_field_names: HashSet<String>,
27}
28
29impl Default for InputProcessingConfig {
30 fn default() -> Self {
31 Self {
32 id_policy: IDPolicy::default(),
33 validate_ids: true,
34 id_field_names: Self::default_id_field_names(),
35 }
36 }
37}
38
39impl InputProcessingConfig {
40 fn default_id_field_names() -> HashSet<String> {
42 [
43 "id",
44 "userId",
45 "user_id",
46 "postId",
47 "post_id",
48 "commentId",
49 "comment_id",
50 "authorId",
51 "author_id",
52 "ownerId",
53 "owner_id",
54 "creatorId",
55 "creator_id",
56 "tenantId",
57 "tenant_id",
58 ]
59 .iter()
60 .map(|s| (*s).to_string())
61 .collect()
62 }
63
64 pub fn add_id_field(&mut self, field_name: String) {
66 self.id_field_names.insert(field_name);
67 }
68
69 #[must_use]
71 pub fn strict_uuid() -> Self {
72 Self {
73 id_policy: IDPolicy::UUID,
74 validate_ids: true,
75 id_field_names: Self::default_id_field_names(),
76 }
77 }
78
79 #[must_use]
81 pub fn opaque() -> Self {
82 Self {
83 id_policy: IDPolicy::OPAQUE,
84 validate_ids: false, id_field_names: Self::default_id_field_names(),
86 }
87 }
88}
89
90pub fn process_variables(
124 variables: &Value,
125 config: &InputProcessingConfig,
126) -> Result<Value, ProcessingError> {
127 if !config.validate_ids {
128 return Ok(variables.clone());
129 }
130
131 match variables {
132 Value::Object(obj) => {
133 let mut result = Map::new();
134
135 for (key, value) in obj {
136 let processed_value = process_value(value, config, key)?;
137 result.insert(key.clone(), processed_value);
138 }
139
140 Ok(Value::Object(result))
141 },
142 Value::Null => Ok(Value::Null),
143 other => Ok(other.clone()),
144 }
145}
146
147fn process_value(
149 value: &Value,
150 config: &InputProcessingConfig,
151 field_name: &str,
152) -> Result<Value, ProcessingError> {
153 match value {
154 Value::String(s)
157 if {
158 let base_field = field_name.split('[').next().unwrap_or(field_name);
159 config.id_field_names.contains(base_field)
160 } =>
161 {
162 validate_id(s, config.id_policy).map_err(|e| ProcessingError {
163 field_path: field_name.to_string(),
164 reason: format!("Invalid ID value: {e}"),
165 })?;
166 Ok(Value::String(s.clone()))
167 },
168
169 Value::Object(obj) => {
171 let mut result = Map::new();
172
173 for (key, nested_value) in obj {
174 let processed = process_value(nested_value, config, key)?;
175 result.insert(key.clone(), processed);
176 }
177
178 Ok(Value::Object(result))
179 },
180
181 Value::Array(arr) => {
183 let processed_items: Result<Vec<_>, _> = arr
184 .iter()
185 .enumerate()
186 .map(|(idx, item)| {
187 let array_field = format!("{field_name}[{idx}]");
188 process_value(item, config, &array_field)
189 })
190 .collect();
191
192 Ok(Value::Array(processed_items?))
193 },
194
195 other => Ok(other.clone()),
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ProcessingError {
203 pub field_path: String,
205 pub reason: String,
207}
208
209impl std::fmt::Display for ProcessingError {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 write!(f, "Error in field '{}': {}", self.field_path, self.reason)
212 }
213}
214
215impl std::error::Error for ProcessingError {}
216
217#[cfg(test)]
218mod tests {
219 #![allow(clippy::unwrap_used)] use serde_json::json;
222
223 use super::*;
224
225 #[test]
226 fn test_process_valid_uuid_id() {
227 let config = InputProcessingConfig::strict_uuid();
228 let variables = json!({
229 "userId": "550e8400-e29b-41d4-a716-446655440000"
230 });
231
232 let result = process_variables(&variables, &config);
233 result.unwrap_or_else(|e| panic!("valid UUID should pass: {e}"));
234 }
235
236 #[test]
237 fn test_process_invalid_uuid_id() {
238 let config = InputProcessingConfig::strict_uuid();
239 let variables = json!({
240 "userId": "invalid-id"
241 });
242
243 let result = process_variables(&variables, &config);
244 let err = result.expect_err("invalid UUID should fail validation");
245 assert!(
246 err.field_path.contains("userId"),
247 "expected field_path to contain 'userId', got: {}",
248 err.field_path
249 );
250 }
251
252 #[test]
253 fn test_process_multiple_ids() {
254 let config = InputProcessingConfig::strict_uuid();
255 let variables = json!({
256 "userId": "550e8400-e29b-41d4-a716-446655440000",
257 "postId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
258 "name": "John"
259 });
260
261 let result = process_variables(&variables, &config);
262 result.unwrap_or_else(|e| panic!("multiple valid UUIDs should pass: {e}"));
263 }
264
265 #[test]
266 fn test_process_nested_ids() {
267 let config = InputProcessingConfig::strict_uuid();
268 let variables = json!({
269 "input": {
270 "userId": "550e8400-e29b-41d4-a716-446655440000",
271 "profile": {
272 "authorId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
273 }
274 }
275 });
276
277 let result = process_variables(&variables, &config);
278 result.unwrap_or_else(|e| panic!("nested valid UUIDs should pass: {e}"));
279 }
280
281 #[test]
282 fn test_process_nested_invalid_id() {
283 let config = InputProcessingConfig::strict_uuid();
284 let variables = json!({
285 "input": {
286 "userId": "550e8400-e29b-41d4-a716-446655440000",
287 "profile": {
288 "authorId": "invalid"
289 }
290 }
291 });
292
293 let result = process_variables(&variables, &config);
294 let err = result.expect_err("nested invalid UUID should fail");
295 assert!(
296 err.field_path.contains("authorId"),
297 "expected field_path to contain 'authorId', got: {}",
298 err.field_path
299 );
300 }
301
302 #[test]
303 fn test_process_array_of_ids() {
304 let config = InputProcessingConfig::strict_uuid();
305 let variables = json!({
306 "userIds": [
307 "550e8400-e29b-41d4-a716-446655440000",
308 "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
309 ]
310 });
311
312 let result = process_variables(&variables, &config);
313 result.unwrap_or_else(|e| panic!("array of valid UUIDs should pass: {e}"));
314 }
315
316 #[test]
317 fn test_process_array_with_invalid_id() {
318 let mut config = InputProcessingConfig::strict_uuid();
319 config.add_id_field("userIds".to_string());
321 let variables = json!({
322 "userIds": [
323 "550e8400-e29b-41d4-a716-446655440000",
324 "invalid-id"
325 ]
326 });
327
328 let result = process_variables(&variables, &config);
329 let err = result.expect_err("array with invalid UUID should fail");
330 assert!(
331 err.field_path.contains("userIds"),
332 "expected field_path to contain 'userIds', got: {}",
333 err.field_path
334 );
335 }
336
337 #[test]
338 fn test_opaque_policy_accepts_any_id() {
339 let config = InputProcessingConfig::opaque();
340 let variables = json!({
341 "userId": "any-string-here"
342 });
343
344 let result = process_variables(&variables, &config);
345 result.unwrap_or_else(|e| panic!("opaque policy should accept any ID: {e}"));
346 }
347
348 #[test]
349 fn test_disabled_validation_skips_checks() {
350 let mut config = InputProcessingConfig::strict_uuid();
351 config.validate_ids = false;
352
353 let variables = json!({
354 "userId": "invalid-id"
355 });
356
357 let result = process_variables(&variables, &config);
358 result.unwrap_or_else(|e| panic!("disabled validation should skip checks: {e}"));
359 }
360
361 #[test]
362 fn test_custom_id_field_names() {
363 let mut config = InputProcessingConfig::strict_uuid();
364 config.add_id_field("customId".to_string());
365
366 let variables = json!({
367 "customId": "550e8400-e29b-41d4-a716-446655440000"
368 });
369
370 let result = process_variables(&variables, &config);
371 result.unwrap_or_else(|e| panic!("custom ID field with valid UUID should pass: {e}"));
372 }
373
374 #[test]
375 fn test_process_null_variables() {
376 let config = InputProcessingConfig::strict_uuid();
377 let result = process_variables(&Value::Null, &config);
378 let value = result.unwrap_or_else(|e| panic!("null variables should pass: {e}"));
379 assert!(value.is_null(), "expected null output, got: {value:?}");
380 }
381
382 #[test]
383 fn test_non_id_fields_pass_through() {
384 let config = InputProcessingConfig::strict_uuid();
385 let variables = json!({
386 "name": "not-a-uuid",
387 "email": "invalid-format@email",
388 "age": 25
389 });
390
391 let result = process_variables(&variables, &config);
392 result.unwrap_or_else(|e| {
393 panic!("non-ID fields should pass through without validation: {e}")
394 });
395 }
396}