Golang i Gin: jak zbudować API z middleware, walidacją i logowaniem

0
94
1/5 - (1 vote)

Nawigacja:

Dlaczego Gin i Golang do budowy API produkcyjnego

Atuty Golanga w budowie szybkich API HTTP

Golang powstał z myślą o serwerach i systemach rozproszonych. Dla API oznacza to kilka konkretnych korzyści. Po pierwsze, wydajność: wbudowana współbieżność (goroutines, kanały) i niskie zużycie pamięci pozwalają serwerowi obsłużyć duży ruch bez skomplikowanej konfiguracji. Po drugie, statyczne typowanie i kompilacja do jednego binarnego pliku redukują klasę błędów, które w dynamicznych językach wychodzą dopiero na produkcji.

Kod w Go jest krótki, ale jednocześnie czytelny. Prosty model błędów (jawne zwracanie error) ułatwia budowanie kontrolowanego przepływu: każdy błąd można przechwycić, opakować i zmapować na odpowiedź HTTP. Dodatkowo standardowa biblioteka net/http jest stabilna i dobrze zaprojektowana, a frameworki takie jak Gin jedynie ją rozszerzają, nie próbując jej zastąpić własnym „magicznie działającym” serwerem.

Dla zespołów ważna jest też łatwa dystrybucja. Jedna binarka, zero zależności środowiskowych. To ułatwia konteneryzację (Docker), wdrożenia na serwery bare-metal czy na PaaS typu Heroku/Render. W kontekście mikroserwisów takie API w Go często startuje w milisekundach i zużywa niewielką ilość RAM, co bezpośrednio przekłada się na koszty chmury.

Dlaczego właśnie Gin Gonic

Gin to lekki framework HTTP, który opiera się na standardowym net/http, ale dodaje kilka elementów, które są krytyczne przy budowie API:

  • Router oparty na drzewie trie – szybkie dopasowanie ścieżek i parametrów, nawet przy dużej liczbie endpointów.
  • Middleware – prosty model filtrów przed i po handlerze, w którym można dodać logowanie, autoryzację, metricsy, rate limiting.
  • Binding i walidacja – automatyczne parsowanie JSON, query, formularzy do struktur Go, plus integracja z validator.v10.
  • Contextgin.Context łączy w sobie request, response, przechowywanie danych i funkcje pomocnicze.

Dzięki temu powstaje ergonomiczny, lecz wciąż przewidywalny zestaw narzędzi. Nie ma tu „magii” znanej z ciężkich frameworków. Gin nie narzuca architektury, a jedynie wspiera typowe zadania API: routing, serializacja, walidacja, middleware.

Istotna jest też dojrzałość ekosystemu. Gin jest jednym z najpopularniejszych frameworków HTTP w Go, ma stabilne API i sporo gotowych rozszerzeń (middleware do JWT, CORS, metrics, integracje z loggerami). Dla osób startujących z Go oznacza to dużo przykładów i odpowiedzi na Stack Overflow.

Gin vs net/http i inne frameworki (Echo, Fiber, chi)

Do budowy API można zostać przy czystym net/http. W prostych projektach to dobra opcja, bo router można zbudować ręcznie lub użyć małej biblioteki. Jednak przy większym API bez wsparcia dla middleware, bindingu i grup tras kod zaczyna się dublować, a obsługa błędów i logowanie są rozrzucone po całym projekcie.

W porównaniu do innych frameworków:

FrameworkGłówne cechyKiedy wybrać
GinSzybki router, wygodny context, binding, middlewareWiększość API REST, mikroserwisy, standardowe potrzeby
EchoPodobny do Gin, nieco inne API, wbudowany rendererGdy lubisz styl Echo lub używasz gotowych pluginów
FiberInspirowany Express.js, oparty na fasthttpKiedy najważniejsza jest maksymalna wydajność I/O
chiBardzo lekki router, mocny nacisk na kompozycję middlewareDla purystów net/http i małych, modułowych serwisów

Gin ma sens, gdy potrzebne jest praktyczne API produkcyjne: REST/JSON, typowe wzorce (JWT, rate limiting, CORS), spójna walidacja i logowanie. Nie przesadza z funkcjami, ale daje wszystko, co potrzebne w 80–90% serwisów.

Typy API, w których Gin sprawdza się najlepiej

Gin świetnie nadaje się do:

  • API REST/JSON – CRUD, zasoby, paginacja, filtrowanie, autoryzacja.
  • Mikroserwisów – małe binarki, prosta konfiguracja, łatwy deployment w Kubernetes.
  • Gatewayów – routery kierujące ruch do innych usług, z middleware do autoryzacji i logowania.
  • API backstage/administracyjnych – gdzie liczy się wygoda i jasna architektura.

Gin można też użyć jako warstwę HTTP nad gRPC (np. w service mesh), ale jego mocną stroną są przede wszystkim klasyczne API HTTP 1.1/2 z JSON-em i tokenami.

Przygotowanie środowiska i struktura projektu pod API

Narzędzia wymagane do startu

Do zbudowania API z Gin potrzebne są:

  • Go w wersji co najmniej 1.18 lub nowszej (dla generyków i aktualnych bibliotek).
  • go mod – wbudowany menedżer zależności (od Go 1.11+), zastępujący GOPATH w klasycznym znaczeniu.
  • Dowolny edytor z obsługą Go (GoLand, VS Code z rozszerzeniem Go, Vim/Neovim z pluginem).
  • Git do wersjonowania kodu i CI/CD (GitHub Actions, GitLab CI, itp.).

