Files
warmbly/internal/errx/bind.go
T

178 lines
5.6 KiB
Go

package errx
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"time"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// maxBindProblems caps how many validation failures one refusal lists.
const maxBindProblems = 5
// Validation failures name fields as the caller wrote them (the json key), not as Go does.
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterTagNameFunc(jsonFieldName)
}
}
func jsonFieldName(f reflect.StructField) string {
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == "-" {
return ""
}
return name
}
// InvalidBody is the 400 for a request body that failed to bind, naming what
// was wrong with it: empty, not JSON, the wrong shape, or missing a field.
func InvalidBody(err error) *Error {
var (
syntaxErr *json.SyntaxError
typeErr *json.UnmarshalTypeError
sizeErr *http.MaxBytesError
timeErr *time.ParseError
)
switch {
case err == nil:
return ErrInvalid
case errors.Is(err, io.EOF):
return New(BadRequest, "The request body is empty. Send a JSON body with Content-Type: application/json.")
case errors.Is(err, io.ErrUnexpectedEOF):
return New(BadRequest, "The request body is not valid JSON: it ends before the value is complete.")
case errors.As(err, &sizeErr):
return New(BadRequest, fmt.Sprintf("The request body is larger than the %d byte limit.", sizeErr.Limit))
case errors.As(err, &syntaxErr):
return New(BadRequest, fmt.Sprintf("The request body is not valid JSON: %s (at byte %d).", strings.TrimPrefix(syntaxErr.Error(), "json: "), syntaxErr.Offset))
case errors.As(err, &typeErr):
return typeMismatch(typeErr)
case errors.As(err, &timeErr):
return New(BadRequest, fmt.Sprintf("The request body has a time that is not RFC 3339 (such as 2026-10-01T09:00:00Z): %q.", strings.Trim(timeErr.Value, `"`)))
case strings.HasPrefix(err.Error(), "invalid UUID"):
return New(BadRequest, "The request body has a value that should be a UUID and is not one.")
}
if fes := fieldErrors(err); len(fes) > 0 {
return fieldProblems(fes)
}
return New(BadRequest, "The request body could not be read in the shape this endpoint expects. Check it against the API reference.")
}
func typeMismatch(e *json.UnmarshalTypeError) *Error {
want := jsonKind(e.Type)
got, _, _ := strings.Cut(e.Value, " ")
if e.Field == "" {
return New(BadRequest, fmt.Sprintf("The request body must be a JSON %s, not a JSON %s.", want, got))
}
return New(BadRequest, fmt.Sprintf("Field %q must be a JSON %s, not a JSON %s.", e.Field, want, got))
}
// jsonKind names a Go type by the JSON value that decodes into it.
func jsonKind(t reflect.Type) string {
if t == nil {
return "value"
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Implements(textUnmarshaler) || reflect.PointerTo(t).Implements(textUnmarshaler) {
return "string"
}
switch t.Kind() {
case reflect.Slice, reflect.Array:
return "array"
case reflect.Struct, reflect.Map:
return "object"
case reflect.String:
return "string"
case reflect.Bool:
return "boolean"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
}
return "value"
}
var textUnmarshaler = reflect.TypeFor[interface{ UnmarshalText([]byte) error }]()
// fieldErrors flattens gin's per-element errors for a slice body into one list.
func fieldErrors(err error) []validator.FieldError {
var out []validator.FieldError
var sliceErrs binding.SliceValidationError
var structErrs validator.ValidationErrors
switch {
case errors.As(err, &sliceErrs):
for _, e := range sliceErrs {
out = append(out, fieldErrors(e)...)
}
case errors.As(err, &structErrs):
out = append(out, structErrs...)
}
return out
}
func fieldProblems(errs []validator.FieldError) *Error {
parts := make([]string, 0, maxBindProblems)
for i, fe := range errs {
if i == maxBindProblems {
parts = append(parts, fmt.Sprintf("and %d more", len(errs)-maxBindProblems))
break
}
parts = append(parts, fieldProblem(fe))
}
return New(BadRequest, "The request body is missing or has invalid fields: "+strings.Join(parts, "; ")+".")
}
func fieldProblem(fe validator.FieldError) string {
name := fieldPath(fe)
unit := ""
switch fe.Kind() {
case reflect.String:
unit = " characters"
case reflect.Slice, reflect.Array, reflect.Map:
unit = " items"
}
switch fe.Tag() {
case "required":
return fmt.Sprintf("%q is required", name)
case "min", "gte":
return fmt.Sprintf("%q must be at least %s%s", name, fe.Param(), unit)
case "max", "lte":
return fmt.Sprintf("%q must be at most %s%s", name, fe.Param(), unit)
case "gt":
return fmt.Sprintf("%q must be more than %s%s", name, fe.Param(), unit)
case "lt":
return fmt.Sprintf("%q must be less than %s%s", name, fe.Param(), unit)
case "len":
return fmt.Sprintf("%q must be exactly %s%s", name, fe.Param(), unit)
case "oneof":
return fmt.Sprintf("%q must be one of: %s", name, strings.ReplaceAll(fe.Param(), " ", ", "))
case "email":
return fmt.Sprintf("%q must be an email address", name)
case "uuid", "uuid4":
return fmt.Sprintf("%q must be a UUID", name)
case "url", "http_url":
return fmt.Sprintf("%q must be a URL", name)
}
return fmt.Sprintf("%q failed the %q rule", name, fe.Tag())
}
// fieldPath is the dotted json path of a failing field, without the Go type the namespace starts with.
func fieldPath(fe validator.FieldError) string {
ns := fe.Namespace()
if _, rest, ok := strings.Cut(ns, "."); ok && rest != "" {
return rest
}
return fe.Field()
}