1use std::collections::BTreeMap;
10
11use serde_json::{Map, Value};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Widget {
16 Text,
17 Email,
18 Number,
19 Select,
20 Textarea,
21 Checkbox,
22 Password,
24 Hidden,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Option_ {
32 pub value: String,
33 pub label: String,
34}
35
36#[derive(Debug, Clone)]
38pub struct Field {
39 pub name: String,
41 pub label: String,
42 pub widget: Widget,
43 pub required: bool,
44 pub placeholder: Option<String>,
45 pub help: Option<String>,
46 pub options: Vec<Option_>,
47 pub json_type: JsonType,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum JsonType {
54 String,
55 Integer,
56 Number,
57 Boolean,
58 Other,
60}
61
62#[must_use]
64pub fn fields_of(schema: &Value) -> Vec<Field> {
65 let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
66 return Vec::new();
67 };
68 let required: Vec<&str> = schema
69 .get("required")
70 .and_then(Value::as_array)
71 .map(|list| list.iter().filter_map(Value::as_str).collect())
72 .unwrap_or_default();
73 properties
74 .iter()
75 .map(|(name, property)| field_of(name, property, required.contains(&name.as_str())))
76 .collect()
77}
78
79fn field_of(name: &str, property: &Value, required: bool) -> Field {
80 let json_type = json_type_of(property);
81 let hint = |key: &str| property.get(key);
82 let hidden = hint("x-cf-hidden")
83 .and_then(Value::as_bool)
84 .unwrap_or(false)
85 || json_type == JsonType::Other;
86 let options: Vec<Option_> = hint("x-cf-options")
87 .and_then(Value::as_array)
88 .map(|list| {
89 list.iter()
90 .filter_map(|item| {
91 let value = item.get("value").and_then(Value::as_str)?;
92 let label = item.get("label").and_then(Value::as_str).unwrap_or(value);
93 Some(Option_ {
94 value: value.to_owned(),
95 label: label.to_owned(),
96 })
97 })
98 .collect()
99 })
100 .or_else(|| {
101 property.get("enum").and_then(Value::as_array).map(|list| {
102 list.iter()
103 .filter_map(Value::as_str)
104 .map(|value| Option_ {
105 value: value.to_owned(),
106 label: humanize(value),
107 })
108 .collect()
109 })
110 })
111 .unwrap_or_default();
112 let widget = if hidden {
113 Widget::Hidden
114 } else {
115 match hint("x-cf-widget").and_then(Value::as_str) {
116 Some("email") => Widget::Email,
117 Some("select") => Widget::Select,
118 Some("textarea") => Widget::Textarea,
119 Some("checkbox") => Widget::Checkbox,
120 Some("number") => Widget::Number,
121 Some("password") => Widget::Password,
122 Some("text") => Widget::Text,
123 _ if !options.is_empty() => Widget::Select,
124 _ => match json_type {
125 JsonType::Boolean => Widget::Checkbox,
126 JsonType::Integer | JsonType::Number => Widget::Number,
127 _ if property.get("format").and_then(Value::as_str) == Some("email") => {
128 Widget::Email
129 }
130 _ => Widget::Text,
131 },
132 }
133 };
134 let text = |key: &str| hint(key).and_then(Value::as_str).map(str::to_owned);
135 Field {
136 name: name.to_owned(),
137 label: text("x-cf-label").unwrap_or_else(|| humanize(name)),
138 widget,
139 required,
140 placeholder: text("x-cf-placeholder"),
141 help: text("x-cf-help"),
142 options,
143 json_type,
144 }
145}
146
147fn json_type_of(property: &Value) -> JsonType {
148 let names: Vec<&str> = match property.get("type") {
149 Some(Value::String(one)) => vec![one.as_str()],
150 Some(Value::Array(many)) => many.iter().filter_map(Value::as_str).collect(),
151 _ => Vec::new(),
152 };
153 let first = names.iter().find(|t| **t != "null").copied();
154 match first {
155 Some("string") => JsonType::String,
156 Some("integer") => JsonType::Integer,
157 Some("number") => JsonType::Number,
158 Some("boolean") => JsonType::Boolean,
159 _ => JsonType::Other,
162 }
163}
164
165#[must_use]
167pub fn humanize(name: &str) -> String {
168 let mut out = String::with_capacity(name.len() + 4);
169 for (i, ch) in name.chars().enumerate() {
170 if ch == '_' || ch == '-' {
171 out.push(' ');
172 } else if ch.is_ascii_uppercase() && i > 0 {
173 out.push(' ');
174 out.push(ch.to_ascii_lowercase());
175 } else if i == 0 {
176 out.extend(ch.to_uppercase());
177 } else {
178 out.push(ch);
179 }
180 }
181 out
182}
183
184pub type Values = BTreeMap<String, String>;
187
188#[must_use]
194pub fn form_to_json(fields: &[Field], values: &Values) -> Map<String, Value> {
195 let mut object = Map::new();
196 for field in fields {
197 let raw = values.get(&field.name).map(String::as_str);
198 match field.json_type {
199 JsonType::Boolean => {
200 let checked = raw.is_some_and(|v| matches!(v, "on" | "true" | "1"));
201 if checked || field.required {
202 object.insert(field.name.clone(), Value::Bool(checked));
203 }
204 }
205 JsonType::Integer => {
206 if let Some(raw) = raw.filter(|v| !v.is_empty()) {
207 let value = raw
208 .parse::<i64>()
209 .map_or_else(|_| Value::String(raw.to_owned()), Value::from);
210 object.insert(field.name.clone(), value);
211 }
212 }
213 JsonType::Number => {
214 if let Some(raw) = raw.filter(|v| !v.is_empty()) {
215 let value = raw
216 .parse::<f64>()
217 .ok()
218 .and_then(serde_json::Number::from_f64)
219 .map_or_else(|| Value::String(raw.to_owned()), Value::Number);
220 object.insert(field.name.clone(), value);
221 }
222 }
223 JsonType::String => {
224 if let Some(raw) = raw
225 && (!raw.is_empty() || field.required)
226 {
227 object.insert(field.name.clone(), Value::String(raw.to_owned()));
228 }
229 }
230 JsonType::Other => {
231 if let Some(parsed) = raw.and_then(|v| serde_json::from_str::<Value>(v).ok()) {
234 object.insert(field.name.clone(), parsed);
235 }
236 }
237 }
238 }
239 object
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use schemars::JsonSchema;
246
247 #[derive(JsonSchema)]
248 #[allow(dead_code)]
249 struct Body {
250 #[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
251 email: String,
252 product: String,
253 count: Option<u32>,
254 agree: bool,
255 #[schemars(extend("x-cf-hidden" = true))]
256 #[serde(rename = "captchaToken")]
257 captcha_token: Option<String>,
258 answers: Option<serde_json::Value>,
259 }
260
261 fn schema() -> Value {
262 let mut schema = cratefield_core::schema_for::<Body>();
263 cratefield_core::hint_field(
264 &mut schema,
265 "product",
266 "enum",
267 serde_json::json!(["a", "b-c"]),
268 );
269 schema.to_value()
270 }
271
272 #[test]
273 fn fields_follow_struct_order_and_infer_widgets() {
274 let fields = fields_of(&schema());
275 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
276 assert_eq!(
277 names,
278 [
279 "email",
280 "product",
281 "count",
282 "agree",
283 "captchaToken",
284 "answers"
285 ]
286 );
287 assert_eq!(fields[0].widget, Widget::Email);
288 assert!(fields[0].required);
289 assert_eq!(fields[0].label, "Email");
290 assert_eq!(fields[1].widget, Widget::Select);
291 assert_eq!(fields[1].options[1].label, "B c");
292 assert_eq!(fields[2].widget, Widget::Number);
293 assert!(!fields[2].required);
294 assert_eq!(fields[3].widget, Widget::Checkbox);
295 assert_eq!(fields[4].widget, Widget::Hidden);
296 assert_eq!(fields[4].label, "Captcha token");
297 assert_eq!(fields[5].widget, Widget::Hidden, "untyped value is hidden");
298 }
299
300 #[test]
301 fn form_to_json_respects_types_and_omits_empties() {
302 let fields = fields_of(&schema());
303 let values: Values = [
304 ("email", "a@b.co"),
305 ("product", "a"),
306 ("count", "3"),
307 ("agree", "on"),
308 ("captchaToken", ""),
309 ("answers", r#"{"q":"x"}"#),
310 ("unknown", "dropped"),
311 ]
312 .into_iter()
313 .map(|(k, v)| (k.to_owned(), v.to_owned()))
314 .collect();
315 let json = Value::Object(form_to_json(&fields, &values));
316 assert_eq!(
317 json,
318 serde_json::json!({
319 "email": "a@b.co", "product": "a", "count": 3, "agree": true,
320 "answers": {"q": "x"}
321 })
322 );
323
324 let empty: Values = Values::new();
325 let json = Value::Object(form_to_json(&fields, &empty));
326 assert_eq!(json, serde_json::json!({ "agree": false }));
327
328 let bad: Values = [("count".to_owned(), "lots".to_owned())].into();
329 let json = Value::Object(form_to_json(&fields, &bad));
330 assert_eq!(json["count"], "lots", "unparsable number goes through raw");
331 }
332
333 #[test]
334 fn humanize_splits_snake_kebab_and_camel() {
335 assert_eq!(humanize("referral_code"), "Referral code");
336 assert_eq!(humanize("captchaToken"), "Captcha token");
337 assert_eq!(humanize("email-signup"), "Email signup");
338 assert_eq!(humanize("join"), "Join");
339 }
340}