GOROOT najczęściej ustawia instalator Go i nie ma potrzeby go ruszać. GOPATH ma wciąż znaczenie, ale przy pracy z modułami większość projektów działa poza GOPATH, a zależności trzymane są w katalogu modułu. Z perspektywy API: kluczowe jest używanie go mod init i konsekwentne korzystanie z modułów.

Inicjalizacja modułu i dodanie Gin jako zależności

Podstawowy start projektu wygląda następująco:

mkdir myapi
cd myapi
go mod init github.com/yourname/myapi
go get github.com/gin-gonic/gin

Plik go.mod będzie przechowywał wersje zależności. Przy projektach produkcyjnych warto:

  • utrzymywać stabilną wersję Gin (np. v1.x),
  • regularnie wykonywać go get -u=patch dla aktualizacji poprawek,
  • korzystać z go mod tidy do czyszczenia nieużywanych paczek.

Jeśli repozytorium będzie prywatne, adres modułu w go.mod powinien odpowiadać docelowemu URL (np. GitHub/GitLab), co upraszcza importy między serwisami.

Struktura katalogów dla przejrzystego API

Przy większych API warto od razu rozdzielić kod na warstwy. Przykładowy układ:

myapi/
  cmd/
    api/
      main.go
  internal/
    http/
      handler/
      middleware/
      router.go
    domain/
      user/
        service.go
        model.go
        errors.go
      auth/
        service.go
    repository/
      userrepo/
        postgres.go
        memory.go
    config/
      config.go
    logging/
      logger.go
  pkg/
    // ewentualne pomocnicze biblioteki wielokrotnego użytku

Kluczowy podział:

  • cmd/api – punkt wejścia serwera HTTP, konfiguracja, uruchomienie routera.
  • internal/http – warstwa transportu HTTP: router, handlery, middleware.
  • internal/domain – logika biznesowa i modele domenowe.
  • internal/repository – warstwa dostępu do danych: bazy, cache, zewnętrzne API.

Taka struktura pozwala później podmienić np. Postgresa na inny storage bez zmiany handlerów HTTP, bo te zależą od interfejsów serwisów, a nie bezpośrednio od repozytoriów.

Warstwy: handler, service, repository

Praktyczny podział kodu można streścić tak:

  • Handler (HTTP) – przyjmuje request (Gin), binduje dane, woła serwisy, mapuje wyniki i błędy na JSON + HTTP status.
  • Service (domena) – implementuje logikę biznesową; operuje na modelach domeny, korzysta z repozytoriów i innych serwisów.
  • Repository – abstrakcja nad bazą danych/zewnętrznym API; dostarcza metody typu FindUserByID, CreateOrder.

Handler nie powinien znać szczegółów SQL, a repozytorium nie powinno formować odpowiedzi HTTP. Taki podział ułatwia testowanie (testy serwisów bez HTTP, testy handlerów z mockami serwisów) i rozwój (np. dodawanie nowych endpointów bez naruszania logiki domenowej).

Podstawowa konfiguracja Gin: router, grupy, konfiguracja środowiskowa

Tworzenie instancji routera: gin.Default vs gin.New

Gin udostępnia dwa główne sposoby tworzenia routera:

r := gin.Default() // logger + recovery z pudełka

// lub
r := gin.New()
r.Use(gin.Logger())
r.Use(gin.Recovery())

gin.Default() tworzy router z dwoma globalnymi middleware: logowaniem requestów oraz odzyskiwaniem po panikach (zwraca HTTP 500). To wygodne na start, ale przy produkcyjnym logowaniu często potrzebna jest własna konfiguracja loggera, więc lepiej budować router krok po kroku z gin.New().

W aplikacji produkcyjnej zwykle:

  • tworzysz router przez gin.New(),
  • dodajesz własny middleware logowania (np. z zap/logrus),
  • konfigurujesz gin.RecoveryWithWriter do logowania panic do loggera strukturalnego.

Rejestracja prostych endpointów i parametrów trasy

Prosty przykład endpointu w Gin:

r := gin.New()
r.GET("/health", func(c *gin.Context) {
    c.JSON(200, gin.H{"status": "ok"})
})

r.POST("/users", createUserHandler)

Parametry ścieżki i query:

r.GET("/users/:id", func(c *gin.Context) {
    id := c.Param("id")           // z segmentu path
    page := c.Query("page")      // z query string
    // ...
})

c.Param i c.Query są wygodne, ale dla większych requestów lepiej korzystać z bindingu do struktur, żeby zyskać typowanie i walidację. Do tego wrócimy w dalszej części, przy walidacji danych.

Grupy tras dla wersjonowania i segmentacji

Router groups pozwalają pogrupować endpointy logicznie: wersje API, obszary (auth, admin), różne zestawy middleware.

api := r.Group("/api")

v1 := api.Group("/v1")
{
    users := v1.Group("/users")
    {
        users.GET("", listUsersHandler)
        users.POST("", createUserHandler)
        users.GET("/:id", getUserHandler)
    }
}

admin := api.Group("/admin")
admin.Use(AdminAuthMiddleware())
{
    admin.GET("/stats", statsHandler)
}

Takie podejście porządkuje kod i pozwala na poziomie grupy przypiąć middleware, które nie ma sensu dla całego API (np. dodatkowe logowanie dla endpointów administracyjnych czy inny mechanizm autoryzacji).

Konfiguracja środowiskowa i integracja z Gin

Port, tryb debug/production, klucze JWT – to nie powinny być wartości na sztywno. Typowa praktyka:

  • czytanie konfiguracji z zmiennych środowiskowych (os.Getenv),
  • opcjonalnie z pliku YAML/JSON (np. viper, envconfig),
  • przekazywanie konfiguracji do modułów (HTTP, logowanie, baza).

