init push
This commit is contained in:
95
internal/transport/http/validators/validator.go
Normal file
95
internal/transport/http/validators/validator.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
|
||||
"wucher/internal/transport/http/jsonapi"
|
||||
)
|
||||
|
||||
type Validator struct {
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
var hoursPattern = regexp.MustCompile(`^\d+(\.\d+)?$`)
|
||||
|
||||
// validateHoursOrDate accepts an hours value (e.g. "1200" or "1200.5") or a calendar
|
||||
// date "YYYY-MM-DD". Anything else (e.g. "1200 FH") is rejected.
|
||||
func validateHoursOrDate(fl validator.FieldLevel) bool {
|
||||
s := strings.TrimSpace(fl.Field().String())
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
if hoursPattern.MatchString(s) {
|
||||
return true
|
||||
}
|
||||
_, err := time.Parse("2006-01-02", s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func New() *Validator {
|
||||
v := validator.New()
|
||||
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
name := fld.Tag.Get("json")
|
||||
if name == "" {
|
||||
return fld.Name
|
||||
}
|
||||
if idx := strings.Index(name, ","); idx != -1 {
|
||||
return name[:idx]
|
||||
}
|
||||
return name
|
||||
})
|
||||
_ = v.RegisterValidation("hours_or_date", validateHoursOrDate)
|
||||
return &Validator{validate: v}
|
||||
}
|
||||
|
||||
func (v *Validator) ValidateStruct(s any) []jsonapi.ErrorObject {
|
||||
if err := v.validate.Struct(s); err != nil {
|
||||
if ve, ok := err.(validator.ValidationErrors); ok {
|
||||
return toJSONAPIErrors(ve)
|
||||
}
|
||||
return []jsonapi.ErrorObject{{
|
||||
Status: "400",
|
||||
Title: "Invalid request",
|
||||
Detail: err.Error(),
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toJSONAPIErrors(ve validator.ValidationErrors) []jsonapi.ErrorObject {
|
||||
errs := make([]jsonapi.ErrorObject, 0, len(ve))
|
||||
for _, fe := range ve {
|
||||
field := fe.Field()
|
||||
jsonPath := "/data/attributes/" + strings.ReplaceAll(field, ".", "/")
|
||||
errs = append(errs, jsonapi.ErrorObject{
|
||||
Status: "422",
|
||||
Title: "Validation error",
|
||||
Detail: validationMessage(field, fe),
|
||||
Source: &jsonapi.ErrorSource{Pointer: jsonPath},
|
||||
})
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validationMessage(field string, fe validator.FieldError) string {
|
||||
switch fe.Tag() {
|
||||
case "required":
|
||||
return field + " is required"
|
||||
case "uuid":
|
||||
return field + " must be a valid UUID"
|
||||
case "email":
|
||||
return field + " must be a valid email address"
|
||||
case "hours_or_date":
|
||||
return field + " must be flight hours (e.g. 1200) or a date in YYYY-MM-DD format"
|
||||
case "oneof":
|
||||
return field + " must be one of: " + fe.Param()
|
||||
default:
|
||||
return fmt.Sprintf("%s is invalid (%s)", field, fe.Tag())
|
||||
}
|
||||
}
|
||||
119
internal/transport/http/validators/validator_test.go
Normal file
119
internal/transport/http/validators/validator_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type validatorSample struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
type validatorTagNameSample struct {
|
||||
NoTag string `validate:"required"`
|
||||
Nick string `json:"nick,omitempty" validate:"required"`
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
v := New()
|
||||
if v == nil || v.validate == nil {
|
||||
t.Fatalf("expected validator instance")
|
||||
}
|
||||
}
|
||||
|
||||
type hoursOrDateSample struct {
|
||||
Due string `json:"due" validate:"omitempty,hours_or_date"`
|
||||
}
|
||||
|
||||
func TestHoursOrDateValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
valid bool
|
||||
}{
|
||||
{"", true},
|
||||
{"1200", true},
|
||||
{"1200.5", true},
|
||||
{"2026-06-17", true},
|
||||
{"1200 FH", false},
|
||||
{"1200FH", false},
|
||||
{"FH", false},
|
||||
{"17-06-2026", false},
|
||||
{"abc", false},
|
||||
{"-5", false},
|
||||
}
|
||||
v := New()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
errs := v.ValidateStruct(hoursOrDateSample{Due: tc.in})
|
||||
if tc.valid && errs != nil {
|
||||
t.Fatalf("%q should be valid, got %+v", tc.in, errs)
|
||||
}
|
||||
if !tc.valid && errs == nil {
|
||||
t.Fatalf("%q should be rejected", tc.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStruct(t *testing.T) {
|
||||
t.Run("valid struct", func(t *testing.T) {
|
||||
v := New()
|
||||
errs := v.ValidateStruct(validatorSample{Email: "user@example.com", Name: "John"})
|
||||
if errs != nil {
|
||||
t.Fatalf("expected nil errors, got %+v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation errors converted to jsonapi", func(t *testing.T) {
|
||||
v := New()
|
||||
errs := v.ValidateStruct(validatorSample{Email: "invalid", Name: ""})
|
||||
if len(errs) < 2 {
|
||||
t.Fatalf("expected at least 2 validation errors, got %+v", errs)
|
||||
}
|
||||
for _, e := range errs {
|
||||
if e.Status != "422" {
|
||||
t.Fatalf("expected status 422, got %q", e.Status)
|
||||
}
|
||||
if e.Title != "Validation error" {
|
||||
t.Fatalf("expected validation title, got %q", e.Title)
|
||||
}
|
||||
if e.Source == nil || !strings.HasPrefix(e.Source.Pointer, "/data/attributes/") {
|
||||
t.Fatalf("expected jsonapi pointer, got %+v", e.Source)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non validation error", func(t *testing.T) {
|
||||
v := New()
|
||||
errs := v.ValidateStruct(123)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected single error, got %+v", errs)
|
||||
}
|
||||
if errs[0].Status != "400" || errs[0].Title != "Invalid request" {
|
||||
t.Fatalf("unexpected error object: %+v", errs[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tag name resolver handles empty and comma json tags", func(t *testing.T) {
|
||||
v := New()
|
||||
errs := v.ValidateStruct(validatorTagNameSample{})
|
||||
if len(errs) != 2 {
|
||||
t.Fatalf("expected 2 validation errors, got %+v", errs)
|
||||
}
|
||||
|
||||
var seenNoTag bool
|
||||
var seenNick bool
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Detail, "NoTag is required") && e.Source != nil && e.Source.Pointer == "/data/attributes/NoTag" {
|
||||
seenNoTag = true
|
||||
}
|
||||
if strings.Contains(e.Detail, "nick is required") && e.Source != nil && e.Source.Pointer == "/data/attributes/nick" {
|
||||
seenNick = true
|
||||
}
|
||||
}
|
||||
if !seenNoTag || !seenNick {
|
||||
t.Fatalf("expected errors for NoTag and nick fields, got %+v", errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user