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 <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-22 23:56:58 +02:00
parent dd3cb7ac29
commit 31634f706f
5 changed files with 124 additions and 31 deletions

View File

@@ -81,7 +81,9 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
case "unique": case "unique":
uniqueTable = arg uniqueTable = arg
case "boolean": 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": case "email":
tags = append(tags, "email") tags = append(tags, "email")
case "confirmed": case "confirmed":
@@ -149,14 +151,43 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
} }
tags = withoutTag(tags, "numeric") tags = withoutTag(tags, "numeric")
} }
if required && isEmptyValue(val) {
return []string{validateMessage(ctx, tr, "required", field, nil)}, nil
}
usedBetween := betweenMin != "" && betweenMax != ""
if len(tags) > 0 { if len(tags) > 0 {
tag := strings.Join(tags, ",") msgs := make([]string, 0, len(tags))
if err := validateOnce.Var(val, tag); err != nil { for _, t := range tags {
ruleName := "required" name, _, _ := strings.Cut(t, "=")
if !required { if name == "required" || name == "min" || name == "max" {
ruleName = firstNonOmit(tags) 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 != "" { if uniqueTable != "" {
@@ -368,52 +399,79 @@ func withoutTag(tags []string, name string) []string {
return out 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 { func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field string, params map[string]string) string {
if params == nil { if params == nil {
params = map[string]string{} params = map[string]string{}
} }
params["attribute"] = field params["attribute"] = laravelAttribute(field)
key := "lagoon.validate." + rule key := "lagoon::validate." + rule
if tr != nil { if tr != nil {
s := tr.Get(ctx, key, params) s := tr.Get(ctx, key, params)
if s != "" && s != key { if s != "" && s != key {
return s return s
} }
} }
attr := params["attribute"]
switch rule { switch rule {
case "required": case "required":
return "The " + field + " field is required." return "The " + attr + " field is required."
case "integer": case "integer":
return "The " + field + " must be an integer." return "The " + attr + " must be an integer."
case "numeric": case "numeric":
return "The " + field + " must be a number." return "The " + attr + " must be a number."
case "unique": case "unique":
return "The " + field + " has already been taken." return "The " + attr + " has already been taken."
case "max": case "max":
return "The " + field + " may not be greater than " + params["max"] + "." return "The " + attr + " may not be greater than " + params["max"] + "."
case "min": case "min":
return "The " + field + " must be at least " + params["min"] + "." return "The " + attr + " must be at least " + params["min"] + "."
case "oneof": case "oneof":
return "The selected " + field + " is invalid." return "The selected " + attr + " is invalid."
case "email": case "email":
return "The " + field + " must be a valid email address." return "The " + attr + " must be a valid email address."
case "confirmed": case "confirmed":
return "The " + field + " confirmation does not match." return "The " + attr + " confirmation does not match."
case "different": case "different":
return "The " + field + " and " + params["other"] + " must be different." return "The " + attr + " and " + params["other"] + " must be different."
case "mimes": 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: 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
}

6
phrasebook/lang.go Normal file
View File

@@ -0,0 +1,6 @@
package phrasebook
import "embed"
//go:embed lang
var systemLangFS embed.FS

View File

@@ -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."

View File

@@ -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."

View File

@@ -88,6 +88,9 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
return fmt.Errorf("phrasebook: app is nil") return fmt.Errorf("phrasebook: app is nil")
} }
cat := NewCatalog() cat := NewCatalog()
if err := cat.Load("lagoon", systemLangFS); err != nil {
return err
}
for _, p := range plugins { for _, p := range plugins {
hl, ok := any(p).(pact.HasLang) hl, ok := any(p).(pact.HasLang)
if !ok { if !ok {