Przykład prostej konfiguracji:

type Config struct {
    Port        string
    GinMode     string
    JWTSecret   string
}

func LoadConfig() Config {
    return Config{
        Port:    getenv("PORT", "8080"),
        GinMode: getenv("GIN_MODE", "release"),
        JWTSecret: os.Getenv("JWT_SECRET"),
    }
}

func getenv(key, def string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return def
}

Integracja z Gin:

cfg := LoadConfig()
gin.SetMode(cfg.GinMode)

r := gin.New()
// ...
r.Run(":" + cfg.Port)

Dzięki temu API można uruchomić w trybie debug lokalnie (lepsze logi, stacktrace), a na produkcji w release, gdzie Gin ogranicza niektóre dodatkowe funkcje i skupia się na wydajności.

Przepływ żądania w Gin i praca z context.Context

Lifecycle żądania: od routera do odpowiedzi

Przepływ requestu w Gin można streścić tak:

  1. HTTP request trafia do serwera (net/http + Gin router).
  2. Router dopasowuje ścieżkę i metodę do odpowiedniej sekwencji middleware + handlera.
  3. Middleware wykonują się w kolejności rejestracji, wywołując c.Next(), aby przejść dalej.
  4. Handler obsługuje logikę endpointu, korzystając z *gin.Context.
  5. Powiązanie gin.Context z context.Context

    *gin.Context opakowuje context.Context z pakietu standardowego. To ważne przy integracji z bazą danych, klientami HTTP czy kolejkami, które oczekują context.Context.

    Podstawowe wyciągnięcie context.Context z Gin:

    func getUserHandler(c *gin.Context) {
        ctx := c.Request.Context()
    
        user, err := userService.GetUser(ctx, c.Param("id"))
        if err != nil {
            // mapowanie błędów, o tym później
            return
        }
    
        c.JSON(http.StatusOK, user)
    }
    

    Dodawanie danych do kontekstu na poziomie middleware:

    func RequestIDMiddleware() gin.HandlerFunc {
        return func(c *gin.Context) {
            id := uuid.NewString()
    
            // umieszczamy request ID w standardowym context.Context
            ctx := context.WithValue(c.Request.Context(), "request_id", id)
            c.Request = c.Request.WithContext(ctx)
    
            // dodatkowo w Gin context, dla wygody
            c.Set("request_id", id)
    
            c.Next()
        }
    }
    

    Taka kombinacja pozwala przekazać request_id zarówno do serwisów (przez context.Context), jak i wykorzystać go w handlerach czy loggerze HTTP (przez c.Get).

    Anulowanie żądania i timeouts

    Przy dłuższych operacjach (zapytania do wolnej bazy, zewnętrzne API) trzeba umieć przerwać pracę, gdy klient się rozłączy albo przekroczymy limit czasu. Standardowy kontekst rozwiązuje ten problem.

    Przykład obsługi timeoutu w handlerze:

    func longOperationHandler(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
        defer cancel()
    
        resultCh := make(chan string, 1)
    
        go func() {
            // ciężka operacja
            time.Sleep(3 * time.Second)
            resultCh <- "done"
        }()
    
        select {
        case <-ctx.Done():
            // kontekst anulowany (timeout lub przerwanie połączenia)
            c.JSON(http.StatusGatewayTimeout, gin.H{"error": "request timed out"})
            return
        case res := <-resultCh:
            c.JSON(http.StatusOK, gin.H{"result": res})
        }
    }
    

    W praktyce logikę timeoutów lepiej przenieść do serwisów lub middleware, ale schemat pozostaje ten sam: wszystko bazuje na context.Context z requestu.

    Przekazywanie danych przez kontekst w warstwach

    W *gin.Context można przechowywać dowolne dane (np. użytkownika po autoryzacji), ale niższe warstwy powinny dostawać już czysty context.Context. Przykładowy przepływ:

  1. Middleware JWT pobiera użytkownika i zapisuje go w c.Set("user") oraz w context.Context.
  2. Handler pobiera ctx := c.Request.Context() i przekazuje do serwisu.
  3. Serwis wyciąga dane (np. ID użytkownika) z ctx i przekazuje dalej do repozytorium lub loggera.
// klucze do contextu jako własny typ, żeby uniknąć kolizji
type ctxKey string

const ctxUserKey ctxKey = "user"

type User struct {
    ID   string
    Role string
}

func WithUser(ctx context.Context, u *User) context.Context {
    return context.WithValue(ctx, ctxUserKey, u)
}

func UserFromContext(ctx context.Context) (*User, bool) {
    u, ok := ctx.Value(ctxUserKey).(*User)
    return u, ok
}

Middleware:

func AuthMiddleware(authService AuthService) gin.HandlerFunc {
    return func(c *gin.Context) {
        token := extractToken(c.Request)
        user, err := authService.ValidateToken(c.Request.Context(), token)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
            return
        }

        ctx := WithUser(c.Request.Context(), user)
        c.Request = c.Request.WithContext(ctx)
        c.Set("user", user)

        c.Next()
    }
}
Zbliżenie ekranu z kodem HTML, CSS i JavaScript w edytorze
Źródło: Pexels | Autor: Саша Алалыкин

Middleware w praktyce: projektowanie własnych filtrów i hooków

Cykl życia middleware i kolejność wykonania

Middleware w Gin to funkcje gin.HandlerFunc, które owijają handler. Wykonywane są w kolejności, w jakiej zostały zarejestrowane, a c.Next() przekazuje sterowanie dalej. Po zakończeniu handlera wykonuje się część „po Next„.

func ExampleMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()

        // kod przed handlerem
        c.Next()

        // kod po handlerze
        latency := time.Since(start)
        log.Printf("request %s took %s", c.FullPath(), latency)
    }
}

Kolejność ma znaczenie. Na poziomie globalnym:

r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(LoggingMiddleware())
r.Use(RecoveryMiddleware())

RequestIDMiddleware uruchomi się jako pierwsze. Jeśli w loggerze korzystasz z ID żądania, ma to kluczowe znaczenie.

Globalne middleware vs middleware na grupach i trasach

Nie każde zachowanie musi być globalne. Często wygodniej powiesić middleware tylko na wybranej grupie ścieżek.

api := r.Group("/api")
api.Use(RequestIDMiddleware())

public := api.Group("/public")
// brak auth middleware

private := api.Group("/private")
private.Use(AuthMiddleware(authService))

private.GET("/profile", profileHandler)

Trasy pod /public nie mają autoryzacji, ale wciąż dostają ID żądania. Trasy pod /private dostają cały łańcuch: ID żądania, logowanie, autoryzację.

Middleware do metryk i pomiaru czasu

Prosty middleware do zbierania czasu odpowiedzi i statusów HTTP można połączyć z Prometheusem lub innym systemem metryk. Minimalny przykład logujący dane:

func MetricsMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()

        c.Next()

        duration := time.Since(start)
        status := c.Writer.Status()
        path := c.FullPath() // szablon ścieżki, np. /users/:id

        log.Printf("status=%d path=%s duration=%s", status, path, duration)
    }
}

Dla integracji z Prometheusem zamieniasz log.Printf na inkrementację liczników i histogramów w odpowiednich bucketach.

Middleware do modyfikacji odpowiedzi

Czasem trzeba dołożyć nagłówki do każdej odpowiedzi (np. nagłówki bezpieczeństwa, CORS). Wtedy middleware działa tylko „po” handlerze lub zamiast niego.

func SecurityHeadersMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Writer.Header().Set("X-Content-Type-Options", "nosniff")
        c.Writer.Header().Set("X-Frame-Options", "DENY")
        c.Writer.Header().Set("X-XSS-Protection", "1; mode=block")

        c.Next()
    }
}

Dla CORS zazwyczaj używa się gotowych rozwiązań (np. github.com/gin-contrib/cors), ale zasada jest identyczna.

Middleware do limitowania ruchu (rate limiting)

Prosty limiter w pamięci wystarczy dla mniejszych API. Dla większych – Redis albo dedykowana bramka API. Schemat z token bucketem w middleware:

type Limiter struct {
    tokens chan struct{}
}

func NewLimiter(rate int) *Limiter {
    l := &Limiter{
        tokens: make(chan struct{}, rate),
    }

    // wypełniamy bucket
    for i := 0; i < rate; i++ {
        l.tokens <- struct{}{}
    }

    go func() {
        ticker := time.NewTicker(time.Second)
        defer ticker.Stop()

        for range ticker.C {
            select {
            case l.tokens <- struct{}{}:
            default:
                // bucket pełny
            }
        }
    }()

    return l
}

func (l *Limiter) Middleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        select {
        case <-l.tokens:
            c.Next()
        default:
            c.AbortWithStatusJSON(http.StatusTooManyRequests,
                gin.H{"error": "too many requests"})
        }
    }
}

Rejestracja:

limiter := NewLimiter(100) // ~100 requestów na sekundę na instancję
r.Use(limiter.Middleware())

Walidacja danych wejściowych: binding, struktury i komunikaty błędów

Binding JSON, form i parametrów ścieżki

Gin potrafi zbindować dane z różnych źródeł do struktur Go, używając tagów. Przykład prostego requestu do tworzenia użytkownika:

type CreateUserRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=8"`
    Name     string `json:"name" binding:"required"`
}

func createUserHandler(c *gin.Context) {
    var req CreateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        handleBindingError(c, err)
        return
    }

    // wywołanie serwisu
}

Form-data i query param:

type ListUsersQuery struct {
    Page    int    `form:"page,default=1" binding:"gte=1"`
    PerPage int    `form:"per_page,default=20" binding:"gte=1,lte=100"`
    Search  string `form:"search"`
}

func listUsersHandler(c *gin.Context) {
    var q ListUsersQuery
    if err := c.ShouldBindQuery(&q); err != nil {
        handleBindingError(c, err)
        return
    }

    // ...
}

Walidator pod spodem: go-playground/validator

Gin korzysta z github.com/go-playground/validator/v10. Dzięki temu można używać gotowych tagów (required, email, min, max, len, oneof) i rejestrować własne reguły.

type RegisterRequest struct {
    Username string `json:"username" binding:"required,alphanum,min=3,max=20"`
    Age      int    `json:"age" binding:"gte=13"`
}

Dodanie własnego walidatora, na przykład na silne hasło:

var validate *validator.Validate

func initValidator() {
    if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
        validate = v
        _ = validate.RegisterValidation("strong_password", strongPassword)
    }
}

func strongPassword(fl validator.FieldLevel) bool {
    pass := fl.Field().String()
    if len(pass) < 8 {
        return false
    }
    hasDigit := regexp.MustCompile(`[0-9]`).MatchString(pass)
    hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(pass)
    hasSpecial := regexp.MustCompile(`[^a-zA-Z0-9]`).MatchString(pass)
    return hasDigit && hasUpper && hasSpecial
}

type SecureRegisterRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,strong_password"`
}

Mapowanie błędów walidacji na czytelne odpowiedzi

Surowe błędy walidatora są mało przyjazne dla klienta. Lepiej zmapować je na listę pól z kodami błędów.

type ValidationErrorResponse struct {
    Errors []FieldError `json:"errors"`
}

type FieldError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

func handleBindingError(c *gin.Context, err error) {
    var ve validator.ValidationErrors
    if errors.As(err, &ve) {
        out := make([]FieldError, len(ve))
        for i, fe := range ve {
            out[i] = FieldError{
                Field:   fe.Field(),
                Message: buildValidationMessage(fe),
            }
        }
        c.JSON(http.StatusBadRequest, ValidationErrorResponse{Errors: out})
        return
    }

    // inne błędy (np. niepoprawny JSON)
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}

func buildValidationMessage(fe validator.FieldError) string {
    switch fe.Tag() {
    case "required":
        return "pole jest wymagane"
    case "email":
        return "nieprawidłowy adres e-mail"
    case "min":
        return fmt.Sprintf("minimalna wartość to %s", fe.Param())
    case "max":
        return fmt.Sprintf("maksymalna wartość to %s", fe.Param())
    default:
        return "nieprawidłowa wartość"
    }
}

Walidacja między polami i walidacja biznesowa

Walidator sprawdza strukturę, ale wiele zasad dotyczy relacji między polami (password == confirmPassword) lub logiki domenowej (czy e-mail jest unikalny). To już należy do serwisu, nie do bindingu.

type ChangePasswordRequest struct {
    OldPassword string `json:"old_password" binding:"required"`
    NewPassword string `json:"new_password" binding:"required,strong_password"`
    Confirm     string `json:"confirm" binding:"required"`
}

func changePasswordHandler(c *gin.Context) {
    var req ChangePasswordRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        handleBindingError(c, err)
        return
    }

    if req.NewPassword != req.Confirm {
        c.JSON(http.StatusBadRequest, gin.H{
            "errors": []FieldError{{
                Field:   "confirm",
                Message: "hasła nie są zgodne",
            }},
        })
        return
    }

    // logika serwisu zmiany hasła
}

Struktury wejściowe vs modele domenowe

Struktury używane w handlerach powinny być oddzielone od modeli domenowych. Binding i walidacja działają na DTO (Data Transfer Object), a serwis dostaje już model zmapowany z DTO. Unika się wtedy przecieków szczegółów transportu HTTP do domeny.

type CreateUserRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=8"`
    Name     string `json:"name" binding:"required"`
}

type User struct {
    ID       string
    Email    string
    Name     string
    // hash hasła, brak surowego hasła
}

func createUserHandler(c *gin.Context) {
    var req CreateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        handleBindingError(c, err)
        return
    }

    ctx := c.Request.Context()
    u, err := userService.CreateUser(ctx, user.CreateParams{
        Email:    req.Email,
        Password: req.Password,
        Name:     req.Name,
    })
    if err != nil {
        handleServiceError(c, err)
        return
    }

    c.JSON(http.StatusCreated, mapUserToResponse(u))
}

Obsługa błędów i spójne odpowiedzi API

Konwencja odpowiedzi: sukces vs błąd

Dobrze zdefiniowany schemat odpowiedzi oszczędza czas klientom API i programistom backendu. Najprostszy standard:

Standardowy format odpowiedzi JSON

Prosty, spójny format odpowiedzi pomaga logować, debuggować i integrować się z klientami. Przykładowa konwencja:

type Envelope struct {
    Data   any          `json:"data,omitempty"`
    Error  *ErrorBody   `json:"error,omitempty"`
    Meta   *Meta        `json:"meta,omitempty"`
}

type ErrorBody struct {
    Code    string            `json:"code"`
    Message string            `json:"message"`
    Details map[string]any    `json:"details,omitempty"`
}

type Meta struct {
    RequestID string `json:"request_id,omitempty"`
}

func respondOK(c *gin.Context, status int, data any) {
    reqID, _ := c.Get("request_id")
    c.JSON(status, Envelope{
        Data: data,
        Meta: &Meta{RequestID: fmt.Sprint(reqID)},
    })
}

func respondError(c *gin.Context, status int, code, message string, details map[string]any) {
    reqID, _ := c.Get("request_id")
    c.JSON(status, Envelope{
        Error: &ErrorBody{
            Code:    code,
            Message: message,
            Details: details,
        },
        Meta: &Meta{RequestID: fmt.Sprint(reqID)},
    })
}

Handler użytkownika zwraca już tylko respondOK / respondError:

func getUserHandler(c *gin.Context) {
    id := c.Param("id")

    ctx := c.Request.Context()
    u, err := userService.GetByID(ctx, id)
    if err != nil {
        handleServiceError(c, err)
        return
    }

    respondOK(c, http.StatusOK, mapUserToResponse(u))
}

Mapowanie błędów domenowych na HTTP

Lepiej nie mieszać statusów HTTP bezpośrednio w serwisach. Serwis zwraca błędy domenowe, a warstwa HTTP mapuje je na response.

var (
    ErrNotFound       = errors.New("not_found")
    ErrAlreadyExists  = errors.New("already_exists")
    ErrUnauthorized   = errors.New("unauthorized")
    ErrForbidden      = errors.New("forbidden")
    ErrInvalidState   = errors.New("invalid_state")
)

type DomainError struct {
    Code    string
    Message string
    Err     error
}

func (e *DomainError) Error() string {
    return e.Err.Error()
}

func WrapDomainError(code, message string, err error) *DomainError {
    return &DomainError{
        Code:    code,
        Message: message,
        Err:     err,
    }
}

