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 use serde_json::json;
220
221 use super::*;
222
223 #[test]
224 fn test_process_valid_uuid_id() {
225 let config = InputProcessingConfig::strict_uuid();
226 let variables = json!({
227 "userId": "550e8400-e29b-41d4-a716-446655440000"
228 });
229
230 let result = process_variables(&variables, &config);
231 assert!(result.is_ok());
232 }
233
234 #[test]
235 fn test_process_invalid_uuid_id() {
236 let config = InputProcessingConfig::strict_uuid();
237 let variables = json!({
238 "userId": "invalid-id"
239 });
240
241 let result = process_variables(&variables, &config);
242 assert!(result.is_err());
243 let err = result.unwrap_err();
244 assert!(err.field_path.contains("userId"));
245 }
246
247 #[test]
248 fn test_process_multiple_ids() {
249 let config = InputProcessingConfig::strict_uuid();
250 let variables = json!({
251 "userId": "550e8400-e29b-41d4-a716-446655440000",
252 "postId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
253 "name": "John"
254 });
255
256 let result = process_variables(&variables, &config);
257 assert!(result.is_ok());
258 }
259
260 #[test]
261 fn test_process_nested_ids() {
262 let config = InputProcessingConfig::strict_uuid();
263 let variables = json!({
264 "input": {
265 "userId": "550e8400-e29b-41d4-a716-446655440000",
266 "profile": {
267 "authorId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
268 }
269 }
270 });
271
272 let result = process_variables(&variables, &config);
273 assert!(result.is_ok());
274 }
275
276 #[test]
277 fn test_process_nested_invalid_id() {
278 let config = InputProcessingConfig::strict_uuid();
279 let variables = json!({
280 "input": {
281 "userId": "550e8400-e29b-41d4-a716-446655440000",
282 "profile": {
283 "authorId": "invalid"
284 }
285 }
286 });
287
288 let result = process_variables(&variables, &config);
289 assert!(result.is_err());
290 }
291
292 #[test]
293 fn test_process_array_of_ids() {
294 let config = InputProcessingConfig::strict_uuid();
295 let variables = json!({
296 "userIds": [
297 "550e8400-e29b-41d4-a716-446655440000",
298 "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
299 ]
300 });
301
302 let result = process_variables(&variables, &config);
303 assert!(result.is_ok());
304 }
305
306 #[test]
307 fn test_process_array_with_invalid_id() {
308 let mut config = InputProcessingConfig::strict_uuid();
309 config.add_id_field("userIds".to_string());
311 let variables = json!({
312 "userIds": [
313 "550e8400-e29b-41d4-a716-446655440000",
314 "invalid-id"
315 ]
316 });
317
318 let result = process_variables(&variables, &config);
319 assert!(result.is_err());
320 }
321
322 #[test]
323 fn test_opaque_policy_accepts_any_id() {
324 let config = InputProcessingConfig::opaque();
325 let variables = json!({
326 "userId": "any-string-here"
327 });
328
329 let result = process_variables(&variables, &config);
330 assert!(result.is_ok());
331 }
332
333 #[test]
334 fn test_disabled_validation_skips_checks() {
335 let mut config = InputProcessingConfig::strict_uuid();
336 config.validate_ids = false;
337
338 let variables = json!({
339 "userId": "invalid-id"
340 });
341
342 let result = process_variables(&variables, &config);
343 assert!(result.is_ok());
344 }
345
346 #[test]
347 fn test_custom_id_field_names() {
348 let mut config = InputProcessingConfig::strict_uuid();
349 config.add_id_field("customId".to_string());
350
351 let variables = json!({
352 "customId": "550e8400-e29b-41d4-a716-446655440000"
353 });
354
355 let result = process_variables(&variables, &config);
356 assert!(result.is_ok());
357 }
358
359 #[test]
360 fn test_process_null_variables() {
361 let config = InputProcessingConfig::strict_uuid();
362 let result = process_variables(&Value::Null, &config);
363 assert!(result.is_ok());
364 assert!(result.unwrap().is_null());
365 }
366
367 #[test]
368 fn test_non_id_fields_pass_through() {
369 let config = InputProcessingConfig::strict_uuid();
370 let variables = json!({
371 "name": "not-a-uuid",
372 "email": "invalid-format@email",
373 "age": 25
374 });
375
376 let result = process_variables(&variables, &config);
377 assert!(result.is_ok());
378 }
379}