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>
273 lines
7.6 KiB
Go
273 lines
7.6 KiB
Go
package jsonschema
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
// fl returns a *float64 pointing at v — convenience for table tests.
|
|
func fl(v float64) *float64 { return &v }
|
|
func ip(v int) *int { return &v }
|
|
|
|
// jsonAny decodes a JSON literal into the same `any` shape that the form
|
|
// handler hands to Validate (json.Unmarshal of the request body).
|
|
func jsonAny(t *testing.T, s string) any {
|
|
t.Helper()
|
|
var v any
|
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
|
t.Fatalf("decode %q: %v", s, err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestValidate_Types(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
schema *Schema
|
|
valueJS string
|
|
wantErr bool
|
|
}{
|
|
{"string-ok", &Schema{Type: "string"}, `"hi"`, false},
|
|
{"string-wrong-type", &Schema{Type: "string"}, `42`, true},
|
|
{"number-ok-int", &Schema{Type: "number"}, `42`, false},
|
|
{"number-ok-float", &Schema{Type: "number"}, `3.14`, false},
|
|
{"number-wrong-type", &Schema{Type: "number"}, `"hi"`, true},
|
|
{"integer-ok", &Schema{Type: "integer"}, `42`, false},
|
|
{"integer-rejects-float", &Schema{Type: "integer"}, `3.14`, true},
|
|
{"integer-accepts-floaty-int", &Schema{Type: "integer"}, `42.0`, false},
|
|
{"boolean-ok", &Schema{Type: "boolean"}, `true`, false},
|
|
{"boolean-wrong-type", &Schema{Type: "boolean"}, `"true"`, true},
|
|
{"null-ok", &Schema{Type: "null"}, `null`, false},
|
|
{"array-ok", &Schema{Type: "array"}, `[]`, false},
|
|
{"array-wrong-type", &Schema{Type: "array"}, `{}`, true},
|
|
{"object-ok", &Schema{Type: "object"}, `{}`, false},
|
|
{"object-wrong-type", &Schema{Type: "object"}, `[]`, true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
errs := Validate(tc.schema, jsonAny(t, tc.valueJS))
|
|
if (len(errs) > 0) != tc.wantErr {
|
|
t.Fatalf("Validate errs = %v; wantErr=%v", errs, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidate_Enum(t *testing.T) {
|
|
s := &Schema{Type: "string", Enum: []any{"a", "b", "c"}}
|
|
|
|
if errs := Validate(s, "a"); len(errs) != 0 {
|
|
t.Errorf("a: unexpected errs %v", errs)
|
|
}
|
|
if errs := Validate(s, "z"); len(errs) == 0 {
|
|
t.Errorf("z: expected enum error")
|
|
}
|
|
|
|
// Numeric enum with int↔float coercion: schema authored with int, value
|
|
// arrives as float64 (JSON default).
|
|
num := &Schema{Type: "integer", Enum: []any{1, 2, 3}}
|
|
if errs := Validate(num, jsonAny(t, `2`)); len(errs) != 0 {
|
|
t.Errorf("int 2 against int enum: unexpected errs %v", errs)
|
|
}
|
|
if errs := Validate(num, jsonAny(t, `4`)); len(errs) == 0 {
|
|
t.Errorf("int 4 against int enum: expected error")
|
|
}
|
|
}
|
|
|
|
func TestValidate_StringConstraints(t *testing.T) {
|
|
s := &Schema{Type: "string", MinLength: ip(3), MaxLength: ip(8)}
|
|
cases := map[string]int{
|
|
`"hi"`: 1, // too short
|
|
`"hello"`: 0,
|
|
`"hellothere"`: 1, // too long
|
|
}
|
|
for v, want := range cases {
|
|
errs := Validate(s, jsonAny(t, v))
|
|
if len(errs) != want {
|
|
t.Errorf("%s: got %d errs (%v), want %d", v, len(errs), errs, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidate_NumberConstraints(t *testing.T) {
|
|
s := &Schema{Type: "integer", Minimum: fl(1), Maximum: fl(5)}
|
|
cases := []struct {
|
|
v string
|
|
want int
|
|
}{
|
|
{`0`, 1}, {`1`, 0}, {`3`, 0}, {`5`, 0}, {`6`, 1},
|
|
}
|
|
for _, tc := range cases {
|
|
errs := Validate(s, jsonAny(t, tc.v))
|
|
if len(errs) != tc.want {
|
|
t.Errorf("v=%s: got %d errs (%v), want %d", tc.v, len(errs), errs, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidate_Format_Date(t *testing.T) {
|
|
s := &Schema{Type: "string", Format: "date"}
|
|
cases := map[string]bool{
|
|
`"2026-05-01"`: false,
|
|
`"2026-13-01"`: true, // bad month
|
|
`"2026-02-30"`: true, // bad day
|
|
`"05/01/2026"`: true, // wrong format
|
|
`"2026-5-1"`: true, // missing zero-pad
|
|
}
|
|
for v, wantErr := range cases {
|
|
errs := Validate(s, jsonAny(t, v))
|
|
if (len(errs) > 0) != wantErr {
|
|
t.Errorf("%s: got errs=%v wantErr=%v", v, errs, wantErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidate_Format_Email(t *testing.T) {
|
|
s := &Schema{Type: "string", Format: "email"}
|
|
cases := map[string]bool{
|
|
`"casey@example.com"`: false,
|
|
`"casey+tag@ex.io"`: false,
|
|
`"not-an-email"`: true,
|
|
`"missing@dot"`: true,
|
|
`"@nouser.com"`: true,
|
|
}
|
|
for v, wantErr := range cases {
|
|
errs := Validate(s, jsonAny(t, v))
|
|
if (len(errs) > 0) != wantErr {
|
|
t.Errorf("%s: got errs=%v wantErr=%v", v, errs, wantErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidate_Object_Required(t *testing.T) {
|
|
s := &Schema{
|
|
Type: "object",
|
|
Required: []string{"name", "age"},
|
|
Properties: map[string]*Schema{
|
|
"name": {Type: "string"},
|
|
"age": {Type: "integer"},
|
|
},
|
|
}
|
|
|
|
errs := Validate(s, jsonAny(t, `{"name":"Casey","age":42}`))
|
|
if len(errs) != 0 {
|
|
t.Errorf("complete object: unexpected errs %v", errs)
|
|
}
|
|
|
|
errs = Validate(s, jsonAny(t, `{"name":"Casey"}`))
|
|
if len(errs) != 1 || errs[0].Path != "/age" {
|
|
t.Errorf("missing age: got errs=%v want one error at /age", errs)
|
|
}
|
|
|
|
errs = Validate(s, jsonAny(t, `{}`))
|
|
if len(errs) != 2 {
|
|
t.Errorf("empty object: got %d errs (%v) want 2", len(errs), errs)
|
|
}
|
|
}
|
|
|
|
func TestValidate_Object_AdditionalPropertiesFalse(t *testing.T) {
|
|
s := &Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{"a": {Type: "string"}},
|
|
AdditionalProperties: false,
|
|
}
|
|
|
|
if errs := Validate(s, jsonAny(t, `{"a":"hi"}`)); len(errs) != 0 {
|
|
t.Errorf("declared-only: unexpected %v", errs)
|
|
}
|
|
|
|
errs := Validate(s, jsonAny(t, `{"a":"hi","b":1}`))
|
|
if len(errs) != 1 || errs[0].Path != "/b" {
|
|
t.Errorf("extra prop: got %v want one error at /b", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidate_Object_NestedErrorPaths(t *testing.T) {
|
|
s := &Schema{
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"inner": {
|
|
Type: "object",
|
|
Properties: map[string]*Schema{
|
|
"n": {Type: "integer", Minimum: fl(0)},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
errs := Validate(s, jsonAny(t, `{"inner":{"n":-5}}`))
|
|
if len(errs) != 1 || errs[0].Path != "/inner/n" {
|
|
t.Errorf("nested path: got %v want /inner/n", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidate_Array_Items(t *testing.T) {
|
|
s := &Schema{
|
|
Type: "array",
|
|
Items: &Schema{Type: "integer", Minimum: fl(0)},
|
|
}
|
|
|
|
if errs := Validate(s, jsonAny(t, `[1,2,3]`)); len(errs) != 0 {
|
|
t.Errorf("valid array: unexpected %v", errs)
|
|
}
|
|
|
|
errs := Validate(s, jsonAny(t, `[1,-2,3,-4]`))
|
|
if len(errs) != 2 {
|
|
t.Errorf("two bad items: got %d errs %v want 2", len(errs), errs)
|
|
}
|
|
if errs[0].Path != "/1" || errs[1].Path != "/3" {
|
|
t.Errorf("array paths: got %v want /1 then /3", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidate_Array_NestedObjects(t *testing.T) {
|
|
s := &Schema{
|
|
Type: "array",
|
|
Items: &Schema{
|
|
Type: "object",
|
|
Required: []string{"k"},
|
|
Properties: map[string]*Schema{
|
|
"k": {Type: "string"},
|
|
"v": {Type: "integer", Minimum: fl(1), Maximum: fl(5)},
|
|
},
|
|
},
|
|
}
|
|
errs := Validate(s, jsonAny(t, `[{"k":"a","v":3},{"v":99}]`))
|
|
// Expected: missing k at /1/k, v out of range at /1/v
|
|
if len(errs) != 2 {
|
|
t.Fatalf("got %d errs (%v) want 2", len(errs), errs)
|
|
}
|
|
gotPaths := map[string]bool{errs[0].Path: true, errs[1].Path: true}
|
|
if !gotPaths["/1/k"] || !gotPaths["/1/v"] {
|
|
t.Errorf("array-of-objects paths: got %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidate_PtrEnc_SpecialChars(t *testing.T) {
|
|
s := &Schema{
|
|
Type: "object",
|
|
Required: []string{"a/b", "c~d"},
|
|
Properties: map[string]*Schema{
|
|
"a/b": {Type: "string"},
|
|
"c~d": {Type: "string"},
|
|
},
|
|
}
|
|
errs := Validate(s, jsonAny(t, `{}`))
|
|
if len(errs) != 2 {
|
|
t.Fatalf("got %d errs %v", len(errs), errs)
|
|
}
|
|
gotPaths := map[string]bool{errs[0].Path: true, errs[1].Path: true}
|
|
// Per RFC 6901: '/' → '~1', '~' → '~0'.
|
|
if !gotPaths["/a~1b"] {
|
|
t.Errorf("expected /a~1b in %v", errs)
|
|
}
|
|
if !gotPaths["/c~0d"] {
|
|
t.Errorf("expected /c~0d in %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidate_NilSchemaIsNoOp(t *testing.T) {
|
|
if errs := Validate(nil, "anything"); errs != nil {
|
|
t.Errorf("nil schema returned errs: %v", errs)
|
|
}
|
|
}
|