ZDDC/zddc/internal/jsonschema/validate.go
2026-06-11 13:32:31 -05:00

277 lines
6.5 KiB
Go

package jsonschema
import (
"reflect"
"strconv"
"strings"
"unicode/utf8"
)
// Validate reports all violations of s against v. v is the decoded data —
// typically the result of json.Unmarshal or yaml.Unmarshal into `any`.
//
// Returns nil when v conforms. Returns one or more Error entries otherwise.
// Errors carry JSON-Pointer paths so the form renderer can attach each
// message to the right widget.
//
// Supported keywords (v0 subset): type, properties, required, items, enum,
// minimum, maximum, minLength, maxLength, format (date, email),
// additionalProperties: false. Everything else is silently ignored — the
// form-spec meta-schema enforces that authors stay within this subset.
func Validate(s *Schema, v any) []Error {
if s == nil {
return nil
}
var errs []Error
validate(s, v, "", &errs)
return errs
}
func validate(s *Schema, v any, path string, errs *[]Error) {
if s == nil {
return
}
// Type check first — a wrong-typed value can't satisfy the rest, and
// dispatching the constraint checks below assumes the type matches.
if s.Type != "" && !typeMatches(s.Type, v) {
*errs = append(*errs, Error{
Path: path,
Message: typeMessage(s.Type, v),
})
return
}
// enum: value must be one of the listed alternatives. Numeric comparisons
// coerce int↔float to handle JSON's float64 vs YAML's int mismatch.
if len(s.Enum) > 0 && !enumContains(s.Enum, v) {
*errs = append(*errs, Error{
Path: path,
Message: "must be one of the allowed values",
})
return
}
switch s.Type {
case "string":
validateString(s, v.(string), path, errs)
case "number", "integer":
validateNumber(s, v, path, errs)
case "object":
validateObject(s, v, path, errs)
case "array":
validateArray(s, v, path, errs)
}
}
func validateString(s *Schema, str, path string, errs *[]Error) {
n := utf8.RuneCountInString(str)
if s.MinLength != nil && n < *s.MinLength {
*errs = append(*errs, Error{Path: path, Message: minLenMsg(*s.MinLength)})
}
if s.MaxLength != nil && n > *s.MaxLength {
*errs = append(*errs, Error{Path: path, Message: maxLenMsg(*s.MaxLength)})
}
if s.Format != "" && !formatValid(s.Format, str) {
*errs = append(*errs, Error{Path: path, Message: "must be a valid " + s.Format})
}
}
func validateNumber(s *Schema, v any, path string, errs *[]Error) {
f, _ := toFloat(v)
if s.Minimum != nil && f < *s.Minimum {
*errs = append(*errs, Error{Path: path, Message: "must be ≥ " + numStr(*s.Minimum)})
}
if s.Maximum != nil && f > *s.Maximum {
*errs = append(*errs, Error{Path: path, Message: "must be ≤ " + numStr(*s.Maximum)})
}
}
func validateObject(s *Schema, v any, path string, errs *[]Error) {
obj, ok := v.(map[string]any)
if !ok {
return
}
for _, name := range s.Required {
if _, present := obj[name]; !present {
*errs = append(*errs, Error{
Path: ptrPush(path, name),
Message: "required",
})
}
}
for name, propSchema := range s.Properties {
if val, present := obj[name]; present {
validate(propSchema, val, ptrPush(path, name), errs)
}
}
// additionalProperties: false → reject anything not declared in properties.
// Only the bool-false form is enforced in v0; schema form is parsed but ignored.
if ap, ok := s.AdditionalProperties.(bool); ok && !ap {
for name := range obj {
if _, declared := s.Properties[name]; !declared {
*errs = append(*errs, Error{
Path: ptrPush(path, name),
Message: "unexpected property",
})
}
}
}
}
func validateArray(s *Schema, v any, path string, errs *[]Error) {
arr, ok := v.([]any)
if !ok {
return
}
if s.Items != nil {
for i, item := range arr {
validate(s.Items, item, ptrPushIdx(path, i), errs)
}
}
}
// --- Type / coercion helpers -------------------------------------------------
func typeMatches(t string, v any) bool {
switch t {
case "null":
return v == nil
case "boolean":
_, ok := v.(bool)
return ok
case "string":
_, ok := v.(string)
return ok
case "number":
_, ok := toFloat(v)
return ok
case "integer":
if _, ok := toFloat(v); !ok {
return false
}
return isInteger(v)
case "array":
_, ok := v.([]any)
return ok
case "object":
_, ok := v.(map[string]any)
return ok
}
return true
}
func toFloat(v any) (float64, bool) {
switch n := v.(type) {
case int:
return float64(n), true
case int8:
return float64(n), true
case int16:
return float64(n), true
case int32:
return float64(n), true
case int64:
return float64(n), true
case uint:
return float64(n), true
case uint8:
return float64(n), true
case uint16:
return float64(n), true
case uint32:
return float64(n), true
case uint64:
return float64(n), true
case float32:
return float64(n), true
case float64:
return n, true
}
return 0, false
}
func isInteger(v any) bool {
switch n := v.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return true
case float32:
f := float64(n)
return f == float64(int64(f))
case float64:
return n == float64(int64(n))
}
return false
}
// enumContains returns true when v matches one of the enum values, with
// numeric coercion (int↔float) so that JSON's float64-default doesn't reject
// an enum authored in YAML as plain integers.
func enumContains(opts []any, v any) bool {
for _, opt := range opts {
if valuesEqual(opt, v) {
return true
}
}
return false
}
func valuesEqual(a, b any) bool {
af, aok := toFloat(a)
bf, bok := toFloat(b)
if aok && bok {
return af == bf
}
return reflect.DeepEqual(a, b)
}
// --- JSON Pointer (RFC 6901) -------------------------------------------------
func ptrPush(path, segment string) string {
return path + "/" + ptrEnc(segment)
}
func ptrPushIdx(path string, idx int) string {
return path + "/" + strconv.Itoa(idx)
}
func ptrEnc(s string) string {
s = strings.ReplaceAll(s, "~", "~0")
s = strings.ReplaceAll(s, "/", "~1")
return s
}
// --- Message formatting ------------------------------------------------------
func typeMessage(want string, got any) string {
return "expected " + want + ", got " + typeName(got)
}
func typeName(v any) string {
switch v.(type) {
case nil:
return "null"
case bool:
return "boolean"
case string:
return "string"
case []any:
return "array"
case map[string]any:
return "object"
}
if _, ok := toFloat(v); ok {
return "number"
}
return "unknown"
}
func minLenMsg(n int) string { return "must be at least " + strconv.Itoa(n) + " characters" }
func maxLenMsg(n int) string { return "must be at most " + strconv.Itoa(n) + " characters" }
func numStr(f float64) string {
return strconv.FormatFloat(f, 'g', -1, 64)
}