All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s
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>
277 lines
6.5 KiB
Go
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)
|
|
}
|