ZDDC/zddc/internal/jsonschema/validate.go
ZDDC a02a26d3c2
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s
feat: form-data system v0 (sixth tool + zddc-server endpoints)
Schema-driven form renderer plus zddc-server endpoints that turn any
<name>.form.yaml into a working data-collection form at <path>/<name>.form.html.
Submissions land in <path>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml,
ACL-gated by the existing .zddc cascade. The form posts back to its own URL;
the server strips ".html" and routes by what's underneath, so create and
update use the same client-side code path.

Form spec dialect: JSON Schema 2020-12 + RJSF-style ui:* hints, written in
YAML. Chosen for LLM authorability — it's the canonical structured-output
target for OpenAI/Anthropic, and the ui:* convention is the most-trained UI
hint vocabulary. Supported subset for v0: type (string/number/integer/boolean/
array/object), enum, min/max, minLength/maxLength, required, additionalProperties:
false, properties, items, format (date, email). Round-trip mode is form-as-truth:
submission YAML is regenerated each save, comments are not preserved (the v1
file-as-truth mode for hand-edited files like .zddc itself is deferred).

New components:
  * form/ — sixth single-file HTML tool, vanilla JS renderer (~760 LoC)
  * zddc/internal/jsonschema/ — focused JSON Schema validator covering only
    the v0 keyword subset. Match-implementation-cost-to-surface-used: a full
    library brings 70%+ surface we don't use; revisit when v1 adds $ref +
    oneOf + if/then/else.
  * zddc/internal/handler/formhandler.go — RecognizeFormRequest / ServeForm,
    capability-URL re-edit, atomic submission writes via the new
    zddc.WriteAtomic helper extracted from writer.go.
  * dispatch() in zddc-server/main.go now intercepts *.form.html and
    *.yaml.html before the static-file path; spec existence is the trigger.

Build pipeline: form joins ZDDC_RELEASE_TOOLS in lockstep, gets its own
embedded copy in handler/form.html (separate from the apps cascade —
the form renderer is fixed, not subject to per-folder version overrides).

Tests: 5 new Playwright specs (form-safety) + 14 new Go tests across the
validator and handler. All 172 Playwright tests + 10 Go packages green.
End-to-end manual verification: GET empty → POST 201 + capability URL →
GET re-edit (pre-filled) → POST update → 200, raw YAML browsable, ACL
deny → 403.

Docs: form/ section added to AGENTS.md and ARCHITECTURE.md. AGENTS.md
also documents the implementation-vs-dependency policy. CLAUDE.md repo-shape
list extended.

Deferred (v1+): .zddc editor migration onto this system, file-as-truth
lossless YAML round-trip, ui:show-when conditional visibility, oneOf/anyOf,
apps-cascade preview hook, cascade-fetched form definitions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:12:16 -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)
}