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) }