mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
BREAKING, on purpose. hasBody called itself "the ONE place that rule lives" and
was the only site that called it; three others re-stated it differently and
DELETE had drifted:
- typed.go read a body for anything but GET/HEAD, so a typed Delete decoded a
request the document says has none;
- cli.go's Remote.Invoke SENT that body, so the CLI's remote path and its
in-process path disagreed about one op;
- cli.go's bindIn offered every field as a flag, including ones no URL can
carry — `--tags '["a"]'` on a DELETE marshalled fine, went out as a query
value, and was dropped by the binder. The command silently did nothing.
All four now call hasBody. A typed Delete takes its input from the URL: path
params and query string, which is what every client generated from that document
already sent. It is a rule about the WIRE, not about the op — op.invoke still
decodes whatever JSON it is handed, because a call addressed by NAME (MCP
tools/call, zip.Call, a command) has no URL to carry half the input in.
Two things a bodyless op lost in the document are closed with it:
- REQUIRED-NESS REACHES THE PARAMETER. urlField.required reads the same
`validate:"required"` the body schema does. Every query parameter was
hardcoded optional, so a field the handler refuses to run without was
described as optional and every generated client made the argument optional
to match — a document describing a call that cannot succeed.
- THE EXAMPLE SURVIVES. A bodyless op has no requestBody for the doc comment's
Example to live in, so it rides the parameters that carry its values and
CommandsFromSpec rebuilds the object from them. Before this, every GET and
DELETE reached the published reference and the generated CLI with no example
at all.
TestCLI_SpecAndRegistryAgree is now plain equality over every field of every
command for every method. The skip is gone, and so is the divergence helper that
replaced it one release ago: there is no divergence left to pin.
Also: a validation failure names the field the way the CALLER named it, through
the same jsonFieldName rule the decoder, the schema and the flag all read.
Someone who sent `reason` was told `field "Reason"` is required — a name that
appears nowhere in the document they were working from.
Co-authored-by: Hanzo Dev <[email protected]>
153 lines
3.9 KiB
Go
153 lines
3.9 KiB
Go
package zip
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// validate runs minimal struct-tag validation: required, min=N, max=N,
|
|
// minlen=N, maxlen=N. Operates on string / numeric / slice fields.
|
|
// Anything else passes through.
|
|
//
|
|
// This is deliberately not a full validator (no `github.com/go-playground/validator`
|
|
// — we don't want the dependency). It covers ~90% of REST API input
|
|
// validation needs and is ~120 LOC.
|
|
func validate(v any) error {
|
|
val := reflect.ValueOf(v)
|
|
if val.Kind() == reflect.Pointer {
|
|
if val.IsNil() {
|
|
return nil
|
|
}
|
|
val = val.Elem()
|
|
}
|
|
if val.Kind() != reflect.Struct {
|
|
return nil
|
|
}
|
|
t := val.Type()
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
tag := field.Tag.Get("validate")
|
|
if tag == "" || tag == "-" {
|
|
continue
|
|
}
|
|
// The field is named to the caller the way the caller named it: its wire
|
|
// name, from the same jsonFieldName rule the decoder, the schema and the
|
|
// flag all read. Reporting the Go name told someone who sent `reason`
|
|
// that `"Reason"` was missing — a name that appears nowhere in the
|
|
// document they were working from.
|
|
if err := validateField(jsonFieldName(field), val.Field(i), tag); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateField(name string, val reflect.Value, tag string) error {
|
|
rules := strings.Split(tag, ",")
|
|
for _, r := range rules {
|
|
r = strings.TrimSpace(r)
|
|
if r == "" {
|
|
continue
|
|
}
|
|
var key, raw string
|
|
if i := strings.IndexByte(r, '='); i >= 0 {
|
|
key, raw = r[:i], r[i+1:]
|
|
} else {
|
|
key = r
|
|
}
|
|
switch key {
|
|
case "required":
|
|
if isZero(val) {
|
|
return fmt.Errorf("field %q is required", name)
|
|
}
|
|
case "min":
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("field %q invalid min=%q", name, raw)
|
|
}
|
|
if !checkMin(val, n) {
|
|
return fmt.Errorf("field %q must be >= %d", name, n)
|
|
}
|
|
case "max":
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("field %q invalid max=%q", name, raw)
|
|
}
|
|
if !checkMax(val, n) {
|
|
return fmt.Errorf("field %q must be <= %d", name, n)
|
|
}
|
|
case "minlen":
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("field %q invalid minlen=%q", name, raw)
|
|
}
|
|
if !checkLenMin(val, n) {
|
|
return fmt.Errorf("field %q length must be >= %d", name, n)
|
|
}
|
|
case "maxlen":
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("field %q invalid maxlen=%q", name, raw)
|
|
}
|
|
if !checkLenMax(val, n) {
|
|
return fmt.Errorf("field %q length must be <= %d", name, n)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isZero(v reflect.Value) bool {
|
|
switch v.Kind() {
|
|
case reflect.String:
|
|
return v.Len() == 0
|
|
case reflect.Slice, reflect.Map, reflect.Array:
|
|
return v.Len() == 0
|
|
case reflect.Pointer, reflect.Interface:
|
|
return v.IsNil()
|
|
}
|
|
return v.IsZero()
|
|
}
|
|
|
|
func checkMin(v reflect.Value, n int) bool {
|
|
switch v.Kind() {
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return v.Int() >= int64(n)
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
return v.Uint() >= uint64(n)
|
|
case reflect.Float32, reflect.Float64:
|
|
return v.Float() >= float64(n)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func checkMax(v reflect.Value, n int) bool {
|
|
switch v.Kind() {
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return v.Int() <= int64(n)
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
return v.Uint() <= uint64(n)
|
|
case reflect.Float32, reflect.Float64:
|
|
return v.Float() <= float64(n)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func checkLenMin(v reflect.Value, n int) bool {
|
|
switch v.Kind() {
|
|
case reflect.String, reflect.Slice, reflect.Map, reflect.Array:
|
|
return v.Len() >= n
|
|
}
|
|
return true
|
|
}
|
|
|
|
func checkLenMax(v reflect.Value, n int) bool {
|
|
switch v.Kind() {
|
|
case reflect.String, reflect.Slice, reflect.Map, reflect.Array:
|
|
return v.Len() <= n
|
|
}
|
|
return true
|
|
}
|