1use std::collections::HashMap;
2
3use serde::Serialize;
4use serde_json::Value;
5
6#[derive(Debug, Clone, Serialize)]
8pub struct CustomFieldDescriptor {
9 pub name: String,
10 pub label: String,
11 pub field_type: FieldType,
12 pub required: bool,
13 pub help_text: Option<String>,
14 pub min_length: Option<u64>,
15 pub max_length: Option<u64>,
16 pub minimum: Option<f64>,
17 pub maximum: Option<f64>,
18 pub default_value: Option<Value>,
19 pub enum_values: Option<Vec<Value>>,
20}
21
22#[derive(Debug, Clone, Serialize, PartialEq)]
23#[serde(rename_all = "snake_case")]
24pub enum FieldType {
25 Text,
26 Email,
27 Url,
28 Textarea,
29 Number,
30 Checkbox,
31 Select,
32}
33
34pub struct CustomSchemaConfig {
36 pub schema: Value,
37 pub validator: jsonschema::Validator,
38 pub fields: Vec<CustomFieldDescriptor>,
39}
40
41pub fn validate_custom_schema(schema: &Value) -> Result<(), String> {
47 let obj = schema
48 .as_object()
49 .ok_or_else(|| "schema must be a JSON object".to_string())?;
50
51 match obj.get("type").and_then(Value::as_str) {
52 Some("object") => {}
53 Some(other) => return Err(format!("schema type must be \"object\", got \"{other}\"")),
54 None => return Err("schema must have \"type\": \"object\"".to_string()),
55 }
56
57 let props = match obj.get("properties").and_then(Value::as_object) {
58 Some(p) => p,
59 None => return Ok(()), };
61
62 for (name, prop) in props {
63 let prop_obj = prop
64 .as_object()
65 .ok_or_else(|| format!("property \"{name}\" must be a JSON object"))?;
66 if let Some(ty) = prop_obj.get("type").and_then(Value::as_str) {
67 match ty {
68 "string" | "integer" | "number" | "boolean" => {}
69 "object" | "array" => {
70 return Err(format!(
71 "property \"{name}\" has type \"{ty}\"; nested objects/arrays are not supported"
72 ));
73 }
74 other => {
75 return Err(format!(
76 "property \"{name}\" has unsupported type \"{other}\""
77 ));
78 }
79 }
80 }
81 }
82
83 Ok(())
84}
85
86pub fn extract_field_descriptors(schema: &Value) -> Vec<CustomFieldDescriptor> {
91 let obj = match schema.as_object() {
92 Some(o) => o,
93 None => return Vec::new(),
94 };
95
96 let props = match obj.get("properties").and_then(Value::as_object) {
97 Some(p) => p,
98 None => return Vec::new(),
99 };
100
101 let required_set: Vec<&str> = obj
102 .get("required")
103 .and_then(Value::as_array)
104 .map(|arr| arr.iter().filter_map(Value::as_str).collect())
105 .unwrap_or_default();
106
107 props
108 .iter()
109 .map(|(name, prop)| {
110 let prop_obj = prop.as_object();
111 let ty = prop_obj
112 .and_then(|o| o.get("type"))
113 .and_then(Value::as_str)
114 .unwrap_or("string");
115 let format = prop_obj
116 .and_then(|o| o.get("format"))
117 .and_then(Value::as_str);
118 let has_enum = prop_obj
119 .and_then(|o| o.get("enum"))
120 .and_then(Value::as_array)
121 .is_some();
122
123 let field_type = match (ty, format, has_enum) {
124 ("string", _, true) => FieldType::Select,
125 ("string", Some("email"), _) => FieldType::Email,
126 ("string", Some("uri"), _) => FieldType::Url,
127 ("string", Some("textarea"), _) => FieldType::Textarea,
128 ("integer" | "number", _, _) => FieldType::Number,
129 ("boolean", _, _) => FieldType::Checkbox,
130 _ => FieldType::Text,
131 };
132
133 let label = prop_obj
134 .and_then(|o| o.get("title"))
135 .and_then(Value::as_str)
136 .map(String::from)
137 .unwrap_or_else(|| title_case(name));
138
139 CustomFieldDescriptor {
140 name: name.clone(),
141 label,
142 field_type,
143 required: required_set.contains(&name.as_str()),
144 help_text: prop_obj
145 .and_then(|o| o.get("description"))
146 .and_then(Value::as_str)
147 .map(String::from),
148 min_length: prop_obj
149 .and_then(|o| o.get("minLength"))
150 .and_then(Value::as_u64),
151 max_length: prop_obj
152 .and_then(|o| o.get("maxLength"))
153 .and_then(Value::as_u64),
154 minimum: prop_obj
155 .and_then(|o| o.get("minimum"))
156 .and_then(Value::as_f64),
157 maximum: prop_obj
158 .and_then(|o| o.get("maximum"))
159 .and_then(Value::as_f64),
160 default_value: prop_obj.and_then(|o| o.get("default")).cloned(),
161 enum_values: prop_obj
162 .and_then(|o| o.get("enum"))
163 .and_then(Value::as_array)
164 .cloned(),
165 }
166 })
167 .collect()
168}
169
170pub fn extract_and_coerce_custom_data(
178 form_data: &HashMap<String, String>,
179 schema: &Value,
180) -> Value {
181 let props = match schema
182 .as_object()
183 .and_then(|o| o.get("properties"))
184 .and_then(Value::as_object)
185 {
186 Some(p) => p,
187 None => return Value::Object(serde_json::Map::new()),
188 };
189
190 let required_set: Vec<&str> = schema
191 .as_object()
192 .and_then(|o| o.get("required"))
193 .and_then(Value::as_array)
194 .map(|arr| arr.iter().filter_map(Value::as_str).collect())
195 .unwrap_or_default();
196
197 let mut custom_values: HashMap<&str, &str> = HashMap::new();
199 for (key, value) in form_data {
200 if let Some(field_name) = key
201 .strip_prefix("custom_data[")
202 .and_then(|s| s.strip_suffix(']'))
203 {
204 custom_values.insert(field_name, value.as_str());
205 }
206 }
207
208 let mut result = serde_json::Map::new();
209
210 for (name, prop) in props {
211 let ty = prop
212 .as_object()
213 .and_then(|o| o.get("type"))
214 .and_then(Value::as_str)
215 .unwrap_or("string");
216
217 match ty {
218 "boolean" => {
219 let checked = custom_values
221 .get(name.as_str())
222 .is_some_and(|v| !v.is_empty());
223 result.insert(name.clone(), Value::Bool(checked));
224 }
225 "integer" => {
226 if let Some(raw) = custom_values.get(name.as_str()) {
227 if raw.is_empty() {
228 if required_set.contains(&name.as_str()) {
230 result.insert(name.clone(), Value::Null);
231 }
232 } else if let Ok(n) = raw.parse::<i64>() {
233 result.insert(name.clone(), Value::Number(n.into()));
234 } else {
235 result.insert(name.clone(), Value::String((*raw).to_string()));
237 }
238 }
239 }
240 "number" => {
241 if let Some(raw) = custom_values.get(name.as_str()) {
242 if raw.is_empty() {
243 if required_set.contains(&name.as_str()) {
244 result.insert(name.clone(), Value::Null);
245 }
246 } else if let Ok(n) = raw.parse::<f64>() {
247 if let Some(num) = serde_json::Number::from_f64(n) {
248 result.insert(name.clone(), Value::Number(num));
249 } else {
250 result.insert(name.clone(), Value::String((*raw).to_string()));
251 }
252 } else {
253 result.insert(name.clone(), Value::String((*raw).to_string()));
254 }
255 }
256 }
257 _ => {
258 if let Some(raw) = custom_values.get(name.as_str()) {
260 if raw.is_empty() && !required_set.contains(&name.as_str()) {
261 } else {
263 result.insert(name.clone(), Value::String((*raw).to_string()));
264 }
265 }
266 }
267 }
268 }
269
270 Value::Object(result)
271}
272
273pub fn format_validation_errors(
279 errors: &[jsonschema::error::ValidationError<'_>],
280) -> Vec<(String, String)> {
281 errors
282 .iter()
283 .map(|err| {
284 let path = err.instance_path().as_str();
285 let field = path.strip_prefix('/').unwrap_or(path);
287 (field.to_string(), err.to_string())
288 })
289 .collect()
290}
291
292fn title_case(s: &str) -> String {
294 s.split(['_', '-'])
295 .filter(|w| !w.is_empty())
296 .map(|word| {
297 let mut chars = word.chars();
298 match chars.next() {
299 Some(first) => {
300 let upper: String = first.to_uppercase().collect();
301 let rest: String = chars.collect();
302 format!("{upper}{rest}")
303 }
304 None => String::new(),
305 }
306 })
307 .collect::<Vec<_>>()
308 .join(" ")
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use serde_json::json;
315
316 #[test]
317 fn validate_custom_schema_rejects_nested_objects() {
318 let schema = json!({
319 "type": "object",
320 "properties": {
321 "address": {
322 "type": "object",
323 "properties": {
324 "street": { "type": "string" }
325 }
326 }
327 }
328 });
329 let result = validate_custom_schema(&schema);
330 assert!(result.is_err());
331 assert!(result.unwrap_err().contains("nested objects/arrays"));
332 }
333
334 #[test]
335 fn validate_custom_schema_rejects_arrays() {
336 let schema = json!({
337 "type": "object",
338 "properties": {
339 "tags": {
340 "type": "array",
341 "items": { "type": "string" }
342 }
343 }
344 });
345 let result = validate_custom_schema(&schema);
346 assert!(result.is_err());
347 assert!(result.unwrap_err().contains("nested objects/arrays"));
348 }
349
350 #[test]
351 fn validate_custom_schema_accepts_flat_object() {
352 let schema = json!({
353 "type": "object",
354 "properties": {
355 "company": { "type": "string" },
356 "age": { "type": "integer" },
357 "score": { "type": "number" },
358 "active": { "type": "boolean" }
359 }
360 });
361 assert!(validate_custom_schema(&schema).is_ok());
362 }
363
364 #[test]
365 fn validate_custom_schema_rejects_non_object_type() {
366 let schema = json!({
367 "type": "string"
368 });
369 let result = validate_custom_schema(&schema);
370 assert!(result.is_err());
371 assert!(result.unwrap_err().contains("must be \"object\""));
372 }
373
374 #[test]
375 fn validate_custom_schema_accepts_empty_properties() {
376 let schema = json!({
377 "type": "object",
378 "properties": {}
379 });
380 assert!(validate_custom_schema(&schema).is_ok());
381 }
382
383 #[test]
384 fn validate_custom_schema_accepts_no_properties() {
385 let schema = json!({
386 "type": "object"
387 });
388 assert!(validate_custom_schema(&schema).is_ok());
389 }
390
391 #[test]
392 fn extract_field_descriptors_produces_correct_types() {
393 let schema = json!({
394 "type": "object",
395 "required": ["email", "company"],
396 "properties": {
397 "company": {
398 "type": "string",
399 "title": "Company Name",
400 "description": "Your company"
401 },
402 "contact_email": {
403 "type": "string",
404 "format": "email"
405 },
406 "website": {
407 "type": "string",
408 "format": "uri"
409 },
410 "bio": {
411 "type": "string",
412 "format": "textarea",
413 "maxLength": 500
414 },
415 "age": {
416 "type": "integer",
417 "minimum": 0,
418 "maximum": 150
419 },
420 "score": {
421 "type": "number"
422 },
423 "newsletter": {
424 "type": "boolean"
425 },
426 "plan": {
427 "type": "string",
428 "enum": ["free", "pro", "enterprise"]
429 }
430 }
431 });
432
433 let fields = extract_field_descriptors(&schema);
434 assert_eq!(fields.len(), 8);
435
436 let company = &fields[0];
437 assert_eq!(company.name, "company");
438 assert_eq!(company.label, "Company Name");
439 assert_eq!(company.field_type, FieldType::Text);
440 assert!(company.required);
441 assert_eq!(company.help_text.as_deref(), Some("Your company"));
442
443 let contact = &fields[1];
444 assert_eq!(contact.name, "contact_email");
445 assert_eq!(contact.field_type, FieldType::Email);
446 assert!(!contact.required);
447 assert_eq!(contact.label, "Contact Email");
449
450 let website = &fields[2];
451 assert_eq!(website.field_type, FieldType::Url);
452
453 let bio = &fields[3];
454 assert_eq!(bio.field_type, FieldType::Textarea);
455 assert_eq!(bio.max_length, Some(500));
456
457 let age = &fields[4];
458 assert_eq!(age.field_type, FieldType::Number);
459 assert_eq!(age.minimum, Some(0.0));
460 assert_eq!(age.maximum, Some(150.0));
461
462 let score = &fields[5];
463 assert_eq!(score.field_type, FieldType::Number);
464
465 let newsletter = &fields[6];
466 assert_eq!(newsletter.field_type, FieldType::Checkbox);
467
468 let plan = &fields[7];
469 assert_eq!(plan.field_type, FieldType::Select);
470 assert!(plan.enum_values.is_some());
471 assert_eq!(plan.enum_values.as_ref().map(|v| v.len()), Some(3));
472 }
473
474 #[test]
475 fn extract_and_coerce_string_fields() {
476 let schema = json!({
477 "type": "object",
478 "properties": {
479 "company": { "type": "string" }
480 }
481 });
482
483 let mut form = HashMap::new();
484 form.insert("custom_data[company]".to_string(), "Acme Corp".to_string());
485
486 let result = extract_and_coerce_custom_data(&form, &schema);
487 assert_eq!(result["company"], "Acme Corp");
488 }
489
490 #[test]
491 fn extract_and_coerce_integer_fields() {
492 let schema = json!({
493 "type": "object",
494 "properties": {
495 "age": { "type": "integer" }
496 }
497 });
498
499 let mut form = HashMap::new();
500 form.insert("custom_data[age]".to_string(), "25".to_string());
501
502 let result = extract_and_coerce_custom_data(&form, &schema);
503 assert_eq!(result["age"], 25);
504 assert!(result["age"].is_i64());
505 }
506
507 #[test]
508 fn extract_and_coerce_number_fields() {
509 let schema = json!({
510 "type": "object",
511 "properties": {
512 "score": { "type": "number" }
513 }
514 });
515
516 let mut form = HashMap::new();
517 form.insert("custom_data[score]".to_string(), "3.14".to_string());
518
519 let result = extract_and_coerce_custom_data(&form, &schema);
520 assert_eq!(result["score"], 3.14);
521 }
522
523 #[test]
524 fn extract_and_coerce_checkbox_present() {
525 let schema = json!({
526 "type": "object",
527 "properties": {
528 "newsletter": { "type": "boolean" }
529 }
530 });
531
532 let mut form = HashMap::new();
533 form.insert("custom_data[newsletter]".to_string(), "true".to_string());
534
535 let result = extract_and_coerce_custom_data(&form, &schema);
536 assert_eq!(result["newsletter"], true);
537 }
538
539 #[test]
540 fn extract_and_coerce_checkbox_absent_is_false() {
541 let schema = json!({
542 "type": "object",
543 "properties": {
544 "newsletter": { "type": "boolean" }
545 }
546 });
547
548 let form: HashMap<String, String> = HashMap::new();
550
551 let result = extract_and_coerce_custom_data(&form, &schema);
552 assert_eq!(result["newsletter"], false);
553 }
554
555 #[test]
556 fn extract_omits_empty_optional_strings() {
557 let schema = json!({
558 "type": "object",
559 "required": ["name"],
560 "properties": {
561 "name": { "type": "string" },
562 "bio": { "type": "string" }
563 }
564 });
565
566 let mut form = HashMap::new();
567 form.insert("custom_data[name]".to_string(), "Alice".to_string());
568 form.insert("custom_data[bio]".to_string(), String::new());
569
570 let result = extract_and_coerce_custom_data(&form, &schema);
571 assert_eq!(result["name"], "Alice");
572 assert!(result.get("bio").is_none());
574 }
575
576 #[test]
577 fn extract_includes_empty_required_strings() {
578 let schema = json!({
579 "type": "object",
580 "required": ["name"],
581 "properties": {
582 "name": { "type": "string" }
583 }
584 });
585
586 let mut form = HashMap::new();
587 form.insert("custom_data[name]".to_string(), String::new());
588
589 let result = extract_and_coerce_custom_data(&form, &schema);
590 assert_eq!(result["name"], "");
593 }
594
595 #[test]
596 fn format_validation_errors_maps_to_fields() {
597 let schema = json!({
598 "type": "object",
599 "required": ["name"],
600 "properties": {
601 "name": { "type": "string", "minLength": 1 },
602 "age": { "type": "integer", "minimum": 0 }
603 }
604 });
605
606 let validator = jsonschema::validator_for(&schema).expect("valid schema");
607 let instance = json!({ "age": -1 });
608
609 let errors: Vec<_> = validator.iter_errors(&instance).collect();
610 assert!(!errors.is_empty());
611
612 let formatted = format_validation_errors(&errors);
613 assert!(!formatted.is_empty());
614 for (field, msg) in &formatted {
616 assert!(!msg.is_empty(), "error message should not be empty");
617 let _ = field;
619 }
620 }
621
622 #[test]
623 fn title_case_conversion() {
624 assert_eq!(title_case("company_name"), "Company Name");
625 assert_eq!(title_case("contact-email"), "Contact Email");
626 assert_eq!(title_case("simple"), "Simple");
627 assert_eq!(title_case("already_Good"), "Already Good");
628 }
629
630 #[test]
631 fn extract_ignores_non_custom_data_keys() {
632 let schema = json!({
633 "type": "object",
634 "properties": {
635 "company": { "type": "string" }
636 }
637 });
638
639 let mut form = HashMap::new();
640 form.insert("email".to_string(), "test@example.com".to_string());
641 form.insert("password".to_string(), "secret".to_string());
642 form.insert("custom_data[company]".to_string(), "Acme".to_string());
643
644 let result = extract_and_coerce_custom_data(&form, &schema);
645 let obj = result.as_object().expect("should be object");
646 assert_eq!(obj.len(), 1);
647 assert_eq!(result["company"], "Acme");
648 }
649}