go 42 lines · 7 steps

Custom struct-level validation in Gin

Register a cross-field validator so Gin rejects expired card dates during JSON binding.

Explained by highlit
1package handlers
2 
3import (
4 "net/http"
5 "time"
6 
7 "github.com/gin-gonic/gin"
8 "github.com/go-playground/validator/v10"
9)
10 
11type CardExpiry struct {
12 Month int `json:"month" binding:"required,min=1,max=12"`
13 Year int `json:"year" binding:"required,min=2000,max=2099"`
14}
15 
16func ExpiryNotPast(sl validator.StructLevel) {
17 exp := sl.Current().Interface().(CardExpiry)
18 
19 now := time.Now()
20 lastDay := time.Date(exp.Year, time.Month(exp.Month)+1, 0, 23, 59, 59, 0, time.UTC)
21 
22 if lastDay.Before(now) {
23 sl.ReportError(exp.Month, "Month", "Month", "cardexpired", "")
24 sl.ReportError(exp.Year, "Year", "Year", "cardexpired", "")
25 }
26}
27 
28func RegisterCardValidators() {
29 if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
30 v.RegisterStructValidation(ExpiryNotPast, CardExpiry{})
31 }
32}
33 
34func ChargeCard(c *gin.Context) {
35 var exp CardExpiry
36 if err := c.ShouldBindJSON(&exp); err != nil {
37 c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
38 return
39 }
40 
41 c.JSON(http.StatusOK, gin.H{"valid_through": exp})
42}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Field-level binding tags can't express rules that depend on multiple fields together, so struct-level validators fill the gap.
  2. 2Registering a validator once on Gin's shared engine makes it fire automatically on every bind of that type.
  3. 3Computing the last moment of the expiry month lets a card stay valid through its final day rather than expiring early.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Custom struct-level validation in Gin — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code