mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
130 lines
3.0 KiB
Go
130 lines
3.0 KiB
Go
package core
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin/binding"
|
|
"github.com/go-playground/validator/v10"
|
|
)
|
|
|
|
type FieldError struct {
|
|
Field string `json:"field"`
|
|
Tag string `json:"tag"`
|
|
Param string `json:"param,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type ValidationError struct {
|
|
Errors []FieldError `json:"errors"`
|
|
}
|
|
|
|
func (e *ValidationError) Error() string {
|
|
return fmt.Sprintf("validation failed (%d errors)", len(e.Errors))
|
|
}
|
|
|
|
// DecodeStrict decodes a JSON object from raw, rejects unknown fields, and
|
|
// validates struct tags (binding:"..."). Returns a ValidationError for
|
|
// semantic issues so callers can map it to HTTP 422.
|
|
func DecodeStrict[T any](raw json.RawMessage) (T, error) {
|
|
var out T
|
|
|
|
trimmed := bytes.TrimSpace(raw)
|
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
|
return out, &ValidationError{
|
|
Errors: []FieldError{{
|
|
Field: "",
|
|
Tag: "required",
|
|
Message: "config is required",
|
|
}},
|
|
}
|
|
}
|
|
if trimmed[0] != '{' {
|
|
return out, fmt.Errorf("config must be a JSON object")
|
|
}
|
|
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&out); err != nil {
|
|
return out, fmt.Errorf("invalid JSON: %w", err)
|
|
}
|
|
// Ensure no trailing junk after the object
|
|
if err := dec.Decode(&struct{}{}); err != io.EOF {
|
|
if err == nil {
|
|
return out, errors.New("unexpected trailing data after JSON object")
|
|
}
|
|
return out, fmt.Errorf("invalid JSON: %w", err)
|
|
}
|
|
|
|
if binding.Validator == nil {
|
|
return out, errors.New("validator not initialized")
|
|
}
|
|
if err := binding.Validator.ValidateStruct(out); err != nil {
|
|
if verrs, ok := err.(validator.ValidationErrors); ok {
|
|
return out, toValidationError[T](verrs)
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func toValidationError[T any](verrs validator.ValidationErrors) *ValidationError {
|
|
errs := make([]FieldError, 0, len(verrs))
|
|
t := reflect.TypeOf((*T)(nil)).Elem()
|
|
if t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
|
|
for _, fe := range verrs {
|
|
name := fe.Field()
|
|
if t.Kind() == reflect.Struct {
|
|
if sf, ok := t.FieldByName(fe.StructField()); ok {
|
|
if tag := sf.Tag.Get("json"); tag != "" && tag != "-" {
|
|
name = strings.Split(tag, ",")[0]
|
|
} else {
|
|
// fallback to lower-camel for nicer output
|
|
name = lowerCamel(name)
|
|
}
|
|
}
|
|
}
|
|
errs = append(errs, FieldError{
|
|
Field: name,
|
|
Tag: fe.Tag(),
|
|
Param: fe.Param(),
|
|
Message: defaultMsg(fe),
|
|
})
|
|
}
|
|
return &ValidationError{Errors: errs}
|
|
}
|
|
|
|
func defaultMsg(fe validator.FieldError) string {
|
|
switch fe.Tag() {
|
|
case "required":
|
|
return ""
|
|
case "min":
|
|
return fmt.Sprintf("must be at least %s", fe.Param())
|
|
case "max":
|
|
return fmt.Sprintf("must be at most %s", fe.Param())
|
|
case "oneof":
|
|
return "must be one of: " + fe.Param()
|
|
case "hostname", "ip":
|
|
return "must be a valid " + fe.Tag()
|
|
}
|
|
return fe.Error()
|
|
}
|
|
|
|
func lowerCamel(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
r := []rune(s)
|
|
r[0] = []rune(strings.ToLower(string(r[0])))[0]
|
|
return string(r)
|
|
}
|