From 31634f706fa3d8d53a7825caeac4ee2db668d963 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 22 Sep 2026 23:56:58 +0200 Subject: [PATCH] feat(07-07): localize lagoon validation with Laravel-shaped Polish messages Replay of the user-api corpus needs lagoon::validate.* catalogs and underscore-to-space attribute names so Go 422 bodies match Winter. Co-authored-by: Cursor --- lagoon/validate.go | 120 +++++++++++++++++++++++-------- phrasebook/lang.go | 6 ++ phrasebook/lang/en/validate.yaml | 13 ++++ phrasebook/lang/pl/validate.yaml | 13 ++++ phrasebook/translator.go | 3 + 5 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 phrasebook/lang.go create mode 100644 phrasebook/lang/en/validate.yaml create mode 100644 phrasebook/lang/pl/validate.yaml diff --git a/lagoon/validate.go b/lagoon/validate.go index 599859b..68b8ecd 100644 --- a/lagoon/validate.go +++ b/lagoon/validate.go @@ -81,7 +81,9 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri case "unique": uniqueTable = arg case "boolean": - // Go's bool field type already enforces this; treat as a type-check no-op. + if !isLaravelBoolean(val) { + return []string{validateMessage(ctx, tr, "boolean", field, nil)}, nil + } case "email": tags = append(tags, "email") case "confirmed": @@ -149,14 +151,43 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri } tags = withoutTag(tags, "numeric") } + if required && isEmptyValue(val) { + return []string{validateMessage(ctx, tr, "required", field, nil)}, nil + } + usedBetween := betweenMin != "" && betweenMax != "" if len(tags) > 0 { - tag := strings.Join(tags, ",") - if err := validateOnce.Var(val, tag); err != nil { - ruleName := "required" - if !required { - ruleName = firstNonOmit(tags) + msgs := make([]string, 0, len(tags)) + for _, t := range tags { + name, _, _ := strings.Cut(t, "=") + if name == "required" || name == "min" || name == "max" { + continue } - return []string{validateMessage(ctx, tr, ruleName, field, nil)}, nil + if err := validateOnce.Var(val, t); err != nil { + ruleName := name + if name == "oneof" { + ruleName = "oneof" + } + msgs = append(msgs, validateMessage(ctx, tr, ruleName, field, map[string]string{ + "min": betweenMin, "max": betweenMax, + })) + } + } + if usedBetween && !numericRange { + n := 0 + if s, ok := val.(string); ok { + n = len(s) + } else if !isEmptyValue(val) { + n = len(fmt.Sprint(val)) + } + minN, maxN := atoiOr(betweenMin, 0), atoiOr(betweenMax, 0) + if n < minN || (maxN > 0 && n > maxN) { + msgs = append(msgs, validateMessage(ctx, tr, "between", field, map[string]string{ + "min": betweenMin, "max": betweenMax, + })) + } + } + if len(msgs) > 0 { + return msgs, nil } } if uniqueTable != "" { @@ -368,52 +399,79 @@ func withoutTag(tags []string, name string) []string { return out } -func firstNonOmit(tags []string) string { - for _, t := range tags { - name, _, _ := strings.Cut(t, "=") - if name != "omitempty" && name != "required" { - return name - } - } - return "invalid" -} - func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string { if params == nil { params = map[string]string{} } - params["attribute"] = field - key := "lagoon.validate." + rule + params["attribute"] = laravelAttribute(field) + key := "lagoon::validate." + rule if tr != nil { s := tr.Get(ctx, key, params) if s != "" && s != key { return s } } + attr := params["attribute"] switch rule { case "required": - return "The " + field + " field is required." + return "The " + attr + " field is required." case "integer": - return "The " + field + " must be an integer." + return "The " + attr + " must be an integer." case "numeric": - return "The " + field + " must be a number." + return "The " + attr + " must be a number." case "unique": - return "The " + field + " has already been taken." + return "The " + attr + " has already been taken." case "max": - return "The " + field + " may not be greater than " + params["max"] + "." + return "The " + attr + " may not be greater than " + params["max"] + "." case "min": - return "The " + field + " must be at least " + params["min"] + "." + return "The " + attr + " must be at least " + params["min"] + "." case "oneof": - return "The selected " + field + " is invalid." + return "The selected " + attr + " is invalid." case "email": - return "The " + field + " must be a valid email address." + return "The " + attr + " must be a valid email address." case "confirmed": - return "The " + field + " confirmation does not match." + return "The " + attr + " confirmation does not match." case "different": - return "The " + field + " and " + params["other"] + " must be different." + return "The " + attr + " and " + params["other"] + " must be different." case "mimes": - return "The " + field + " must be a file of the allowed types." + return "The " + attr + " must be a file of the allowed types." + case "between": + return "The " + attr + " must be between " + params["min"] + " and " + params["max"] + " characters." + case "boolean": + return "The " + attr + " field must be true or false." default: - return "The " + field + " is invalid." + return "The " + attr + " is invalid." } } + +func laravelAttribute(field string) string { + return strings.ReplaceAll(field, "_", " ") +} + +func isLaravelBoolean(val any) bool { + switch v := val.(type) { + case bool: + return true + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "0", "1", "true", "false": + return true + } + return false + case float64: + return v == 0 || v == 1 + case int: + return v == 0 || v == 1 + default: + s := strings.TrimSpace(fmt.Sprint(val)) + return s == "0" || s == "1" || s == "true" || s == "false" + } +} + +func atoiOr(s string, fallback int) int { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return fallback + } + return n +} diff --git a/phrasebook/lang.go b/phrasebook/lang.go new file mode 100644 index 0000000..6cdd4ab --- /dev/null +++ b/phrasebook/lang.go @@ -0,0 +1,6 @@ +package phrasebook + +import "embed" + +//go:embed lang +var systemLangFS embed.FS diff --git a/phrasebook/lang/en/validate.yaml b/phrasebook/lang/en/validate.yaml new file mode 100644 index 0000000..4eca13e --- /dev/null +++ b/phrasebook/lang/en/validate.yaml @@ -0,0 +1,13 @@ +required: "The :attribute field is required." +email: "The :attribute must be a valid email address." +between: "The :attribute must be between :min and :max characters." +boolean: "The :attribute field must be true or false." +unique: "The :attribute has already been taken." +confirmed: "The :attribute confirmation does not match." +different: "The :attribute and :other must be different." +integer: "The :attribute must be an integer." +numeric: "The :attribute must be a number." +max: "The :attribute may not be greater than :max." +min: "The :attribute must be at least :min." +oneof: "The selected :attribute is invalid." +mimes: "The :attribute must be a file of the allowed types." diff --git a/phrasebook/lang/pl/validate.yaml b/phrasebook/lang/pl/validate.yaml new file mode 100644 index 0000000..6caece9 --- /dev/null +++ b/phrasebook/lang/pl/validate.yaml @@ -0,0 +1,13 @@ +required: "Pole :attribute jest wymagane." +email: "Format :attribute jest nieprawidłowy." +between: ":attribute musi zawierać się w granicach :min - :max znaków." +boolean: ":attribute musi mieć wartość prawda albo fałsz" +unique: "Taki :attribute już występuje." +confirmed: "Potwierdzenie :attribute nie zgadza się." +different: ":attribute oraz :other muszą się różnić." +integer: ":attribute musi być liczbą całkowitą." +numeric: ":attribute musi być liczbą." +max: ":attribute nie może być dłuższy niż :max znaków." +min: ":attribute musi mieć przynajmniej :min znaków." +oneof: "Zaznaczony :attribute jest nieprawidłowy." +mimes: ":attribute musi być plikiem typu :values." diff --git a/phrasebook/translator.go b/phrasebook/translator.go index a0f6ea4..ddafbc8 100644 --- a/phrasebook/translator.go +++ b/phrasebook/translator.go @@ -88,6 +88,9 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error return fmt.Errorf("phrasebook: app is nil") } cat := NewCatalog() + if err := cat.Load("lagoon", systemLangFS); err != nil { + return err + } for _, p := range plugins { hl, ok := any(p).(pact.HasLang) if !ok {