| name | go-gin-api |
| description | Gin HTTP API conventions — handler structure, middleware chain, error handling, validation, auth, streaming, OpenAPI, versioning. Load when building APIs with github.com/gin-gonic/gin. |
| argument-hint | Building or modifying a Gin HTTP API — handlers, routes, middleware, or endpoint tests |
| stack | go, gin, swaggo |
Go Gin API
Gin-specific conventions for HTTP API development. Layers on top of go-foundations — this skill handles the transport layer (handlers, routing, middleware) while foundations handles DI, testing, error types, and module structure.
Prerequisite: go-foundations must also be loaded. This skill references errorx, clock, slogx, and aop without redefining them.
Handler Layer Structure
Each domain module gets a handler/ sub-package alongside the service/ and repository/ packages from go-foundations. Handlers are thin adapters — HTTP in, service call, HTTP out.
internal/user/
handler/ # ← Gin transport layer
user_handler.go
user_handler_test.go
Handler Definition
Handler owns its service interface (consumer-defined — see go-foundations):
package handler
import (
"github.com/gin-gonic/gin"
"myapp/internal/user/models"
)
type UserService interface {
Create(ctx context.Context, input CreateUserInput) (*models.User, error)
GetByID(ctx context.Context, id string) (*models.User, error)
}
type UserHandler struct {
svc UserService
}
func NewUserHandler(svc UserService) *UserHandler {
return &UserHandler{svc: svc}
}
Route Registration
Factory wires handlers and returns a route registration function:
package user
func NewModule(db *sql.DB) *Services {
repo := repository.NewUserRepository(db)
svc := service.NewUserService(repo)
h := handler.NewUserHandler(svc)
return &Services{
UserService: svc,
RegisterRoutes: h.RegisterRoutes,
}
}
type Services struct {
UserService service.UserServiceAPI
RegisterRoutes func(rg *gin.RouterGroup)
}
func (h *UserHandler) RegisterRoutes(rg *gin.RouterGroup) {
users := rg.Group("/users")
users.POST("", h.Create)
users.GET("/:id", h.GetByID)
}
userMod := user.NewModule(db)
r := gin.New()
v1 := r.Group("/api/v1")
userMod.RegisterRoutes(v1)
Handler Rules
- Handlers call services, never repositories. If a handler imports a repository package, the module structure is broken.
- Handlers never contain business logic. Conditionals beyond input parsing belong in the service layer.
- Handlers import
models/ (DTOs), not domain/. The handler deals in API representation, not persistence types.
- One handler struct per resource. Don't put all endpoints in one mega-handler.
- Return model/DTO structs directly from handlers — no envelope wrapper (
gin.H{"data": ...}) for resource responses. Envelope only for health checks and non-resource payloads.
Routing & Route Groups
Routes organized by resource within versioned groups:
func setupRouter(userMod *user.Services, taskMod *task.Services) *gin.Engine {
r := gin.New()
r.Use(RequestIDMiddleware(), ClockMiddleware(), LoggerMiddleware(), RecoveryMiddleware())
v1 := r.Group("/api/v1")
userMod.RegisterRoutes(v1)
taskMod.RegisterRoutes(v1)
r.GET("/health/live", healthLiveness)
r.GET("/health/ready", healthReadiness)
return r
}
Route conventions:
- Resources are plural nouns:
/users, /tasks, /projects
- Nested resources for ownership:
/users/:userId/tasks
- Actions that don't map to CRUD use verbs:
POST /users/:id/activate
- IDs in paths use
:id (Gin param syntax), not query strings for resource identity
Middleware Chain
Order matters — each middleware enriches the context for the next: RequestID → Clock → Logger → Recovery → [Auth] → Handler
RequestID Middleware
Generates or propagates a request ID. Follows go-foundations With*/FromContext pattern.
package middleware
const RequestIDHeader = "X-Request-ID"
type requestIDKey struct{}
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader(RequestIDHeader)
if id == "" {
id = uuid.New().String()
}
ctx := context.WithValue(c.Request.Context(), requestIDKey{}, id)
c.Request = c.Request.WithContext(ctx)
c.Header(RequestIDHeader, id)
c.Next()
}
}
func RequestID(ctx context.Context) string {
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
return id
}
return ""
}
Clock, Logger, and Recovery Middleware
func ClockMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := clock.WithTime(c.Request.Context(), time.Now().UTC())
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func LoggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
logger := slog.Default().With(
"requestId", RequestID(c.Request.Context()),
"method", c.Request.Method,
"path", c.Request.URL.Path,
)
ctx := slogx.WithLogger(c.Request.Context(), logger)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func RecoveryMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
slogx.Logger(c.Request.Context()).Error("panic recovered", "panic", r, "stack", string(debug.Stack()))
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
})
}
}()
c.Next()
}
}
Middleware Context Pattern
All middleware follows the same pattern — enrich context, replace request, call next:
ctx := somePackage.WithValue(c.Request.Context(), value)
c.Request = c.Request.WithContext(ctx)
c.Next()
Never use c.Set()/c.Get() for cross-cutting concerns. Use c.Request.Context() with typed keys — services and repositories read from context.Context, not Gin-specific storage.
Error Handling Middleware
Transport-layer counterpart to go-foundations' errorx. Handlers call c.Error(err) and return. The middleware runs after the handler chain:
func ErrorMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 {
return
}
err := c.Errors.Last().Err
var appErr *errorx.AppError
if errors.As(err, &appErr) {
status := sentinelToHTTPStatus(appErr.Cause)
c.JSON(status, gin.H{
"code": appErr.Code,
"message": appErr.Message,
"details": appErr.Details,
})
return
}
slogx.Logger(c.Request.Context()).Error("unhandled error", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
})
}
}
func sentinelToHTTPStatus(err error) int {
switch {
case errors.Is(err, errorx.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, errorx.ErrAlreadyExists):
return http.StatusConflict
case errors.Is(err, errorx.ErrValidation):
return http.StatusBadRequest
case errors.Is(err, errorx.ErrNotAuthorized):
return http.StatusForbidden
default:
return http.StatusInternalServerError
}
}
Handler Error Pattern
Handlers pass errors through c.Error() — never map to HTTP status directly:
func (h *UserHandler) GetByID(c *gin.Context) {
user, err := h.svc.GetByID(c.Request.Context(), c.Param("id"))
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, user)
}
Rules:
- Handlers never import
errorx — services create AppError, middleware renders them
- Handlers never call
c.AbortWithStatusJSON for business errors — only the error middleware maps errors to HTTP status
- Handlers write success responses directly (
c.JSON(200, ...)). Only errors go through the middleware.
Request Validation
Gin's struct binding with go-playground/validator tags, mapped to errorx.AppError for consistent responses.
Binding Structs
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=1,max=100"`
LastName string `json:"lastName" binding:"required,min=1,max=100"`
Role string `json:"role" binding:"required,oneof=admin member viewer"`
}
Validation in Handlers
func (h *UserHandler) Create(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(validationError(err))
return
}
user, err := h.svc.Create(c.Request.Context(), toCreateInput(req))
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, user)
}
Validation Error Mapping
Convert validator.ValidationErrors into errorx.AppError with field-level details:
func validationError(err error) *errorx.AppError {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
fields := make(map[string]string, len(ve))
for _, fe := range ve {
fields[fe.Field()] = fmt.Sprintf("failed on '%s'", fe.Tag())
}
return errorx.New(errorx.ErrValidation, "VALIDATION_ERROR", "Request validation failed").
WithDetails(map[string]any{"fields": fields})
}
return errorx.New(errorx.ErrValidation, "INVALID_REQUEST", err.Error())
}
Customize the field error messages per tag (required, email, min, max, oneof) in a switch — the agent generalizes from the pattern above.
Custom Validators
Register at startup, not in handlers:
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
return regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(fl.Field().String())
})
}
JWT Auth Middleware
Token extraction, validation, and claims injection following go-foundations With*/FromContext pattern.
Auth Context
package authx
type Claims struct {
UserID string
Email string
Role string
}
type ctxKey struct{}
func WithClaims(ctx context.Context, claims *Claims) context.Context {
return context.WithValue(ctx, ctxKey{}, claims)
}
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(ctxKey{}).(*Claims)
return claims, ok
}
Auth Middleware
func AuthMiddleware(secretKey []byte) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if len(header) < 8 || header[:7] != "Bearer " {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "UNAUTHORIZED", "message": "Missing or invalid authorization header",
})
return
}
claims, err := validateJWT(header[7:], secretKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": "UNAUTHORIZED", "message": "Invalid or expired token",
})
return
}
ctx := authx.WithClaims(c.Request.Context(), claims)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
Apply auth selectively — not all routes need it:
v1 := r.Group("/api/v1")
v1.POST("/auth/login", authHandler.Login)
v1.POST("/auth/register", authHandler.Register)
protected := v1.Group("", AuthMiddleware(secretKey))
userMod.RegisterRoutes(protected)
taskMod.RegisterRoutes(protected)
In services: read claims from context, never pass as function parameters:
claims, ok := authx.ClaimsFromContext(ctx)
if !ok {
return nil, errorx.New(errorx.ErrNotAuthorized, "AUTH_REQUIRED", "Authentication required")
}
File Streaming
Never buffer entire files in memory. Use streaming for both uploads and downloads.
Streaming Upload
Read from the multipart stream — never load the full file into memory. Service accepts io.Reader, not []byte:
func (h *FileHandler) Upload(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
c.Error(errorx.New(errorx.ErrValidation, "MISSING_FILE", "File is required"))
return
}
if fileHeader.Size > maxUploadSize {
c.Error(errorx.New(errorx.ErrValidation, "FILE_TOO_LARGE",
fmt.Sprintf("File exceeds maximum size of %d bytes", maxUploadSize)))
return
}
src, err := fileHeader.Open()
if err != nil {
c.Error(fmt.Errorf("opening uploaded file: %w", err))
return
}
defer src.Close()
result, err := h.svc.StoreFile(c.Request.Context(), src, fileHeader.Filename, fileHeader.Size)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, result)
}
Streaming Download
Service returns io.ReadCloser, not []byte. Stream directly to the response writer:
func (h *FileHandler) Download(c *gin.Context) {
meta, reader, err := h.svc.GetFile(c.Request.Context(), c.Param("id"))
if err != nil {
c.Error(err)
return
}
defer reader.Close()
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, meta.Filename))
c.Header("Content-Type", meta.ContentType)
c.Header("Content-Length", strconv.FormatInt(meta.Size, 10))
c.Status(http.StatusOK)
if _, err := io.Copy(c.Writer, reader); err != nil {
slogx.Logger(c.Request.Context()).Error("streaming file failed", "error", err)
}
}
Streaming Rules
- Set
r.MaxMultipartMemory at engine level: r.MaxMultipartMemory = 8 << 20 (8 MB default for multipart parsing buffer)
- Always set size limits before processing uploads — reject oversized files early
- Services accept
io.Reader, return io.ReadCloser — never []byte for file content
- Set appropriate timeouts for large file transfers — override per-route if needed
- Log streaming errors but don't attempt JSON error responses — once streaming begins, the response headers are already sent
OpenAPI / Swagger
Comment-based OpenAPI annotation with swaggo/swag. Annotations live on handler methods.
Handler Annotations
func (h *UserHandler) Create(c *gin.Context) { }
Setup
import (
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "myapp/docs"
)
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
Generate spec: swag init -g cmd/server/main.go -o docs/
Annotation Rules
- Every public endpoint gets annotations —
@Summary, @Tags, @Param, @Success, @Failure, @Router
@Tags match the resource name — users, tasks, files
@Security BearerAuth on protected routes
- Error responses use a shared
ErrorResponse matching the errorx JSON shape:
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
}
API Versioning
URL path prefix versioning with route groups per version.
v1 := r.Group("/api/v1")
userMod.RegisterRoutes(v1)
taskMod.RegisterRoutes(v1)
v2 := r.Group("/api/v2")
userModV2.RegisterRoutes(v2)
taskMod.RegisterRoutes(v2)
Versioning Rules
- New version only when breaking changes are unavoidable — adding fields, adding endpoints, or adding optional parameters does NOT require a new version
- Breaking changes: removing fields, renaming fields, changing field types, changing response structure, removing endpoints
- Both versions run simultaneously — deprecate the old one, don't remove it immediately
- Deprecation header on old versions:
func DeprecationMiddleware(sunset string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Deprecation", "true")
c.Header("Sunset", sunset)
c.Next()
}
}
v1 := r.Group("/api/v1", DeprecationMiddleware("Sat, 01 Nov 2025 00:00:00 GMT"))
- Shared service layer — version differences live in handlers and request/response types, not in business logic. Both v1 and v2 handlers call the same services.
Health & Graceful Shutdown
Health Endpoints
Two separate endpoints — liveness (is the process alive?) and readiness (can it serve traffic?):
func healthLiveness(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func healthReadiness(db *sql.DB) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unavailable",
"checks": gin.H{"database": "down"},
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"checks": gin.H{"database": "up"},
})
}
}
Register without version prefix — orchestrators (k8s, ECS) call these directly:
r.GET("/health/live", healthLiveness)
r.GET("/health/ready", healthReadiness(db))
Graceful Shutdown
Use http.Server directly — never r.Run() in production:
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("server forced to shutdown: %v", err)
}
Rules:
- Always set server timeouts — never use zero-value timeouts in production
- Shutdown grace period matches your longest expected request — if file uploads take 30s, use 30s
- Health endpoints use the request context — readiness checks with hung dependencies get caught by the timeout
Handler Testing
Test with httptest.NewRecorder and mocked services. Include ErrorMiddleware and a setupTestRouter helper in every handler test file:
func TestUserHandler_GetByID(t *testing.T) {
tests := []struct {
name string
id string
setupMock func(*MockUserService)
wantStatus int
wantBody string
}{
{
name: "returns user when found",
id: "usr-123",
setupMock: func(m *MockUserService) {
m.EXPECT().GetByID(mock.Anything, "usr-123").
Return(&models.User{ID: "usr-123", Email: "test@example.com"}, nil)
},
wantStatus: http.StatusOK,
wantBody: `"id":"usr-123"`,
},
{
name: "returns 404 when not found",
id: "usr-999",
setupMock: func(m *MockUserService) {
m.EXPECT().GetByID(mock.Anything, "usr-999").
Return(nil, errorx.New(errorx.ErrNotFound, "USER_NOT_FOUND", "not found"))
},
wantStatus: http.StatusNotFound,
wantBody: `"code":"USER_NOT_FOUND"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSvc := NewMockUserService(t)
tt.setupMock(mockSvc)
h := NewUserHandler(mockSvc)
r := setupTestRouter(h)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/users/"+tt.id, nil)
r.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
assert.Contains(t, w.Body.String(), tt.wantBody)
})
}
}
func setupTestRouter(h *UserHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(ErrorMiddleware())
h.RegisterRoutes(r.Group(""))
return r
}
Testing Rules
- Always include
ErrorMiddleware() in test routers — handler error tests won't produce correct responses without it
- Set
gin.SetMode(gin.TestMode) — suppresses Gin debug output in test logs
- Mock the service interface, not the handler — handlers are thin enough that mocking the service covers all paths
- Test the full HTTP cycle — request → handler → middleware → response. Don't call handler methods directly.
- Use
httptest.NewRequest, not http.NewRequest — simpler, no error return
- Assert on status code AND response body — a 200 with wrong data is still a bug