go 54 lines · 9 steps

Cross-field query validation in Gin

Bind query parameters into a struct and enforce mutually-exclusive filters with a struct-level validator.

Explained by highlit
1package handlers
2 
3type ListFilters struct {
4 Status string `form:"status" binding:"omitempty,oneof=active archived all"`
5 Since string `form:"since" binding:"omitempty,datetime=2006-01-02"`
6 Until string `form:"until" binding:"omitempty,datetime=2006-01-02"`
7 Recent bool `form:"recent"`
8 All bool `form:"all"`
9 Cursor string `form:"cursor"`
10 Page int `form:"page" binding:"omitempty,min=1"`
11}
12 
13func listFiltersExclusive(sl validator.StructLevel) {
14 f := sl.Current().Interface().(ListFilters)
15 
16 if f.Recent && f.All {
17 sl.ReportError(f.Recent, "recent", "Recent", "excluded_with", "all")
18 sl.ReportError(f.All, "all", "All", "excluded_with", "recent")
19 }
20 
21 if f.Cursor != "" && f.Page != 0 {
22 sl.ReportError(f.Cursor, "cursor", "Cursor", "excluded_with", "page")
23 sl.ReportError(f.Page, "page", "Page", "excluded_with", "cursor")
24 }
25 
26 if f.Recent && (f.Since != "" || f.Until != "") {
27 sl.ReportError(f.Recent, "recent", "Recent", "excluded_with", "since_until")
28 }
29}
30 
31func RegisterFilterValidators() error {
32 v, ok := binding.Validator.Engine().(*validator.Validate)
33 if !ok {
34 return fmt.Errorf("gin validator engine is not *validator.Validate")
35 }
36 v.RegisterStructValidation(listFiltersExclusive, ListFilters{})
37 return nil
38}
39 
40func ListItems(c *gin.Context) {
41 var filters ListFilters
42 if err := c.ShouldBindQuery(&filters); err != nil {
43 c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
44 return
45 }
46 
47 items, err := itemsService.List(c.Request.Context(), filters)
48 if err != nil {
49 c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list items"})
50 return
51 }
52 
53 c.JSON(http.StatusOK, gin.H{"data": items})
54}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Struct tags cover per-field rules, but relationships between fields need a struct-level validator.
  2. 2Registering validation with Gin's shared engine lets binding enforce your rules automatically.
  3. 3Returning 422 on bind failure keeps invalid input from ever reaching your service layer.

Related explainers

Share this explainer

Here's the card — post it anywhere.

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