Implementacja handlera błędów HTTP:

func handleServiceError(c *gin.Context, err error) {
    var de *DomainError
    if errors.As(err, &de) {
        status := mapDomainCodeToStatus(de.Code)
        respondError(c, status, de.Code, de.Message, nil)
        return
    }

    // błąd nieznany
    respondError(c, http.StatusInternalServerError,
        "internal_error", "wystąpił błąd serwera", nil)
}

func mapDomainCodeToStatus(code string) int {
    switch code {
    case "not_found":
        return http.StatusNotFound
    case "already_exists":
        return http.StatusConflict
    case "unauthorized":
        return http.StatusUnauthorized
    case "forbidden":
        return http.StatusForbidden
    case "invalid_state", "validation_error":
        return http.StatusBadRequest
    default:
        return http.StatusInternalServerError
    }
}

Middleware do centralnego przechwytywania panic i błędów

Bezpieczniej złapać panikę na jednym poziomie i zwrócić 500 z request ID. Przykład prostego recover middleware (lepszy od domyślnego wbudowanego, bo używa naszego loggera i formatu błędów):

func Recovery(logger *zap.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if r := recover(); r != nil {
                logger.Error("panic recovered",
                    zap.Any("panic", r),
                    zap.Stack("stack"),
                    zap.String("path", c.Request.URL.Path),
                )

                respondError(c, http.StatusInternalServerError,
                    "internal_error", "wystąpił błąd serwera", nil)
                c.Abort()
            }
        }()

        c.Next()
    }
}

Spójne błędy walidacji w tym samym formacie

Poprzednio błędy walidacji były zwracane bez Envelope. Lepiej spiąć to razem:

func handleBindingError(c *gin.Context, err error) {
    var ve validator.ValidationErrors
    if errors.As(err, &ve) {
        out := make([]FieldError, len(ve))
        for i, fe := range ve {
            out[i] = FieldError{
                Field:   fe.Field(),
                Message: buildValidationMessage(fe),
            }
        }

        details := map[string]any{"fields": out}
        respondError(c, http.StatusBadRequest,
            "validation_error", "nieprawidłowe dane wejściowe", details)
        return
    }

    respondError(c, http.StatusBadRequest,
        "invalid_body", "nieprawidłowy format danych", map[string]any{
            "reason": err.Error(),
        })
}

Logowanie w API Gin: od prostego loggera do produkcyjnego setupu

Prosty logger dla środowiska dev

Dla lokalnego developmentu wystarczy czytelny logger na stdout. Gin ma domyślny Logger(), ale przydaje się kontrola nad formatem:

func NewDevLogger() *zap.Logger {
    cfg := zap.NewDevelopmentConfig()
    cfg.EncoderConfig.TimeKey = "ts"
    cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
    logger, _ := cfg.Build()
    return logger
}

W main:

logger := NewDevLogger()
defer logger.Sync()

r := gin.New()
r.Use(
    gin.Recovery(),
    GinZapLogger(logger),
)

Middleware logujący requesty i odpowiedzi

Zamiast używać domyślnego gin.Logger(), można zbudować middleware na zap:

func GinZapLogger(logger *zap.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()

        path := c.Request.URL.Path
        rawQuery := c.Request.URL.RawQuery
        method := c.Request.Method
        clientIP := c.ClientIP()

        c.Next()

        latency := time.Since(start)
        status := c.Writer.Status()
        size := c.Writer.Size()

        fields := []zap.Field{
            zap.String("method", method),
            zap.String("path", path),
            zap.String("query", rawQuery),
            zap.Int("status", status),
            zap.Duration("latency", latency),
            zap.String("client_ip", clientIP),
            zap.Int("size", size),
        }

        if reqID, ok := c.Get("request_id"); ok {
            fields = append(fields, zap.String("request_id", fmt.Sprint(reqID)))
        }

        if len(c.Errors) > 0 {
            for _, e := range c.Errors {
                fields = append(fields, zap.Error(e.Err))
            }
            logger.Error("request completed with errors", fields...)
        } else {
            logger.Info("request completed", fields...)
        }
    }
}

Logger produkcyjny z JSON i rotacją plików

Na produkcji logi zwykle trafiają do stdout (Kubernetes, ECS) lub do plików, które rotuje się narzędziem pokroju filebeat albo rotacją systemową. Przykład konfiguracji z rotacją pliku przy użyciu lumberjack:

import "gopkg.in/natefinch/lumberjack.v2"

func NewProdLogger() *zap.Logger {
    w := zapcore.AddSync(&lumberjack.Logger{
        Filename:   "/var/log/myapi/app.log",
        MaxSize:    100, // MB
        MaxBackups: 7,
        MaxAge:     14, // dni
        Compress:   true,
    })

    encoderCfg := zap.NewProductionEncoderConfig()
    encoderCfg.TimeKey = "ts"
    encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder

    core := zapcore.NewCore(
        zapcore.NewJSONEncoder(encoderCfg),
        w,
        zap.InfoLevel,
    )

    return zap.New(core,
        zap.AddCaller(),
        zap.AddCallerSkip(1),
    )
}

Przekazywanie loggera przez context

Logger warto mieć dostępny w serwisach, nie tylko w handlerach. Wzorzec: logger w context.Context.

type ctxKeyLogger struct{}

func CtxWithLogger(ctx context.Context, l *zap.Logger) context.Context {
    return context.WithValue(ctx, ctxKeyLogger{}, l)
}

func LoggerFromCtx(ctx context.Context) *zap.Logger {
    if l, ok := ctx.Value(ctxKeyLogger{}).(*zap.Logger); ok && l != nil {
        return l
    }
    return zap.NewNop()
}

