Skip to main content

allowthem_server/
custom_fields.rs

1use std::collections::HashMap;
2
3use serde::Serialize;
4use serde_json::Value;
5
6/// Describes a custom field for template rendering.
7#[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
34/// Pre-compiled schema + validator + field descriptors.
35pub struct CustomSchemaConfig {
36    pub schema: Value,
37    pub validator: jsonschema::Validator,
38    pub fields: Vec<CustomFieldDescriptor>,
39}
40
41/// Validate that a schema is a flat object (no nested objects/arrays in properties).
42///
43/// The schema must have `"type": "object"` and a `"properties"` map where each
44/// property's type is a scalar (`string`, `integer`, `number`, `boolean`).
45/// Returns `Err` with a description if the schema is unsuitable.
46pub 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(()), // no properties is valid (empty form)
60    };
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
86/// Extract field descriptors from a JSON Schema for template rendering.
87///
88/// Properties are iterated in insertion order (requires `preserve_order`
89/// feature on `serde_json`).
90pub 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
170/// Extract custom_data fields from form body, coerce types per schema, return as Value.
171///
172/// HTML forms submit everything as strings. This function:
173/// - Looks for keys starting with `custom_data[` and strips the prefix/suffix.
174/// - Coerces values based on the schema property type.
175/// - For booleans, iterates schema properties so that absent checkboxes map to `false`.
176/// - Omits empty optional string fields from the result.
177pub 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    // Build a map of custom_data[key] -> value from the form submission
198    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                // Checkbox: present means true, absent means false
220                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                        // Skip empty optional fields
229                        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                        // Store as string; validation will catch the type mismatch
236                        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                // string types
259                if let Some(raw) = custom_values.get(name.as_str()) {
260                    if raw.is_empty() && !required_set.contains(&name.as_str()) {
261                        // Omit empty optional strings
262                    } else {
263                        result.insert(name.clone(), Value::String((*raw).to_string()));
264                    }
265                }
266            }
267        }
268    }
269
270    Value::Object(result)
271}
272
273/// Map jsonschema validation errors to field-level error messages.
274///
275/// Each error is mapped to `(field_name, message)`. For root-level errors
276/// (e.g., missing required fields), the field name is extracted from the
277/// instance path.
278pub 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            // instance_path is a JSON pointer like "/field_name"
286            let field = path.strip_prefix('/').unwrap_or(path);
287            (field.to_string(), err.to_string())
288        })
289        .collect()
290}
291
292/// Convert "snake_case" or "kebab-case" field names to title case labels.
293fn 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        // Auto-generated label from field name
448        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        // No custom_data[newsletter] in form — checkbox was unchecked
549        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        // bio is empty and optional, so it should be omitted
573        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        // Required field submitted as empty should still be included
591        // so that validation catches it
592        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        // Each error should have a field name and message
615        for (field, msg) in &formatted {
616            assert!(!msg.is_empty(), "error message should not be empty");
617            // field may be empty for root-level errors (like missing required)
618            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}