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