Integracja z Gin w middleware:

func InjectLogger(logger *zap.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        l := logger

        if reqID, ok := c.Get("request_id"); ok {
            l = l.With(zap.String("request_id", fmt.Sprint(reqID)))
        }

        ctx := CtxWithLogger(c.Request.Context(), l)
        c.Request = c.Request.WithContext(ctx)

        c.Next()
    }
}

Serwis:

func (s *UserService) CreateUser(ctx context.Context, p CreateParams) (*User, error) {
    log := LoggerFromCtx(ctx)

    log.Info("creating user", zap.String("email", p.Email))

    // ...
}

Maskowanie danych wrażliwych w logach

API z użytkownikami czy płatnościami łatwo przypadkiem zaloguje hasło albo token. Można wprowadzić prosty filtr.

var sensitiveKeys = map[string]struct{}{
    "password":      {},
    "access_token":  {},
    "refresh_token": {},
    "card_number":   {},
}

func maskSensitive(key, val string) string {
    if _, ok := sensitiveKeys[strings.ToLower(key)]; ok {
        if len(val) > 4 {
            return strings.Repeat("*", len(val)-4) + val[len(val)-4:]
        }
        return "****"
    }
    return val
}

Jeśli logujesz body requestu, użyj filtra przed zapisaniem loga.

Bezpieczeństwo, autoryzacja i JWT w middleware Gin

Podstawowe zasady bezpieczeństwa w API HTTP

Checklistę dla prostego, ale w miarę odpornego API da się streścić w kilku punktach:

  • Tylko HTTPS (za Nginx/ALB/Ingress albo bezpośrednio z TLS w Go).
  • Brak danych wrażliwych w logach.
  • Nagłówki bezpieczeństwa (X-Frame-Options, X-Content-Type-Options, HSTS).
  • Ograniczony CORS – konkretne domeny frontendu, nie *.
  • Autoryzacja na endpointach modyfikujących dane.
  • Rate limiting i ew. blokady IP.

Middleware autoryzacji z Bearer token

Standardem dla API jest nagłówek Authorization: Bearer <token>. Najpierw prosty parser nagłówka:

func extractBearerToken(c *gin.Context) (string, error) {
    header := c.GetHeader("Authorization")
    if header == "" {
        return "", errors.New("missing Authorization header")
    }

    parts := strings.SplitN(header, " ", 2)
    if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
        return "", errors.New("invalid Authorization header format")
    }

    token := strings.TrimSpace(parts[1])
    if token == "" {
        return "", errors.New("empty token")
    }
    return token, nil
}

Projekt payloadu JWT

Do JWT można pakować sporo informacji, ale im mniej, tym lepiej. Typowy zestaw:

  • sub – ID użytkownika
  • exp – data wygaśnięcia
  • iat – data wystawienia
  • role albo lista ról
type Claims struct {
    UserID string   `json:"sub"`
    Roles  []string `json:"roles,omitempty"`
    jwt.RegisteredClaims
}

Generowanie i weryfikacja JWT

Dla prostoty przykład z HMAC (algorytm HS256). Do środowiska produkcyjnego klucz przechowuj w zmiennej środowiskowej lub w Secret Managerze.

type JWTService struct {
    secret []byte
    ttl    time.Duration
}

func NewJWTService(secret string, ttl time.Duration) *JWTService {
    return &JWTService{
        secret: []byte(secret),
        ttl:    ttl,
    }
}

func (s *JWTService) GenerateToken(userID string, roles []string) (string, error) {
    now := time.Now()
    claims := &Claims{
        UserID: userID,
        Roles:  roles,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(now.Add(s.ttl)),
            IssuedAt:  jwt.NewNumericDate(now),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(s.secret)
}

func (s *JWTService) ParseToken(tokenStr string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return s.secret, nil
    })
    if err != nil {
        return nil, err
    }

    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, errors.New("invalid token")
    }

    return claims, nil
}

Middleware Auth, który wstrzykuje użytkownika do contextu

Middleware sprawdza token, a wynik zapisuje w gin.Context i standardowym context.Context. Dzięki temu serwisy mogą czytać dane użytkownika.

type ctxKeyUser struct{}

type AuthUser struct {
    ID    string
    Roles []string
}

func CtxWithUser(ctx context.Context, u *AuthUser) context.Context {
    return context.WithValue(ctx, ctxKeyUser{}, u)
}

func UserFromCtx(ctx context.Context) (*AuthUser, bool) {
    u, ok := ctx.Value(ctxKeyUser{}).(*AuthUser)
    return u, ok
}

func AuthMiddleware(jwtService *JWTService) gin.HandlerFunc {
    return func(c *gin.Context) {
        token, err := extractBearerToken(c)
        if err != nil {
            respondError(c, http.StatusUnauthorized,
                "unauthorized", "brak poprawnego tokenu", nil)
            c.Abort()
            return
        }

        claims, err := jwtService.ParseToken(token)
        if err != nil {
            respondError(c, http.StatusUnauthorized,
                "unauthorized", "nieprawidłowy lub wygasły token", nil)
            c.Abort()
            return
        }

        user := &AuthUser{
            ID:    claims.UserID,
            Roles: claims.Roles,
        }

        c.Set("user", user)
        ctx := CtxWithUser(c.Request.Context(), user)
        c.Request = c.Request.WithContext(ctx)

        c.Next()
    }
}

Wymuszanie ról i uprawnień w middleware

Praktycznie każda aplikacja ma endpointy, które wymagają konkretnej roli (np. admin). Można zbudować prosty wrapper.

