1use crate::CompileError;
7
8const UNREPRESENTABLE: &[(&str, &str)] = &[
11 ("patternProperties", "declare explicit 'properties' instead"),
12 (
13 "prefixItems",
14 "declare a named object; tuples do not round-trip to SQL",
15 ),
16 (
17 "$ref",
18 "inline the definition; cross-schema refs land in a later phase",
19 ),
20 ("allOf", "flatten the composition into one object"),
21 ("anyOf", "split into separate contracts"),
22 ("oneOf", "split into separate contracts"),
23 ("not", "express the constraint positively"),
24];
25
26const SUPPORTED_FORMATS: &[&str] = &["email"];
27
28const TOP_LEVEL_KEYWORDS: &[&str] = &[
35 "$schema",
36 "$comment",
37 "type",
38 "properties",
39 "required",
40 "additionalProperties",
41];
42
43const PROPERTY_KEYWORDS: &[&str] = &[
45 "type",
46 "minLength",
47 "maxLength",
48 "format",
49 "enum",
50 "default",
51 "pattern",
52];
53
54const SCALAR_KEYWORDS: &[&str] = &["type", "default"];
60
61const ARRAY_KEYWORDS: &[&str] = &["type", "items"];
70
71const ARRAY_ITEM_KEYWORDS: &[&str] = &["type"];
80
81const AUTHORING_EMAIL_PATTERN: &str = r"^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$";
91
92#[derive(Debug, Clone, PartialEq)]
93pub enum PropertyKind {
94 String {
95 min_length: Option<u64>,
96 max_length: Option<u64>,
97 format: Option<String>,
98 enum_values: Option<Vec<String>>,
99 default: Option<String>,
100 },
101 Integer {
102 default: Option<i64>,
103 },
104 Number {
105 default: Option<f64>,
106 },
107 Boolean {
108 default: Option<bool>,
109 },
110 Array {
116 element: ArrayElement,
117 },
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum ArrayElement {
126 String,
127 Integer,
128 Number,
129 Boolean,
130}
131
132impl PropertyKind {
133 #[must_use]
135 pub fn has_default(&self) -> bool {
136 match self {
137 Self::String { default, .. } => default.is_some(),
138 Self::Integer { default } => default.is_some(),
139 Self::Number { default } => default.is_some(),
140 Self::Boolean { default } => default.is_some(),
141 Self::Array { .. } => false,
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq)]
149pub struct Property {
150 pub name: String,
151 pub kind: PropertyKind,
152 pub required: bool,
153}
154
155#[derive(Debug, Clone)]
156pub struct ContractSchema {
157 pub contract_name: String,
158 pub properties: Vec<Property>,
159}
160
161pub fn parse(contract_name: &str, schema_json: &str) -> Result<ContractSchema, CompileError> {
162 let value: serde_json::Value =
163 serde_json::from_str(schema_json).map_err(|error| CompileError::InvalidSchema {
164 message: error.to_string(),
165 })?;
166
167 require_identifier("contract name", contract_name)?;
168 reject_unrepresentable(&value)?;
169 reject_unknown_top_level_keywords(&value)?;
170 require_strict_object(&value)?;
171
172 let required = required_names(&value)?;
173 let properties = parse_properties(&value, &required)?;
174
175 Ok(ContractSchema {
176 contract_name: contract_name.to_owned(),
177 properties,
178 })
179}
180
181fn reject_unrepresentable(value: &serde_json::Value) -> Result<(), CompileError> {
186 match value {
187 serde_json::Value::Object(map) => {
188 for (key, child) in map {
189 if let Some((construct, alternative)) = UNREPRESENTABLE
190 .iter()
191 .find(|(construct, _)| *construct == key)
192 {
193 return Err(CompileError::Unrepresentable {
194 construct: (*construct).to_owned(),
195 alternatives: (*alternative).to_owned(),
196 });
197 }
198 if key == "properties" {
199 if let Some(properties) = child.as_object() {
200 for subschema in properties.values() {
201 reject_unrepresentable(subschema)?;
202 }
203 continue;
204 }
205 }
206 reject_unrepresentable(child)?;
207 }
208 }
209 serde_json::Value::Array(entries) => {
210 for entry in entries {
211 reject_unrepresentable(entry)?;
212 }
213 }
214 _ => {}
215 }
216 Ok(())
217}
218
219fn reject_unknown_top_level_keywords(value: &serde_json::Value) -> Result<(), CompileError> {
227 for key in value.as_object().into_iter().flatten().map(|(key, _)| key) {
228 if !TOP_LEVEL_KEYWORDS.contains(&key.as_str()) {
229 return Err(CompileError::Unrepresentable {
230 construct: format!("top-level keyword '{key}'"),
231 alternatives: format!(
232 "the subset carries {}; annotation keywords are not \
233 emitted to any target, so carrying them would drift \
234 the bindings — remove it, or propose it as a \
235 widening step",
236 TOP_LEVEL_KEYWORDS.join(", ")
237 ),
238 });
239 }
240 }
241 Ok(())
242}
243
244fn reject_unknown_property_keywords(
250 name: &str,
251 spec: &serde_json::Value,
252) -> Result<(), CompileError> {
253 for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
254 if !PROPERTY_KEYWORDS.contains(&key.as_str()) {
255 return Err(CompileError::Unrepresentable {
256 construct: format!("keyword '{key}' on property '{name}'"),
257 alternatives: format!(
258 "the subset carries {}; remove it, or propose it as a \
259 widening step",
260 PROPERTY_KEYWORDS.join(", ")
261 ),
262 });
263 }
264 }
265 Ok(())
266}
267
268fn validate_pattern(
273 name: &str,
274 spec: &serde_json::Value,
275 format: Option<&str>,
276) -> Result<(), CompileError> {
277 let Some(value) = spec.get("pattern") else {
278 return Ok(());
279 };
280 let Some(pattern) = value.as_str() else {
281 return Err(invalid_property_keyword(name, "'pattern' must be a string"));
282 };
283 if format == Some("email") && pattern == AUTHORING_EMAIL_PATTERN {
284 return Ok(());
285 }
286 Err(CompileError::Unrepresentable {
287 construct: format!("'pattern' on property '{name}'"),
288 alternatives: "format 'email', or propose pattern support as a widening step".to_owned(),
289 })
290}
291
292fn require_strict_object(value: &serde_json::Value) -> Result<(), CompileError> {
293 if value.get("type").and_then(serde_json::Value::as_str) != Some("object") {
294 return Err(CompileError::InvalidSchema {
295 message: "top-level schema must be an object type".to_owned(),
296 });
297 }
298 if value.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) {
299 return Err(CompileError::InvalidSchema {
300 message: "additionalProperties must be false (strictness is mandatory, charter N2)"
301 .to_owned(),
302 });
303 }
304 Ok(())
305}
306
307fn required_names(value: &serde_json::Value) -> Result<Vec<String>, CompileError> {
308 let Some(required) = value.get("required") else {
309 return Ok(Vec::new());
310 };
311 let Some(entries) = required.as_array() else {
312 return Err(invalid_keyword("required", "must be an array of strings"));
313 };
314 entries
315 .iter()
316 .map(|entry| {
317 entry
318 .as_str()
319 .map(str::to_owned)
320 .ok_or_else(|| invalid_keyword("required", "entries must all be strings"))
321 })
322 .collect()
323}
324
325fn parse_properties(
326 value: &serde_json::Value,
327 required: &[String],
328) -> Result<Vec<Property>, CompileError> {
329 let Some(map) = value
330 .get("properties")
331 .and_then(serde_json::Value::as_object)
332 else {
333 return Err(CompileError::InvalidSchema {
334 message: "schema declares no properties".to_owned(),
335 });
336 };
337 let mut properties = Vec::new();
338 for (name, spec) in map {
339 require_identifier("property name", name)?;
340 let kind = parse_property(name, spec)?;
341 let required = required.contains(name);
342 reject_required_with_default(name, &kind, required)?;
343 reject_non_required_array(name, &kind, required)?;
344 properties.push(Property {
345 name: name.clone(),
346 kind,
347 required,
348 });
349 }
350 Ok(properties)
351}
352
353fn reject_required_with_default(
358 name: &str,
359 kind: &PropertyKind,
360 required: bool,
361) -> Result<(), CompileError> {
362 if required && kind.has_default() {
363 return Err(CompileError::InvalidSchema {
364 message: format!(
365 "property '{name}' is both required and has a default; \
366 choose one: required (caller must send it) or \
367 default (caller may omit it)"
368 ),
369 });
370 }
371 Ok(())
372}
373
374fn reject_non_required_array(
387 name: &str,
388 kind: &PropertyKind,
389 required: bool,
390) -> Result<(), CompileError> {
391 if !required && matches!(kind, PropertyKind::Array { .. }) {
392 return Err(CompileError::InvalidSchema {
393 message: format!(
394 "array property '{name}' must be required; mark it required, \
395 or propose optional arrays as their own widening step"
396 ),
397 });
398 }
399 Ok(())
400}
401
402fn require_identifier(role: &str, name: &str) -> Result<(), CompileError> {
406 let mut chars = name.chars();
407 let valid = chars
408 .next()
409 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
410 && chars.all(|rest| rest.is_ascii_alphanumeric() || rest == '_');
411 if valid {
412 return Ok(());
413 }
414 Err(CompileError::InvalidSchema {
415 message: format!(
416 "{role} '{name}' is not a portable identifier; \
417 names must match [A-Za-z_][A-Za-z0-9_]* to emit into all targets"
418 ),
419 })
420}
421
422fn parse_property(name: &str, spec: &serde_json::Value) -> Result<PropertyKind, CompileError> {
423 match spec.get("type").and_then(serde_json::Value::as_str) {
424 Some("string") => parse_string_property(name, spec),
425 Some(scalar @ ("integer" | "number" | "boolean")) => {
426 parse_scalar_property(name, spec, scalar)
427 }
428 Some("array") => parse_array_property(name, spec),
429 other => Err(CompileError::Unrepresentable {
430 construct: format!("property '{name}' of type {other:?}"),
431 alternatives: "the subset carries string, integer, number, and boolean \
432 properties, and arrays of those scalars; objects land in \
433 a later widening step"
434 .to_owned(),
435 }),
436 }
437}
438
439fn parse_array_property(
443 name: &str,
444 spec: &serde_json::Value,
445) -> Result<PropertyKind, CompileError> {
446 for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
447 if !ARRAY_KEYWORDS.contains(&key.as_str()) {
448 return Err(CompileError::Unrepresentable {
449 construct: format!("keyword '{key}' on array property '{name}'"),
450 alternatives: format!(
451 "the array widening step carries {}; remove it, or propose \
452 it as a widening step",
453 ARRAY_KEYWORDS.join(", ")
454 ),
455 });
456 }
457 }
458
459 let Some(items) = spec.get("items") else {
460 return Err(CompileError::Unrepresentable {
461 construct: format!("array property '{name}' without 'items'"),
462 alternatives: format!(
463 "an array needs an element type: {}; write \
464 {{\"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}",
465 ARRAY_KEYWORDS.join(", ")
466 ),
467 });
468 };
469
470 for key in items.as_object().into_iter().flatten().map(|(key, _)| key) {
471 if !ARRAY_ITEM_KEYWORDS.contains(&key.as_str()) {
472 return Err(CompileError::Unrepresentable {
473 construct: format!("keyword '{key}' on the items of array property '{name}'"),
474 alternatives: format!(
475 "array items carry {} only — a per-element constraint is not \
476 a SQL column constraint, so admitting it would enforce the \
477 contract in three targets and not the fourth",
478 ARRAY_ITEM_KEYWORDS.join(", ")
479 ),
480 });
481 }
482 }
483
484 let element = match items.get("type").and_then(serde_json::Value::as_str) {
485 Some("string") => ArrayElement::String,
486 Some("integer") => ArrayElement::Integer,
487 Some("number") => ArrayElement::Number,
488 Some("boolean") => ArrayElement::Boolean,
489 other => {
490 return Err(CompileError::Unrepresentable {
491 construct: format!("array property '{name}' with items of type {other:?}"),
492 alternatives: "array items carry string, integer, number, or boolean; \
493 nested arrays and arrays of objects land in a later \
494 widening step"
495 .to_owned(),
496 });
497 }
498 };
499
500 Ok(PropertyKind::Array { element })
501}
502
503fn parse_scalar_property(
506 name: &str,
507 spec: &serde_json::Value,
508 scalar: &str,
509) -> Result<PropertyKind, CompileError> {
510 for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
511 if !SCALAR_KEYWORDS.contains(&key.as_str()) {
512 return Err(CompileError::Unrepresentable {
513 construct: format!("keyword '{key}' on {scalar} property '{name}'"),
514 alternatives: format!(
515 "the scalar widening step carries {}; remove it, or \
516 propose it as a widening step",
517 SCALAR_KEYWORDS.join(", ")
518 ),
519 });
520 }
521 }
522 let default = spec.get("default");
523 match scalar {
524 "integer" => {
525 let parsed = typed_default(name, default, scalar, serde_json::Value::as_i64)?;
526 if let Some(val) = parsed {
527 let max_safe = 9_007_199_254_740_991_i64;
533 let min_safe = -9_007_199_254_740_991_i64;
534 if val > max_safe || val < min_safe {
535 return Err(CompileError::InvalidSchema {
536 message: format!(
537 "integer default {val} on property '{name}' exceeds the \
538 safe range for JavaScript number binding (±2^53-1); \
539 the Zod target would emit a different value"
540 ),
541 });
542 }
543 }
544 Ok(PropertyKind::Integer { default: parsed })
545 }
546 "number" => Ok(PropertyKind::Number {
547 default: typed_default(name, default, scalar, serde_json::Value::as_f64)?,
548 }),
549 _ => Ok(PropertyKind::Boolean {
550 default: typed_default(name, default, scalar, serde_json::Value::as_bool)?,
551 }),
552 }
553}
554
555fn typed_default<T>(
556 name: &str,
557 value: Option<&serde_json::Value>,
558 scalar: &str,
559 extract: impl Fn(&serde_json::Value) -> Option<T>,
560) -> Result<Option<T>, CompileError> {
561 let Some(value) = value else {
562 return Ok(None);
563 };
564 extract(value).map(Some).ok_or_else(|| {
565 invalid_property_keyword(
566 name,
567 &format!("'default' must be a {scalar} for a {scalar} property"),
568 )
569 })
570}
571
572fn parse_string_property(
573 name: &str,
574 spec: &serde_json::Value,
575) -> Result<PropertyKind, CompileError> {
576 let type_name = spec.get("type").and_then(serde_json::Value::as_str);
577 if type_name != Some("string") {
578 return Err(CompileError::Unrepresentable {
579 construct: format!("property '{name}' of type {type_name:?}"),
580 alternatives: "Phase 1 subset carries string properties; widen in a later phase"
581 .to_owned(),
582 });
583 }
584 reject_unknown_property_keywords(name, spec)?;
585 let format = parse_format(name, spec)?;
586 validate_pattern(name, spec, format.as_deref())?;
587 let enum_values = parse_enum(name, spec)?;
588 let default = parse_default(name, spec)?;
589 if let (Some(values), Some(value)) = (&enum_values, &default) {
590 if !values.contains(value) {
591 return Err(invalid_property_keyword(
592 name,
593 "'default' must be one of the declared 'enum' values",
594 ));
595 }
596 }
597 Ok(PropertyKind::String {
598 min_length: spec.get("minLength").and_then(serde_json::Value::as_u64),
599 max_length: spec.get("maxLength").and_then(serde_json::Value::as_u64),
600 format,
601 enum_values,
602 default,
603 })
604}
605
606fn parse_format(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
607 let Some(value) = spec.get("format") else {
608 return Ok(None);
609 };
610 let Some(format) = value.as_str() else {
611 return Err(invalid_keyword("format", "must be a string"));
612 };
613 if !SUPPORTED_FORMATS.contains(&format) {
614 return Err(CompileError::Unrepresentable {
615 construct: format!("format '{format}' on property '{name}'"),
616 alternatives: "format 'email', or omit 'format'".to_owned(),
617 });
618 }
619 Ok(Some(format.to_owned()))
620}
621
622fn parse_enum(name: &str, spec: &serde_json::Value) -> Result<Option<Vec<String>>, CompileError> {
623 let Some(value) = spec.get("enum") else {
624 return Ok(None);
625 };
626 let Some(entries) = value.as_array() else {
627 return Err(invalid_property_keyword(name, "'enum' must be an array"));
628 };
629 entries
630 .iter()
631 .map(|entry| {
632 entry
633 .as_str()
634 .map(str::to_owned)
635 .ok_or_else(|| invalid_property_keyword(name, "'enum' entries must all be strings"))
636 })
637 .collect::<Result<Vec<_>, _>>()
638 .map(Some)
639}
640
641fn parse_default(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
642 let Some(value) = spec.get("default") else {
643 return Ok(None);
644 };
645 value.as_str().map(str::to_owned).map(Some).ok_or_else(|| {
646 invalid_property_keyword(name, "'default' must be a string for a string property")
647 })
648}
649
650fn invalid_keyword(keyword: &str, detail: &str) -> CompileError {
651 CompileError::InvalidSchema {
652 message: format!("'{keyword}' {detail}"),
653 }
654}
655
656fn invalid_property_keyword(name: &str, detail: &str) -> CompileError {
657 CompileError::InvalidSchema {
658 message: format!("property '{name}' keyword {detail}"),
659 }
660}