Gin APIs in Go skill for Codex

Bind, validate and abort the way Gin expects. Codex writes gin-gonic handlers that bind with ShouldBind, validate through struct tags and registered custom rules, and report JSON field names instead of raw validator strings. Errors flow through c.Error to one renderer, middleware returns after Abort, trusted proxies are set so ClientIP cannot be spoofed, and the engine runs inside an http.Server with timeouts and graceful shutdown.

  • Backend & APIs
  • $go-gin-api
  • 239 lines
  • 2 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- go-gin-api

Installs to ~/.agents/skills/go-gin-api/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $go-gin-api, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds and hardens Gin APIs in Go - route groups, ShouldBind for JSON, query and URI binding with validator v10 tags and custom validators, error-collecting middleware, auth middleware with c.Set and c.Abort, trusted proxies, release mode, graceful shutdown through http.Server, and httptest tests against the engine. Use when adding endpoints or middleware to a gin-gonic project, fixing binding or validation errors, a handler writes twice, ClientIP returns the wrong address, or a goroutine misuses gin.Context.

Use it when

  • Adding routes, groups or middleware to a gin-gonic service
  • Binding or validation errors are confusing, inconsistent or reject valid zero values
  • Logs warn that headers were already written, or ClientIP is wrong behind a proxy
  • A Gin service starts with r.Run() and needs timeouts and graceful shutdown

Use something else when

  • The service uses net/http without a framework: use go-http-services
  • You are designing resource naming, status codes and pagination across the API: use rest-api-design

How to check the result

go test -race passes with table-driven requests through the real engine covering bad input, auth failure and success, and each returns the agreed JSON error shape and status.

The mistake it prevents

Calling c.Abort() without returning, or using BindJSON, so the handler writes a second response or a text/plain 400 that breaks the API's error format.

~/.agents/skills/go-gin-api/SKILL.md

--- name: go-gin-api description: Builds and hardens Gin APIs in Go - route groups, ShouldBind for JSON, query and URI binding with validator v10 tags and custom validators,… license: MIT ---

Done well means: handlers bind with ShouldBind* and return one consistent error shape, validation lives in struct tags plus a few registered custom rules, middleware aborts explicitly, the engine runs behind an http.Server with timeouts and graceful shutdown, and ClientIP() is trustworthy because proxies are configured.

Before changing anything

  1. Read AGENTS.md, go.mod (Gin version, Go version) and the file that builds the engine (rg -n "gin.New\(|gin.Default\(" --type go).
  2. Note existing conventions: error response shape, auth middleware, how handlers get dependencies. Match them.
  3. Baseline: go build ./... && go test ./.... Fetching new modules needs network.

Engine setup

func newRouter(h *Handlers) *gin.Engine {
	if os.Getenv("GIN_MODE") == "" {
		gin.SetMode(gin.ReleaseMode)
	}
	r := gin.New()                        // not gin.Default(): choose middleware explicitly
	r.Use(requestID(), slogLogger(), gin.Recovery(), errorRenderer())
	_ = r.SetTrustedProxies([]string{"10.0.0.0/8"}) // or nil if no proxy
	r.MaxMultipartMemory = 8 << 20

	r.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusNoContent) })

	v1 := r.Group("/v1")
	v1.Use(requireAuth(h.tokens))
	{
		v1.GET("/orders/:id", h.getOrder)
		v1.POST("/orders", h.createOrder)
		v1.GET("/orders", h.listOrders)
	}
	return r
}
  • SetTrustedProxies defaults to trusting every proxy, so c.ClientIP() will believe a spoofed X-Forwarded-For. Set the real proxy CIDRs, or nil when traffic arrives directly. Behind Cloudflare or GAE, r.TrustedPlatform = gin.PlatformCloudflare reads the platform header.
  • Handlers are methods on a struct holding dependencies; no package-level DB handles.

Binding and validation

type CreateOrder struct {
	SKU      string `json:"sku" binding:"required,alphanum,max=32"`
	Quantity int    `json:"quantity" binding:"required,gte=1,lte=100"`
	Email    string `json:"email" binding:"required,email"`
	Coupon   string `json:"coupon" binding:"omitempty,coupon"`
}

type OrderURI struct {
	ID string `uri:"id" binding:"required,uuid"`
}

type ListQuery struct {
	Limit  int    `form:"limit,default=20" binding:"gte=1,lte=100"`
	Cursor string `form:"cursor"`
}
MethodBehaviourUse
ShouldBindJSONReturns the error; you decide the responseDefault
BindJSON / BindOn error calls AbortWithError(400) and writes a text/plain headerAvoid; it fights your error format
ShouldBindUri, ShouldBindQueryBind path params and query stringPaths and filters
ShouldBindBodyWithJSONCaches the body so it can be bound twiceOnly when middleware also reads the body