Najczęściej zadawane pytania (FAQ)

Czym Gin różni się od standardowego net/http w Go przy budowie API?

Gin opiera się na net/http, ale dodaje router oparty na drzewie trie, prosty model middleware, wygodny kontekst oraz automatyczne bindowanie i walidację danych wejściowych. Dzięki temu większość typowych zadań w API (routing, logowanie, autoryzacja, serializacja JSON) jest gotowa „z pudełka”, bez pisania powtarzalnego kodu.

Przy małym API net/http wystarczy, ale wraz ze wzrostem liczby endpointów rośnie koszt utrzymania ręcznie pisanego routera, logowania czy obsługi błędów. Gin porządkuje te elementy i pozwala skupić się na logice biznesowej zamiast na infrastrukturze HTTP.

Gin vs Echo, Fiber, chi – który framework HTTP w Go wybrać do API?

Gin to bezpieczny wybór dla większości klasycznych API REST/JSON. Ma szybki router, wygodny context i dobre wsparcie dla middleware oraz walidacji. Echo jest bardzo podobny, ale ma nieco inne API i gotowy renderer – sprawdzi się, jeśli zespół ma już z nim doświadczenie lub korzysta z konkretnych pluginów.

Fiber stawia na maksymalną wydajność I/O (oparty na fasthttp), więc sprawdza się tam, gdzie liczy się każdy mikrosekunda opóźnienia, często kosztem pełnej zgodności z net/http. Chi z kolei to bardzo lekki router dla osób, które chcą być jak najbliżej czystego net/http i budować własne kompozycje middleware. Jeśli potrzebne jest praktyczne, produkcyjne API bez przesadnej magii – Gin jest zwykle najbardziej uniwersalnym wyborem.

Jaką strukturę katalogów przyjąć dla większego API w Gin?

Przy większym projekcie opłaca się od razu rozdzielić warstwy: punkt wejścia, HTTP, domenę, repozytoria, konfigurację i logowanie. Przykładowy układ to: cmd/api (main i start serwera), internal/http (router, handlery, middleware), internal/domain (serwisy i modele), internal/repository (bazy, cache), plus pomocnicze pakiety w internal/config i internal/logging.

Taki podział ułatwia testy i wymianę komponentów. Można zmienić Postgresa na inny storage, nie dotykając handlerów HTTP, bo te widzą tylko interfejsy serwisów. W praktyce oznacza to mniej zależności między warstwami i łatwiejszą rozbudowę API.

Jak poprawnie podzielić kod na handler, service i repository w API Gin?

Dobrze działa prosty zestaw zasad:

  • Handler – odbiera request od Gin, binduje dane (JSON, query), wywołuje serwisy, mapuje wynik i błędy na odpowiedzi HTTP (status + JSON).
  • Service – zawiera logikę biznesową, pracuje na modelach domenowych, korzysta z repozytoriów i innych serwisów, ale nie zna HTTP.
  • Repository – obsługuje dostęp do danych (SQL, NoSQL, zewnętrzne API), implementuje metody typu FindUserByID czy CreateOrder, ale nie formuje odpowiedzi HTTP.

Dzięki temu testy serwisów uruchamiasz bez serwera HTTP, a handlery testujesz z mockami serwisów. Kod jest czytelniejszy, a błędy i walidację możesz spójnie mapować na statusy HTTP w jednym miejscu.

Jak skonfigurować router Gin: gin.Default() czy gin.New()?

gin.Default() tworzy router z domyślnym loggerem i middleware Recovery, co wystarczy na start i w prostych usługach. To najszybszy sposób, żeby odpalić API i od razu mieć podstawowe logowanie requestów oraz obsługę panic w jednym miejscu.

Jeżeli chcesz mieć pełną kontrolę nad middleware, użyj gin.New() i samodzielnie dodaj gin.Logger(), gin.Recovery() oraz własne filtry (np. trace ID, metryki, rate limiting). W większych projektach to podejście jest częstsze, bo pozwala precyzyjnie ustawić kolejność i zakres działania middleware.

Jak wygląda bindowanie i walidacja danych w Gin (JSON, query, formularze)?

Gin potrafi automatycznie sparsować dane wejściowe do struktur Go – z JSON, parametrów query albo formularzy. W handlerze wystarczy użyć metod typu ShouldBindJSON, ShouldBindQuery czy ShouldBind. Przy błędach bindowania od razu zwracasz odpowiedni status (np. 400) i komunikat.

Do walidacji Gin korzysta z validator.v10, która działa na tagach w strukturach (np. binding:"required,email"). To wygodny sposób na trzymanie reguł walidacji blisko modeli requestu. W realnym API możesz mieć jeden wspólny mechanizm, który zamienia błędy walidacji na spójny format błędów JSON.

Dlaczego Go i Gin są często wybierane do mikroserwisów i API produkcyjnych?

Go daje lekkie binarki, niskie zużycie pamięci i szybką współbieżność (goroutines, kanały). Serwisy startują w milisekundach, trzymają stabilne zużycie RAM, co wprost wpływa na koszty chmury. Jednocześnie statyczne typowanie i kompilacja do jednego pliku redukują klasę błędów ujawnianych dopiero na produkcji w językach dynamicznych.

Gin dopełnia ten obraz: ma szybki router, prosty model middleware i dojrzały ekosystem gotowych rozszerzeń (JWT, CORS, metryki, integracje z loggerami). W praktyce dla typowego API REST/JSON dostajesz większość potrzebnych klocków – routing, walidację, logowanie – bez nadmiarowej „magii” i ciężkiej architektury.