Validation
Input validation is driven by the standard validate struct tag from
go-playground/validator. goninja
copies that tag onto the generated request types and calls the validator before
anything touches the database.
How it is wired
A validate tag on a model field is copied verbatim onto the matching
<Model>Create and <Model>Update field:
type Book struct {
ID string `gorm:"primaryKey;type:uuid" goninja:"list,retrieve"`
Title string `goninja:"list,retrieve,create,update" validate:"required,max=200"`
AuthorID string `goninja:"list,retrieve,create,update" validate:"required,uuid4"`
Price float64 `goninja:"list,retrieve,create,update" validate:"min=0"`
Stock int `goninja:"list,retrieve,create,update"`
}becomes:
type BookCreate struct {
Title string `json:"title" validate:"required,max=200"`
AuthorID string `json:"author_id" validate:"required,uuid4"`
Price float64 `json:"price" validate:"min=0"`
Stock int `json:"stock"`
}Stock carries no validate tag because the model field had none. The
generated Create and Update methods call goninja.Validate(in) as their
first step, before opening a transaction or issuing a query.
validate tags are only copied onto Create and Update. The List and
Retrieve types never carry them, because validation applies to input, not to
what you return.The failure response
goninja.Validate converts the validator’s errors into a
goninja.ValidationError keyed by JSON field name, not Go field name. The
value is the name of the tag that failed.
A POST /books with an empty title and a negative price returns 422:
{
"code": "VALIDATION_FAILED",
"errors": {
"title": "required",
"price": "min"
}
}Every failing field is reported in one response. See Errors & Responses for the full status mapping.
Validating by hand
Validate is exported, so custom action handlers can use the same rules and
produce the same response shape:
func publishHandler(r *api.BookResource) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
var in PublishRequest
if err := json.NewDecoder(req.Body).Decode(&in); err != nil {
goninja.Respond(w, r.ErrorMapper(), goninja.BadRequest{Detail: "invalid body"})
return
}
if err := goninja.Validate(in); err != nil {
goninja.Respond(w, r.ErrorMapper(), err)
return
}
// ...
}
}func Validate(v any) errorIt returns nil on success, a ValidationError when tags fail, and passes any
other error through unchanged.
Custom tags
Built-in tags (required, max, email, uuid4, oneof, …) work out of the
box. To add your own, register it once at startup — before serving traffic —
with goninja.RegisterValidation:
func RegisterValidation(tag string, fn validator.Func) errorimport "github.com/go-playground/validator/v10"
func main() {
err := goninja.RegisterValidation("isbn", func(fl validator.FieldLevel) bool {
return isValidISBN(fl.Field().String())
})
if err != nil {
log.Fatal(err)
}
// ... mount resources
}Then use it like any other tag:
type Book struct {
ISBN string `goninja:"list,retrieve,create,update" validate:"required,isbn"`
}It forwards to the shared validator instance, so every generated Create and
Update recognises the tag immediately. There is nothing to wire per resource.
Rules validation cannot express
Tag-based validation sees one value at a time, with no database and no request context. Anything needing a query, another field, or the caller’s identity belongs in a hook, which runs inside the operation’s transaction and can abort it by returning an error:
func (r *bookWithChecks) BeforeCreate(ctx context.Context, in *api.BookCreate) error {
var n int64
if err := r.DB(ctx).Model(&models.Book{}).
Where("title = ? AND author_id = ?", in.Title, in.AuthorID).
Count(&n).Error; err != nil {
return err
}
if n > 0 {
return goninja.ValidationError{Fields: map[string]string{
"title": "already exists for this author",
}}
}
return nil
}Returning a ValidationError from a hook produces the same 422 body as a tag
failure. See Hooks & Overrides and
Transactions.