1use std::borrow::Cow;
2use std::collections::BTreeMap;
3
4use serde_json::{Number, Value};
5
6use crate::errors::{HaysonValidationError, ValidationIssue};
7
8use super::content_hash::{is_physical_path, physical_path_of};
9use super::timestamp::parse_iso_timestamp;
10use super::types::{DefaultValue, FieldSpec, FieldType, TableSchema, META_PREFIX};
11
12fn virtual_path_of(source: &serde_json::Map<String, Value>) -> Result<String, String> {
13 match source.get("virtualPath") {
14 Some(Value::String(s)) if s.starts_with('/') && s.len() > 1 => Ok(s.clone()),
15 _ => Err("expected \"virtualPath\" to be an absolute path string".into()),
16 }
17}
18
19fn coerce_markdown(value: &Value, addressable: bool) -> Result<Value, String> {
24 let source = value
25 .as_object()
26 .ok_or("expected an object with \"content\" and \"virtualPath\"")?;
27 for key in source.keys() {
28 if key != "content" && key != "virtualPath" {
29 return Err(format!("has an unknown key \"{key}\""));
30 }
31 }
32 let content = match source.get("content") {
33 Some(Value::String(s)) => s.clone(),
34 _ => return Err("expected \"content\" to be a string".into()),
35 };
36 let mut coerced = serde_json::Map::new();
37 coerced.insert("content".into(), Value::String(content));
38 if addressable || source.contains_key("virtualPath") {
39 coerced.insert("virtualPath".into(), Value::String(virtual_path_of(source)?));
40 }
41 Ok(Value::Object(coerced))
42}
43
44fn coerce_image(value: &Value) -> Result<Value, String> {
45 let source = value
46 .as_object()
47 .ok_or("expected an object with \"content\" and \"virtualPath\"")?;
48 if source.contains_key("physicalPath") {
51 for key in source.keys() {
52 if key != "physicalPath" && key != "virtualPath" {
53 return Err(format!("has an unknown key \"{key}\""));
54 }
55 }
56 let physical_path = match source.get("physicalPath") {
57 Some(Value::String(s)) if is_physical_path(s) => s.clone(),
58 Some(Value::String(_)) => {
59 return Err(
60 "expected \"physicalPath\" to be a content-hash file name this engine wrote (\"<16 hex digits>.bin\") — pass the image bytes as \"content\" instead of naming a file"
61 .into(),
62 )
63 }
64 _ => return Err("expected \"physicalPath\" to be a string".into()),
65 };
66 let virtual_path = virtual_path_of(source)?;
67 let mut coerced = serde_json::Map::new();
68 coerced.insert("physicalPath".into(), Value::String(physical_path));
69 coerced.insert("virtualPath".into(), Value::String(virtual_path));
70 return Ok(Value::Object(coerced));
71 }
72 for key in source.keys() {
73 if key != "content" && key != "virtualPath" {
74 return Err(format!("has an unknown key \"{key}\""));
75 }
76 }
77 let content = match source.get("content") {
78 Some(Value::String(s)) => s.clone(),
79 _ => return Err("expected \"content\" to be a string".into()),
80 };
81 let virtual_path = virtual_path_of(source)?;
82 let decoded = super::content_hash::decode_image_content(&content)?;
83 let physical_path = physical_path_of(&decoded);
84 let mut coerced = serde_json::Map::new();
85 coerced.insert("physicalPath".into(), Value::String(physical_path));
86 coerced.insert("virtualPath".into(), Value::String(virtual_path));
87 Ok(Value::Object(coerced))
88}
89
90pub type HaysonRecord = BTreeMap<String, Value>;
91
92#[derive(Debug, Clone, Copy, Default)]
93pub struct ValidateOptions {
94 pub partial: bool,
95 pub now: Option<i64>,
96}
97
98fn is_json_safe(value: &Value, depth: u32) -> bool {
99 if depth > 32 {
100 return false;
101 }
102 match value {
103 Value::Null | Value::String(_) | Value::Bool(_) => true,
104 Value::Number(n) => n.as_f64().is_some_and(f64::is_finite),
105 Value::Array(items) => items.iter().all(|item| is_json_safe(item, depth + 1)),
106 Value::Object(map) => map.values().all(|item| is_json_safe(item, depth + 1)),
107 }
108}
109
110fn coerce_scalar(field_type: FieldType, values: Option<&[String]>, value: &Value) -> Result<Value, String> {
111 match field_type {
112 FieldType::String => match value {
113 Value::String(_) => Ok(value.clone()),
114 _ => Err("expected a string".into()),
115 },
116 FieldType::Int => match value {
117 Value::Number(n) if n.as_f64().is_some_and(|f| f.is_finite() && f.fract() == 0.0) => Ok(value.clone()),
118 _ => Err("expected an integer".into()),
119 },
120 FieldType::Float => match value.as_f64() {
121 Some(n) if n.is_finite() => Ok(value.clone()),
122 _ => Err("expected a finite number".into()),
123 },
124 FieldType::Bool => match value {
125 Value::Bool(_) => Ok(value.clone()),
126 _ => Err("expected a boolean".into()),
127 },
128 FieldType::Timestamp => match value {
129 Value::Number(n) if n.as_f64().is_some_and(f64::is_finite) => {
130 Ok(Value::Number(Number::from(n.as_f64().unwrap().trunc() as i64)))
131 }
132 Value::String(s) => match parse_iso_timestamp(s) {
133 Some(ms) => Ok(Value::Number(Number::from(ms))),
134 None => Err("expected a valid date".into()),
135 },
136 _ => Err("expected epoch milliseconds or an ISO date string".into()),
137 },
138 FieldType::Enum => match value {
139 Value::String(s) if values.is_some_and(|v| v.iter().any(|item| item == s)) => Ok(value.clone()),
140 _ => Err(format!(
141 "expected one of {}",
142 values.map(|v| v.join(", ")).unwrap_or_default()
143 )),
144 },
145 FieldType::Json => {
146 if is_json_safe(value, 0) {
147 Ok(value.clone())
148 } else {
149 Err("expected JSON data".into())
150 }
151 }
152 FieldType::Image => coerce_image(value),
153 _ => Err(format!("unsupported type {}", field_type.as_str())),
154 }
155}
156
157fn coerce_field(spec: &FieldSpec, value: &Value) -> Result<Value, String> {
158 if value.is_null() {
159 return if spec.required {
160 Err("is required and must not be null".into())
161 } else {
162 Ok(Value::Null)
163 };
164 }
165 if spec.field_type == FieldType::Markdown {
167 return coerce_markdown(value, spec.addressable);
168 }
169 if !spec.field_type.is_array() {
170 return coerce_scalar(spec.field_type, spec.values.as_deref(), value);
171 }
172
173 let items = value.as_array().ok_or_else(|| {
174 format!(
175 "expected an array of {}",
176 spec.field_type.element_type().unwrap().as_str()
177 )
178 })?;
179 let element = spec.field_type.element_type().expect("array type has an element");
180 let mut coerced = Vec::with_capacity(items.len());
181 for (position, item) in items.iter().enumerate() {
182 let value = coerce_scalar(element, None, item).map_err(|reason| format!("item {position} {reason}"))?;
183 coerced.push(value);
184 }
185 Ok(Value::Array(coerced))
186}
187
188fn resolve_default(spec: &FieldSpec, now: i64) -> Value {
189 match &spec.default {
190 Some(DefaultValue::Now) => Value::Number(Number::from(now)),
191 Some(DefaultValue::Value(value)) => value.clone(),
192 None => Value::Null,
193 }
194}
195
196pub fn validate_record(
197 schema: &TableSchema,
198 input: &Value,
199 options: ValidateOptions,
200) -> Result<HaysonRecord, HaysonValidationError> {
201 let Some(source) = input.as_object() else {
202 return Err(HaysonValidationError {
203 table: schema.table.clone(),
204 issues: vec![ValidationIssue {
205 field: "(record)".into(),
206 reason: "expected an object".into(),
207 }],
208 });
209 };
210
211 let mut issues = Vec::new();
212 for name in source.keys() {
213 if name.starts_with(META_PREFIX) {
214 issues.push(ValidationIssue {
215 field: name.clone(),
216 reason: "uses the reserved \"_\" prefix".into(),
217 });
218 } else if !schema.fields.contains_key(name) {
219 issues.push(ValidationIssue {
220 field: name.clone(),
221 reason: "is not declared in the schema".into(),
222 });
223 }
224 }
225
226 let now = options.now.unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
227 let mut record = HaysonRecord::new();
228 for (name, spec) in &schema.fields {
229 match source.get(name) {
230 None => {
231 if options.partial {
232 continue;
233 }
234 if spec.default.is_some() {
235 record.insert(name.clone(), resolve_default(spec, now));
236 } else if spec.required {
237 issues.push(ValidationIssue {
238 field: name.clone(),
239 reason: "is required".into(),
240 });
241 }
242 }
243 Some(value) => match coerce_field(spec, value) {
244 Ok(coerced) => {
245 record.insert(name.clone(), coerced);
246 }
247 Err(reason) => issues.push(ValidationIssue {
248 field: name.clone(),
249 reason,
250 }),
251 },
252 }
253 }
254
255 if !issues.is_empty() {
256 return Err(HaysonValidationError {
257 table: schema.table.clone(),
258 issues,
259 });
260 }
261 Ok(record)
262}
263
264fn is_writable_field(schema: &TableSchema, name: &str) -> bool {
268 name.starts_with(META_PREFIX) || schema.fields.contains_key(name)
269}
270
271pub fn undeclared_fields(schema: &TableSchema, input: &Value) -> Vec<String> {
274 let Some(source) = input.as_object() else {
275 return Vec::new();
276 };
277 source
278 .keys()
279 .filter(|name| !is_writable_field(schema, name))
280 .cloned()
281 .collect()
282}
283
284pub fn without_undeclared<'a>(schema: &TableSchema, input: &'a Value) -> Cow<'a, Value> {
288 let Some(source) = input.as_object() else {
289 return Cow::Borrowed(input);
290 };
291 if source.keys().all(|name| is_writable_field(schema, name)) {
292 return Cow::Borrowed(input);
293 }
294 let kept: serde_json::Map<String, Value> = source
295 .iter()
296 .filter(|(name, _)| is_writable_field(schema, name))
297 .map(|(name, value)| (name.clone(), value.clone()))
298 .collect();
299 Cow::Owned(Value::Object(kept))
300}
301
302#[allow(dead_code)]
303pub fn project_to_schema(schema: &TableSchema, record: &HaysonRecord) -> HaysonRecord {
304 record
305 .iter()
306 .filter(|(name, _)| schema.fields.contains_key(*name))
307 .map(|(name, value)| (name.clone(), value.clone()))
308 .collect()
309}
310
311pub fn primary_key_of(schema: &TableSchema, record: &HaysonRecord) -> Result<String, HaysonValidationError> {
312 match record.get(&schema.primary_key) {
313 Some(Value::String(s)) => Ok(s.clone()),
314 Some(Value::Number(n)) => Ok(n.to_string()),
315 _ => Err(HaysonValidationError {
316 table: schema.table.clone(),
317 issues: vec![ValidationIssue {
318 field: schema.primary_key.clone(),
319 reason: "is required as the primary key".into(),
320 }],
321 }),
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use serde_json::json;
328
329 use super::*;
330 use crate::schema::parse::parse_schema;
331
332 fn users_schema() -> TableSchema {
333 parse_schema(&json!({
334 "schemaVersion": 1,
335 "table": "users",
336 "primaryKey": "id",
337 "fields": {
338 "id": { "type": "string" },
339 "name": { "type": "string", "required": true },
340 "age": { "type": "int" },
341 "createdAt": { "type": "timestamp", "default": "now" },
342 "role": { "type": "enum", "values": ["admin", "user"] },
343 "tags": { "type": "string[]" },
344 },
345 }))
346 .unwrap()
347 }
348
349 #[test]
350 fn fills_in_defaults_and_the_now_sentinel() {
351 let schema = users_schema();
352 let record = validate_record(
353 &schema,
354 &json!({ "id": "u1", "name": "Ania" }),
355 ValidateOptions {
356 partial: false,
357 now: Some(1000),
358 },
359 )
360 .unwrap();
361 assert_eq!(record.get("createdAt"), Some(&Value::from(1000)));
362 }
363
364 #[test]
365 fn rejects_a_required_field_missing_without_a_default() {
366 let schema = users_schema();
367 let err = validate_record(&schema, &json!({ "id": "u1" }), ValidateOptions::default()).unwrap_err();
368 assert!(err.issues.iter().any(|i| i.field == "name"));
369 }
370
371 #[test]
372 fn rejects_a_field_not_declared_in_the_schema() {
373 let schema = users_schema();
374 let err = validate_record(
375 &schema,
376 &json!({ "id": "u1", "name": "Ania", "unknown": 1 }),
377 ValidateOptions::default(),
378 )
379 .unwrap_err();
380 assert!(err.issues.iter().any(|i| i.field == "unknown"));
381 }
382
383 #[test]
384 fn rejects_a_field_using_the_reserved_meta_prefix() {
385 let schema = users_schema();
386 let err = validate_record(
387 &schema,
388 &json!({ "id": "u1", "name": "Ania", "_seq": 1 }),
389 ValidateOptions::default(),
390 )
391 .unwrap_err();
392 assert!(err.issues.iter().any(|i| i.field == "_seq"));
393 }
394
395 #[test]
396 fn rejects_a_non_integer_for_an_int_field() {
397 let schema = users_schema();
398 let err = validate_record(
399 &schema,
400 &json!({ "id": "u1", "name": "Ania", "age": 1.5 }),
401 ValidateOptions::default(),
402 )
403 .unwrap_err();
404 assert!(err.issues.iter().any(|i| i.field == "age"));
405 }
406
407 #[test]
408 fn accepts_an_iso_timestamp_and_converts_it_to_epoch_millis() {
409 let schema = users_schema();
410 let record = validate_record(
411 &schema,
412 &json!({ "id": "u1", "name": "Ania", "createdAt": "2025-01-01T00:00:00Z" }),
413 ValidateOptions::default(),
414 )
415 .unwrap();
416 assert_eq!(record.get("createdAt"), Some(&Value::from(1735689600000i64)));
417 }
418
419 #[test]
420 fn rejects_an_enum_value_outside_its_declared_values() {
421 let schema = users_schema();
422 let err = validate_record(
423 &schema,
424 &json!({ "id": "u1", "name": "Ania", "role": "superadmin" }),
425 ValidateOptions::default(),
426 )
427 .unwrap_err();
428 assert!(err.issues.iter().any(|i| i.field == "role"));
429 }
430
431 #[test]
432 fn accepts_a_typed_array_field() {
433 let schema = users_schema();
434 let record = validate_record(
435 &schema,
436 &json!({ "id": "u1", "name": "Ania", "tags": ["a", "b"] }),
437 ValidateOptions::default(),
438 )
439 .unwrap();
440 assert_eq!(record.get("tags"), Some(&json!(["a", "b"])));
441 }
442
443 #[test]
444 fn partial_mode_skips_absent_fields_instead_of_defaulting_or_requiring() {
445 let schema = users_schema();
446 let record = validate_record(
447 &schema,
448 &json!({ "age": 5 }),
449 ValidateOptions {
450 partial: true,
451 now: Some(1),
452 },
453 )
454 .unwrap();
455 assert!(!record.contains_key("name"));
456 assert!(!record.contains_key("createdAt"));
457 assert_eq!(record.get("age"), Some(&Value::from(5)));
458 }
459
460 #[test]
461 fn projects_out_fields_the_schema_no_longer_declares() {
462 let schema = users_schema();
463 let mut record = HaysonRecord::new();
464 record.insert("id".into(), Value::String("u1".into()));
465 record.insert("dropped".into(), Value::from(1));
466 let projected = project_to_schema(&schema, &record);
467 assert!(!projected.contains_key("dropped"));
468 assert!(projected.contains_key("id"));
469 }
470
471 #[test]
472 fn undeclared_fields_names_the_stray_keys_and_leaves_a_reserved_one_alone() {
473 let schema = users_schema();
474 let input = json!({ "id": "u1", "nickname": "Anka", "_seq": 7, "shoeSize": 39 });
475
476 assert_eq!(
477 undeclared_fields(&schema, &input),
478 vec!["nickname".to_string(), "shoeSize".to_string()]
479 );
480 }
481
482 #[test]
483 fn without_undeclared_keeps_a_clean_document_borrowed_and_copies_only_a_dirty_one() {
484 let schema = users_schema();
485 let clean = json!({ "id": "u1", "name": "Ania" });
486 let dirty = json!({ "id": "u1", "name": "Ania", "nickname": "Anka", "_seq": 7 });
487
488 assert!(matches!(without_undeclared(&schema, &clean), Cow::Borrowed(_)));
489 let kept = without_undeclared(&schema, &dirty);
490 assert!(matches!(kept, Cow::Owned(_)));
491 assert_eq!(kept.get("name"), Some(&json!("Ania")));
492 assert_eq!(kept.get("nickname"), None);
493 assert_eq!(kept.get("_seq"), Some(&json!(7)));
494 }
495
496 #[test]
497 fn a_document_stripped_of_its_stray_key_validates_while_the_original_does_not() {
498 let schema = users_schema();
499 let dirty = json!({ "id": "u1", "name": "Ania", "nickname": "Anka" });
500
501 assert!(validate_record(&schema, &dirty, ValidateOptions::default()).is_err());
502 let record = validate_record(
503 &schema,
504 without_undeclared(&schema, &dirty).as_ref(),
505 ValidateOptions::default(),
506 )
507 .unwrap();
508 assert_eq!(record.get("name"), Some(&json!("Ania")));
509 assert!(!record.contains_key("nickname"));
510 }
511
512 fn docs_schema() -> TableSchema {
513 parse_schema(&json!({
514 "schemaVersion": 1,
515 "table": "docs",
516 "primaryKey": "id",
517 "fields": {
518 "id": { "type": "string" },
519 "page": { "type": "markdown" },
520 "cover": { "type": "image" },
521 },
522 }))
523 .unwrap()
524 }
525
526 #[test]
527 fn coerces_a_markdown_field_into_content_and_virtual_path() {
528 let schema = docs_schema();
529 let record = validate_record(
530 &schema,
531 &json!({
532 "id": "d1",
533 "page": { "content": "# Hi", "virtualPath": "/docs/hi.md" },
534 }),
535 ValidateOptions::default(),
536 )
537 .unwrap();
538 assert_eq!(
539 record.get("page"),
540 Some(&json!({ "content": "# Hi", "virtualPath": "/docs/hi.md" }))
541 );
542 }
543
544 #[test]
545 fn rejects_a_markdown_value_missing_its_virtual_path() {
546 let schema = docs_schema();
547 let err = validate_record(
548 &schema,
549 &json!({ "id": "d1", "page": { "content": "# Hi" } }),
550 ValidateOptions::default(),
551 )
552 .unwrap_err();
553 assert!(err.issues.iter().any(|i| i.field == "page"));
554 }
555
556 fn notes_schema() -> TableSchema {
557 parse_schema(&json!({
558 "schemaVersion": 1,
559 "table": "notes",
560 "primaryKey": "id",
561 "fields": {
562 "id": { "type": "string" },
563 "body": { "type": "markdown", "addressable": false },
564 },
565 }))
566 .unwrap()
567 }
568
569 #[test]
570 fn a_non_addressable_markdown_field_validates_on_content_alone() {
571 let record = validate_record(
572 ¬es_schema(),
573 &json!({ "id": "n1", "body": { "content": "[a](/docs/a.md)" } }),
574 ValidateOptions::default(),
575 )
576 .unwrap();
577
578 assert_eq!(record.get("body"), Some(&json!({ "content": "[a](/docs/a.md)" })));
579 }
580
581 #[test]
582 fn a_non_addressable_markdown_field_still_shape_checks_a_path_it_was_handed() {
583 let kept = validate_record(
584 ¬es_schema(),
585 &json!({ "id": "n1", "body": { "content": "x", "virtualPath": "/notes/n1.md" } }),
586 ValidateOptions::default(),
587 )
588 .unwrap();
589 let refused = validate_record(
590 ¬es_schema(),
591 &json!({ "id": "n1", "body": { "content": "x", "virtualPath": "notes/n1.md" } }),
592 ValidateOptions::default(),
593 )
594 .unwrap_err();
595
596 assert_eq!(
597 kept.get("body"),
598 Some(&json!({ "content": "x", "virtualPath": "/notes/n1.md" })),
599 "a record written before the field opted out has to keep reading back"
600 );
601 assert!(refused.issues.iter().any(|issue| issue.field == "body"));
602 }
603
604 #[test]
605 fn coerces_an_image_field_into_a_content_addressed_physical_path() {
606 let schema = docs_schema();
607 let record = validate_record(
608 &schema,
609 &json!({
610 "id": "d1",
611 "cover": { "content": "Ynl0ZXMtb2YtdGhlLWxvZ28=", "virtualPath": "/images/logo.png" },
612 }),
613 ValidateOptions::default(),
614 )
615 .unwrap();
616 let cover = record.get("cover").unwrap().as_object().unwrap();
617 assert_eq!(cover.get("virtualPath"), Some(&json!("/images/logo.png")));
618 assert!(cover.get("physicalPath").unwrap().as_str().unwrap().ends_with(".bin"));
619 assert!(!cover.contains_key("content"));
620 }
621
622 #[test]
623 fn rejects_an_image_content_that_is_not_valid_base64() {
624 let schema = docs_schema();
625 let err = validate_record(
626 &schema,
627 &json!({ "id": "d1", "cover": { "content": "not base64!!", "virtualPath": "/images/a.png" } }),
628 ValidateOptions::default(),
629 )
630 .unwrap_err();
631 assert!(err.issues.iter().any(|issue| issue.reason.contains("base64")));
632 }
633
634 #[test]
635 fn refuses_a_caller_supplied_physical_path_that_is_not_a_content_hash_file_name() {
636 let schema = docs_schema();
637 let err = validate_record(
638 &schema,
639 &json!({
640 "id": "d1",
641 "cover": { "physicalPath": "../../victim/secret.txt", "virtualPath": "/images/a.png" },
642 }),
643 ValidateOptions::default(),
644 )
645 .unwrap_err();
646 assert!(
647 err.issues
648 .iter()
649 .any(|issue| issue.field == "cover" && issue.reason.contains("content-hash file name")),
650 "{:?}",
651 err.issues
652 );
653 }
654
655 #[test]
656 fn round_trips_an_image_field_it_coerced_itself() {
657 let schema = docs_schema();
658 let coerced = validate_record(
659 &schema,
660 &json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/images/a.png" } }),
661 ValidateOptions::default(),
662 )
663 .unwrap();
664
665 let again = validate_record(
666 &schema,
667 &json!({ "id": "d1", "cover": coerced.get("cover").unwrap() }),
668 ValidateOptions::default(),
669 )
670 .unwrap();
671
672 assert_eq!(again.get("cover"), coerced.get("cover"));
673 }
674
675 #[test]
676 fn hashes_identical_image_content_to_the_same_physical_path() {
677 let schema = docs_schema();
678 let one = validate_record(
679 &schema,
680 &json!({ "id": "d1", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/a.png" } }),
681 ValidateOptions::default(),
682 )
683 .unwrap();
684 let two = validate_record(
685 &schema,
686 &json!({ "id": "d2", "cover": { "content": "c2FtZS1ieXRlcw==", "virtualPath": "/images/b.png" } }),
687 ValidateOptions::default(),
688 )
689 .unwrap();
690 assert_eq!(
691 one.get("cover").unwrap().get("physicalPath"),
692 two.get("cover").unwrap().get("physicalPath")
693 );
694 }
695}