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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- go-gin-api Installs to ~/.agents/skills/go-gin-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install go-gin-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project go-gin-api Installs to .agents/skills/go-gin-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/go-gin-api.zipunzip -o go-gin-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) go-gin-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md,go.mod(Gin version, Go version) and the file that builds the engine (rg -n "gin.New\(|gin.Default\(" --type go). - Note existing conventions: error response shape, auth middleware, how handlers get dependencies. Match them.
- 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
}
SetTrustedProxiesdefaults to trusting every proxy, soc.ClientIP()will believe a spoofedX-Forwarded-For. Set the real proxy CIDRs, ornilwhen traffic arrives directly. Behind Cloudflare or GAE,r.TrustedPlatform = gin.PlatformCloudflarereads 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"`
}
| Method | Behaviour | Use |
|---|---|---|
ShouldBindJSON | Returns the error; you decide the response | Default |
BindJSON / Bind | On error calls AbortWithError(400) and writes a text/plain header | Avoid; it fights your error format |
ShouldBindUri, ShouldBindQuery | Bind path params and query string | Paths and filters |
ShouldBindBodyWithJSON | Caches the body so it can be bound twice | Only 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()withoutreturnkeeps 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.Contextalso implementscontext.Context, but its cancellation only follows the request whenr.ContextWithFallback = true; the request context is unambiguous. gin.Contextis pooled and reused after the handler returns. A goroutine that outlives the handler must usecc := 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.Errorto a single renderer -
http.Serverwith timeouts andShutdown; nor.Run() -
go test -race ./...green
Gotchas
- Path params conflict:
/orders/:idand/orders/exportcoexist in Gin 1.8+, but/orders/:idand/orders/:orderIDat the same position do not; registration panics. c.JSONafterc.Stringin 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.Timefrom a query needstime_format:"2006-01-02"on the tag.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
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.