A request body can be read once. Binding twice with ShouldBindJSON gives EOF on the second call.

binding:"required" on an int rejects 0, because the validator cannot tell zero from missing. Use a pointer (*int) when zero is a valid value.

Register custom validators once at startup:

if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
	_ = v.RegisterValidation("coupon", func(fl validator.FieldLevel) bool {
		return couponPattern.MatchString(fl.Field().String())
	})
	v.RegisterTagNameFunc(func(f reflect.StructField) string {
		return strings.SplitN(f.Tag.Get("json"), ",", 2)[0] // report JSON names in errors
	})
}

Turn validator.ValidationErrors into field-level messages rather than echoing the raw error string:

func (h *Handlers) createOrder(c *gin.Context) {
	var req CreateOrder
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusUnprocessableEntity, validationBody(err))
		return
	}
	order, err := h.orders.Create(c.Request.Context(), req)
	if err != nil {
		_ = c.Error(err) // errorRenderer maps it
		return
	}
	c.JSON(http.StatusCreated, order)
}

func validationBody(err error) gin.H {
	var ve validator.ValidationErrors
	if errors.As(err, &ve) {
		fields := map[string]string{}
		for _, fe := range ve {
			fields[fe.Field()] = fe.Tag()
		}
		return gin.H{"error": "validation failed", "fields": fields}
	}
	return gin.H{"error": "malformed request body"}
}

Middleware

func requireAuth(tokens TokenVerifier) gin.HandlerFunc {
	return func(c *gin.Context) {
		claims, err := tokens.Verify(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
			return // Abort stops later handlers; return stops this one
		}
		c.Set("userID", claims.Subject)
		c.Next()
	}
}

func errorRenderer() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Next()
		if len(c.Errors) == 0 || c.Writer.Written() {
			return
		}
		err := c.Errors.Last().Err
		switch {
		case errors.Is(err, ErrNotFound):
			c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		case errors.Is(err, ErrConflict):
			c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
		default:
			slog.ErrorContext(c.Request.Context(), "request failed", "err", err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
		}
	}
}
  • c.Abort() without return keeps running the current function and can write twice (“headers were already written” warning).
  • Read values with typed getters (c.GetString("userID")), and define keys as constants.

Context and goroutines

  • Pass c.Request.Context() to services and the database. *gin.Context also implements context.Context, but its cancellation only follows the request when r.ContextWithFallback = true; the request context is unambiguous.
  • gin.Context is pooled and reused after the handler returns. A goroutine that outlives the handler must use cc := c.Copy() and must not write a response.

Server and shutdown

Do not call r.Run() in production: it has no timeouts and no graceful shutdown.

srv := &http.Server{
	Addr:              ":8080",
	Handler:           newRouter(h),
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       15 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       120 * time.Second,
}
go func() {
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)

Testing

func TestCreateOrderValidation(t *testing.T) {
	gin.SetMode(gin.TestMode)
	r := newRouter(&Handlers{orders: fakeOrders{}, tokens: allowAll{}})

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/v1/orders", strings.NewReader(`{"sku":"A1","quantity":0}`))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer test")
	r.ServeHTTP(w, req)

	if w.Code != http.StatusUnprocessableEntity {
		t.Fatalf("code = %d body = %s", w.Code, w.Body)
	}
}

Test through the real engine so middleware, groups and binding run. Table-drive the validation cases: missing field, wrong type, out-of-range, unknown custom tag value.

Checklist

  • gin.New() with explicit middleware; release mode outside development
  • Trusted proxies set; ClientIP() checked behind the real proxy
  • ShouldBind* everywhere; one validation error shape; JSON field names in errors
  • Errors flow through c.Error to a single renderer
  • http.Server with timeouts and Shutdown; no r.Run()
  • go test -race ./... green

Gotchas

  • Path params conflict: /orders/:id and /orders/export coexist in Gin 1.8+, but /orders/:id and /orders/:orderID at the same position do not; registration panics.
  • c.JSON after c.String in the same handler appends to the body rather than replacing it.
  • gin.Default() logs every request to stdout in text; replace it when logs are shipped as JSON.
  • Binding time.Time from a query needs time_format:"2006-01-02" on the tag.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Gin APIs in Go"
  short_description: "Bind, validate and abort the way Gin expects"
  default_prompt: "$go-gin-api Add a validated POST /v1/orders endpoint to our Gin API"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem