milkclient / backend /internal /api /validation.go
AzureAD\AdityaDevarshi
Phase 5: consistent error envelope + validation + leak fix + /api 404
00c2742
Raw
History Blame Contribute Delete
2.5 kB
package api
import (
"net/http"
"reflect"
"strings"
"time"
"github.com/go-playground/validator/v10"
)
// validate is the package-level validator singleton. It is configured once to
// report the json tag name (not the Go field name) so envelope field errors
// match the wire contract, and to recognise the custom `yyyymmdd` date rule.
var validate = newValidator()
func newValidator() *validator.Validate {
v := validator.New(validator.WithRequiredStructEnabled())
// Report fields by their json tag so writeFieldError names match the API.
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// yyyymmdd validates a YYYY-MM-DD calendar date (mirrors validDate).
_ = v.RegisterValidation("yyyymmdd", func(fl validator.FieldLevel) bool {
s := fl.Field().String()
_, err := time.Parse("2006-01-02", s)
return err == nil
})
return v
}
// validationMessage turns a single field error into a human-readable message
// that preserves the intent of the old hand-written messages.
func validationMessage(fe validator.FieldError) string {
field := fe.Field()
switch fe.Tag() {
case "required":
return field + " is required"
case "oneof":
return field + " must be one of: " + strings.ReplaceAll(fe.Param(), " ", ", ")
case "gte":
return field + " must be >= " + fe.Param()
case "gt":
return field + " must be > " + fe.Param()
case "lte":
return field + " must be <= " + fe.Param()
case "min":
return field + " must have at least " + fe.Param() + " item(s)"
case "yyyymmdd":
return field + " must be a YYYY-MM-DD date"
default:
return field + " is invalid"
}
}
// validateStruct runs the struct validation rules. On the first violation it
// writes a VALIDATION (400) field error naming the offending json field and
// returns false; on success it returns true. dive/element errors fall back to
// the struct-level field name so callers always get a usable field.
func validateStruct(w http.ResponseWriter, v any) bool {
err := validate.Struct(v)
if err == nil {
return true
}
if verrs, ok := err.(validator.ValidationErrors); ok && len(verrs) > 0 {
fe := verrs[0]
writeFieldError(w, http.StatusBadRequest, CodeValidation, validationMessage(fe), fe.Field())
return false
}
// Non-field validation error (e.g. invalid validation setup): generic 400.
writeError(w, http.StatusBadRequest, "invalid request body")
return false
}