mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
178 lines
5.6 KiB
Go
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()
|
|
}
|