Клієнт-серверна архітектура

Лекція 12. Валідація даних, безпека та документування у Gin

Зв

🎯 Мета лекції

  • Опанувати механізм зв'язування даних (Model Binding) у Gin та автоматичну валідацію через теги структур.
  • Зрозуміти роботу з багатокомпонентними запитами (Multipart) та завантаженням файлів.
  • Навчитися впроваджувати систему автентифікації на основі JWT токенів із підтримкою ротації.
  • Освоїти автоматичну генерацію інтерактивної документації API за стандартом OpenAPI 3.0 / Swagger.

🔑 Ключові терміни

  • Model Binding: автоматичне зв'язування даних із HTTP-запиту зі структурою Go.
  • DTO (Data Transfer Object): об'єкт передачі даних, що описує структуру вхідних/вихідних даних API.
  • JWT (JSON Web Token): стандарт токенів для автентифікації, що містить підписані claims.
  • OpenAPI / Swagger: специфікація опису REST API та інтерфейс для інтерактивного тестування.

Зв'язування даних: від HTTP до структур Go

На попередній лекції ми навчилися обробляти запити та повертати відповіді через gin.Context. Але у реальних застосунках клієнти надсилають складні дані: JSON-тіла запитів, параметри запиту (query parameters), параметри маршруту (path parameters), дані форм та файли.

Зв'язування даних (Model Binding) — це процес автоматичного перетворення даних із HTTP-запиту у типізовану структуру Go. Gin надає сімейство методів Bind* та ShouldBind*, які значно спрощують парсинг та валідацію вхідних даних.

Концепція DTO та теги структур

DTO (Data Transfer Object) — це структура, яка описує формат даних, що передаються між клієнтом та сервером. У Go DTO реалізуються як звичайні структури із спеціальними тегами (struct tags), які визначають:

  1. Як поля серіалізуються/десеріалізуються (теги json, xml, form)
  2. Які правила валідації застосовуються (тег binding)
  3. Як документувати поля для Swagger (тег example)

Розглянемо типову DTO для реєстрації користувача:

package dto

// RegisterRequest описує дані для реєстрації нового користувача
type RegisterRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,min=8,max=72"`
    FullName string `json:"full_name" binding:"required,min=2,max=100"`
    Age      int    `json:"age" binding:"omitempty,gte=18,lte=120"`
}

Розбір тегів:

  • json:"email" — поле буде десеріалізовано з ключа email у JSON-тілі запиту.
  • binding:"required" — поле є обов'язковим (не може бути порожнім або відсутнім).
  • binding:"email" — значення має відповідати формату email адреси.
  • binding:"min=8,max=72" — довжина рядка має бути від 8 до 72 символів.
  • binding:"omitempty" — якщо поле не передано, валідація не застосовується (поле необов'язкове).
  • binding:"gte=18,lte=120" — числове значення має бути більше або дорівнювати 18 та менше або дорівнювати 120.
Теги валідації binding використовують бібліотеку go-playground/validator/v10, яка підтримує понад 100 вбудованих правил валідації. Повний список доступний у документації: github.com/go-playground/validator.

Методи зв'язування у Gin

Gin надає дві групи методів для зв'язування даних:

1. Група Bind* — автоматичне визначення джерела:

func (h *Handler) Register(c *gin.Context) {
    var req dto.RegisterRequest
    
    // Bind автоматично визначає Content-Type та парсить дані
    if err := c.Bind(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    // req тепер містить перевірені дані
}

Метод Bind() автоматично визначає тип вмісту за заголовком Content-Type:

  • application/json → c.BindJSON()
  • application/xml → c.BindXML()
  • application/x-www-form-urlencoded → c.BindForm()
  • multipart/form-data → c.BindMultipart()

2. Група ShouldBind* — явне вказування джерела:

Ці методи надають більший контроль та не встановлюють код статусу автоматично:

// Зв'язування JSON-тіла
if err := c.ShouldBindJSON(&req); err != nil {
    // Обробка помилки валідації
}

// Зв'язування query-параметрів (?page=1&limit=20)
if err := c.ShouldBindQuery(&pagination); err != nil {
    // Обробка помилки
}

// Зв'язування параметрів маршруту (/users/:id)

if err := c.ShouldBindUri(&uriParams); err != nil {
    // Обробка помилки
}

// Зв'язування даних форми
if err := c.ShouldBindForm(&formData); err != nil {
    // Обробка помилки
}
Коли використовувати Bind* vs ShouldBind*?
  • Використовуйте ShouldBind*, коли потрібен детальний контроль над обробкою помилок валідації.
  • Bind* автоматично встановлює статус 400 Bad Request при помилці, що зручно для швидкої розробки.
  • У промисловому коді краще використовувати ShouldBind* та формувати стандартизовані відповіді про помилки.

Приклад реального обробника з валідацією

Розглянемо повний приклад обробника реєстрації користувача із валідацією даних:

package handler

import (
    "net/http"
    "myapp/internal/dto"
    "myapp/internal/usecase"
    "github.com/gin-gonic/gin"
)

type UserHandler struct {
    userUseCase usecase.UserUseCase
}

func NewUserHandler(uc usecase.UserUseCase) *UserHandler {
    return &UserHandler{userUseCase: uc}
}

// Register обробляє реєстрацію нового користувача
// @Summary Реєстрація користувача
// @Tags Users
// @Accept json
// @Produce json
// @Param request body dto.RegisterRequest true "Дані користувача"
// @Success 201 {object} dto.UserResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/users/register [post]
func (h *UserHandler) Register(c *gin.Context) {
    var req dto.RegisterRequest
    
    // Зв'язування та валідація JSON-тіла
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, dto.ErrorResponse{
            Error: "Validation failed",
            Details: err.Error(),
        })
        return
    }
    
    // Передача валідованих даних у бізнес-логіку
    user, err := h.userUseCase.RegisterUser(c.Request.Context(), req)
    if err != nil {
        c.JSON(http.StatusInternalServerError, dto.ErrorResponse{
            Error: "Registration failed",
            Details: err.Error(),
        })
        return
    }
    
    // Успішна відповідь
    c.JSON(http.StatusCreated, dto.UserResponse{
        ID:       user.ID,
        Email:    user.Email,
        FullName: user.FullName,
        CreatedAt: user.CreatedAt,
    })
}
Зверніть увагу на коментарі над функцією, що починаються з @. Це анотації Swagger, які використовуються для автоматичної генерації документації API. Ми розглянемо їх детально у розділі про документування.

Декларативна валідація: правила та кастомні валідатори

Бібліотека go-playground/validator підтримує широкий спектр правил валідації. Розглянемо найпоширеніші категорії.

Базові правила валідації

Обов'язковість полів:

type LoginRequest struct {
    Email    string `binding:"required"`               // Поле обов'язкове
    Password string `binding:"required"`
    Remember bool   `binding:"omitempty"`              // Необов'язкове поле
}

Валідація рядків:

type ProductDTO struct {
    Name        string `binding:"required,min=3,max=100"`      // Довжина рядка
    SKU         string `binding:"required,alphanum"`           // Лише букви та цифри
    Description string `binding:"omitempty,max=500"`
    URL         string `binding:"omitempty,url"`               // Формат URL
    Email       string `binding:"required,email"`              // Формат email
}

Валідація чисел:

type PaginationQuery struct {
    Page  int `form:"page" binding:"required,min=1"`           // Мінімум 1
    Limit int `form:"limit" binding:"required,min=1,max=100"`  // Діапазон 1-100
}

type PriceDTO struct {
    Amount   float64 `json:"amount" binding:"required,gt=0"`   // Більше нуля
    Discount float64 `json:"discount" binding:"omitempty,gte=0,lte=100"` // 0-100%
}

Валідація масивів та зрізів:

type OrderDTO struct {
    Items []string `binding:"required,min=1,max=50,dive,required"` // Мін 1 елемент, макс 50
    Tags  []string `binding:"omitempty,dive,alphanum"`             // Кожен елемент alphanum
}

Директива dive вказує валідатору застосувати правила після коми до кожного елемента масиву.

Валідація вкладених структур:

type Address struct {
    Street  string `binding:"required"`
    City    string `binding:"required"`
    ZipCode string `binding:"required,len=5"`  // Рівно 5 символів
}

type UserProfile struct {
    Name    string  `binding:"required"`
    Address Address `binding:"required"`       // Валідує вкладену структуру
}

Створення власних правил валідації

Іноді вбудованих правил недостатньо для специфічних бізнес-вимог. Gin дозволяє реєструвати власні функції валідації.

Припустимо, нам потрібно валідувати, що пароль містить хоча б одну велику літеру, одну малу літеру та одну цифру:

package validator

import (
    "regexp"
    "github.com/gin-gonic/gin/binding"
    "github.com/go-playground/validator/v10"
)

// strongPassword перевіряє складність пароля
func strongPassword(fl validator.FieldLevel) bool {
    password := fl.Field().String()
    
    // Перевірка наявності великої літери
    hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
    // Перевірка наявності малої літери
    hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
    // Перевірка наявності цифри
    hasDigit := regexp.MustCompile(`[0-9]`).MatchString(password)
    
    return hasUpper && hasLower && hasDigit
}

// RegisterCustomValidators реєструє всі кастомні валідатори
func RegisterCustomValidators() {
    if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
        v.RegisterValidation("strongpwd", strongPassword)
    }
}

Тепер можемо використовувати наш кастомний валідатор у DTO:

type ChangePasswordRequest struct {
    OldPassword string `json:"old_password" binding:"required"`
    NewPassword string `json:"new_password" binding:"required,min=8,strongpwd"`
}

Реєстрація валідаторів має відбуватися один раз при ініціалізації застосунку:

func main() {
    validator.RegisterCustomValidators()
    
    r := gin.Default()
    // ... налаштування маршрутів
    r.Run(":8080")
}
Важливо: Кастомні валідатори мають бути ідемпотентними та не викликати зовнішніх сервісів чи баз даних. Для складної бізнес-валідації краще використовувати окремий шар у UseCase.

Обробка багатокомпонентних запитів та завантаження файлів

Коли клієнт надсилає файли (наприклад, аватар користувача, документи, зображення), HTTP-запит використовує кодування multipart/form-data. Gin надає зручні методи для роботи з такими запитами.

Завантаження одного файлу

Розглянемо обробник завантаження аватара користувача:

package handler

import (
    "fmt"
    "net/http"
    "path/filepath"
    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
)

type FileHandler struct {
    uploadDir string
}

func NewFileHandler(uploadDir string) *FileHandler {
    return &FileHandler{uploadDir: uploadDir}
}

// UploadAvatar обробляє завантаження файлу аватара
func (h *FileHandler) UploadAvatar(c *gin.Context) {
    // Отримання файлу із запиту (ключ "avatar" у формі)
    file, err := c.FormFile("avatar")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "No file uploaded",
        })
        return
    }
    
    // Валідація розміру файлу (максимум 5 МБ)
    const maxFileSize = 5 * 1024 * 1024 // 5 MB
    if file.Size > maxFileSize {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "File too large (max 5MB)",
        })
        return
    }
    
    // Валідація типу файлу за розширенням
    ext := filepath.Ext(file.Filename)
    allowedExts := map[string]bool{
        ".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
    }
    
    if !allowedExts[ext] {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid file type (allowed: jpg, jpeg, png, gif)",
        })
        return
    }
    
    // Генерація унікального імені файлу
    newFilename := fmt.Sprintf("%s%s", uuid.New().String(), ext)
    savePath := filepath.Join(h.uploadDir, newFilename)
    
    // Збереження файлу на диск
    if err := c.SaveUploadedFile(file, savePath); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "Failed to save file",
        })
        return
    }
    
    // Успішна відповідь
    c.JSON(http.StatusOK, gin.H{
        "message": "File uploaded successfully",
        "filename": newFilename,
        "size": file.Size,
        "url": fmt.Sprintf("/uploads/%s", newFilename),
    })
}

Ключові моменти:

  1. c.FormFile("avatar") отримує файл із поля форми з ім'ям avatar.
  2. file.Size містить розмір файлу у байтах.
  3. c.SaveUploadedFile(file, path) зберігає файл на диск.
  4. Обов'язково валідуйте розмір, тип та ім'я файлу для безпеки.

Завантаження декількох файлів

Для завантаження множини файлів використовуйте метод MultipartForm():

func (h *FileHandler) UploadMultiple(c *gin.Context) {
    // Отримання форми з файлами
    form, err := c.MultipartForm()
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    // Отримання масиву файлів (ключ "documents")
    files := form.File["documents"]
    
    if len(files) == 0 {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": "No files uploaded",
        })
        return
    }
    
    uploadedFiles := make([]string, 0, len(files))
    
    for _, file := range files {
        // Валідація та збереження кожного файлу
        filename := fmt.Sprintf("%s_%s", uuid.New().String(), file.Filename)
        savePath := filepath.Join(h.uploadDir, filename)
        
        if err := c.SaveUploadedFile(file, savePath); err != nil {
            continue // Пропускаємо файли з помилками
        }
        
        uploadedFiles = append(uploadedFiles, filename)
    }
    
    c.JSON(http.StatusOK, gin.H{
        "message": fmt.Sprintf("Uploaded %d files", len(uploadedFiles)),
        "files": uploadedFiles,
    })
}

Комбінування файлів та JSON-даних

Часто потрібно надіслати файл разом із структурованими даними (наприклад, створити профіль користувача з аватаром та JSON-даними). У цьому випадку клієнт надсилає дані як поля форми:

type CreateProfileRequest struct {
    FullName string `form:"full_name" binding:"required"`
    Bio      string `form:"bio" binding:"omitempty,max=500"`
}

func (h *UserHandler) CreateProfile(c *gin.Context) {
    // Зв'язування полів форми
    var req CreateProfileRequest
    if err := c.ShouldBind(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    // Отримання файлу
    avatar, err := c.FormFile("avatar")
    if err == nil {
        // Обробка файлу, якщо він був наданий
        // ... збереження файлу
    }
    
    // Створення профілю у базі даних
    // ... бізнес-логіка
    
    c.JSON(http.StatusCreated, gin.H{"message": "Profile created"})
}
Безпека завантаження файлів:
  1. Завжди валідуйте MIME-тип файлу за допомогою http.DetectContentType(), а не лише за розширенням.
  2. Обмежуйте розмір файлу через c.Request.Body = http.MaxBytesReader(...) або на рівні middleware.
  3. Не зберігайте файли з оригінальними іменами — використовуйте UUID або hash.
  4. Не зберігайте файли у веб-корені — зберігайте їх поза публічною директорією та роздавайте через окремий обробник.
  5. Сканування антивірусом для критичних застосунків.

Автентифікація на основі JWT токенів

JWT (JSON Web Token) — це відкритий стандарт (RFC 7519) для створення токенів доступу, що містять JSON-об'єкт із claims (твердженнями) про користувача. Токени підписані криптографічно, що дозволяє серверу перевірити їх автентичність без звернення до бази даних.

Анатомія JWT токена

JWT складається з трьох частин, розділених крапками:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  1. Header (заголовок) — алгоритм підпису та тип токена:
    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
  2. Payload (корисне навантаження) — claims про користувача:
    {
      "sub": "1234567890",
      "name": "John Doe",
      "iat": 1516239022,
      "exp": 1516242622
    }
    
  3. Signature (підпис) — криптографічний підпис перших двох частин:
    HMACSHA256(
      base64UrlEncode(header) + "." + base64UrlEncode(payload),
      secret
    )
    
JWT токени не зашифровані — вони лише підписані. Це означає, що будь-хто може прочитати вміст токена, але не може його змінити без знання секретного ключа. Тому ніколи не зберігайте чутливу інформацію (паролі, номери карток) у JWT.

Встановлення бібліотеки JWT

Використовуємо бібліотеку golang-jwt/jwt:

go get -u github.com/golang-jwt/jwt/v5

Генерація JWT токенів

Створимо сервіс для роботи з JWT:

package auth

import (
    "errors"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

// Claims описує структуру даних у JWT токені
type Claims struct {
    UserID string `json:"user_id"`
    Email  string `json:"email"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

type JWTService struct {
    secretKey     []byte
    accessExpiry  time.Duration
    refreshExpiry time.Duration
}

func NewJWTService(secret string, accessExp, refreshExp time.Duration) *JWTService {
    return &JWTService{
        secretKey:     []byte(secret),
        accessExpiry:  accessExp,
        refreshExpiry: refreshExp,
    }
}

// GenerateAccessToken створює короткостроковий токен доступу
func (s *JWTService) GenerateAccessToken(userID, email, role string) (string, error) {
    claims := Claims{
        UserID: userID,
        Email:  email,
        Role:   role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessExpiry)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            NotBefore: jwt.NewNumericDate(time.Now()),
            Issuer:    "myapp-server",
            Subject:   userID,
        },
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(s.secretKey)
}

// GenerateRefreshToken створює довгостроковий токен оновлення
func (s *JWTService) GenerateRefreshToken(userID string) (string, error) {
    claims := jwt.RegisteredClaims{
        ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.refreshExpiry)),
        IssuedAt:  jwt.NewNumericDate(time.Now()),
        Subject:   userID,
        Issuer:    "myapp-server",
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(s.secretKey)
}

// ValidateToken перевіряє та парсить JWT токен
func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        // Перевірка алгоритму підпису
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, errors.New("unexpected signing method")
        }
        return s.secretKey, nil
    })
    
    if err != nil {
        return nil, err
    }
    
    if claims, ok := token.Claims.(*Claims); ok && token.Valid {
        return claims, nil
    }
    
    return nil, errors.New("invalid token")
}

Типові терміни життя токенів:

  • Access Token: 15 хвилин – 1 година (короткострокові, передаються з кожним запитом)
  • Refresh Token: 7-30 днів (довгострокові, зберігаються на клієнті та використовуються для оновлення Access токенів)

Middleware автентифікації JWT

Створимо middleware, який перевірятиме наявність та валідність JWT у заголовку Authorization:

package middleware

import (
    "net/http"
    "strings"
    "myapp/pkg/auth"
    "github.com/gin-gonic/gin"
)

// AuthMiddleware перевіряє JWT токен у заголовку Authorization
func AuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
    return func(c *gin.Context) {
        // Отримання заголовка Authorization
        authHeader := c.GetHeader("Authorization")
        
        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{
                "error": "Authorization header missing",
            })
            c.Abort()
            return
        }
        
        // Очікується формат: "Bearer <token>"
        parts := strings.SplitN(authHeader, " ", 2)
        if len(parts) != 2 || parts[0] != "Bearer" {
            c.JSON(http.StatusUnauthorized, gin.H{
                "error": "Invalid authorization format (expected: Bearer <token>)",
            })
            c.Abort()
            return
        }
        
        tokenString := parts[1]
        
        // Валідація токена
        claims, err := jwtService.ValidateToken(tokenString)
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{
                "error": "Invalid or expired token",
                "details": err.Error(),
            })
            c.Abort()
            return
        }
        
        // Збереження даних користувача у контексті
        c.Set("user_id", claims.UserID)
        c.Set("user_email", claims.Email)
        c.Set("user_role", claims.Role)
        
        c.Next()
    }
}

// RequireRole створює middleware для перевірки ролі користувача
func RequireRole(roles ...string) gin.HandlerFunc {
    return func(c *gin.Context) {
        userRole, exists := c.Get("user_role")
        if !exists {
            c.JSON(http.StatusForbidden, gin.H{
                "error": "Access denied: role information missing",
            })
            c.Abort()
            return
        }
        
        roleStr := userRole.(string)
        
        // Перевірка, чи має користувач одну з дозволених ролей
        hasRole := false
        for _, role := range roles {
            if roleStr == role {
                hasRole = true
                break
            }
        }
        
        if !hasRole {
            c.JSON(http.StatusForbidden, gin.H{
                "error": "Access denied: insufficient permissions",
            })
            c.Abort()
            return
        }
        
        c.Next()
    }
}

Використання JWT у маршрутах

Тепер можемо захистити маршрути за допомогою нашого middleware:

func setupRoutes(r *gin.Engine, jwtService *auth.JWTService, handlers *handler.Handlers) {
    api := r.Group("/api/v1")
    
    // Публічні маршрути (без автентифікації)
    public := api.Group("/auth")
    {
        public.POST("/register", handlers.Auth.Register)
        public.POST("/login", handlers.Auth.Login)
        public.POST("/refresh", handlers.Auth.RefreshToken)
    }
    
    // Захищені маршрути (потрібна автентифікація)
    protected := api.Group("")
    protected.Use(middleware.AuthMiddleware(jwtService))
    {
        // Доступно всім автентифікованим користувачам
        protected.GET("/profile", handlers.User.GetProfile)
        protected.PUT("/profile", handlers.User.UpdateProfile)
        
        // Доступно лише адміністраторам
        admin := protected.Group("/admin")
        admin.Use(middleware.RequireRole("admin", "superadmin"))
        {
            admin.GET("/users", handlers.Admin.ListUsers)
            admin.DELETE("/users/:id", handlers.Admin.DeleteUser)
        }
    }
}

Реалізація потоку автентифікації (Login + Refresh)

Повний приклад обробника входу та оновлення токенів:

package handler

import (
    "net/http"
    "myapp/internal/dto"
    "myapp/internal/usecase"
    "myapp/pkg/auth"
    "github.com/gin-gonic/gin"
)

type AuthHandler struct {
    userUseCase usecase.UserUseCase
    jwtService  *auth.JWTService
}

func NewAuthHandler(uc usecase.UserUseCase, jwt *auth.JWTService) *AuthHandler {
    return &AuthHandler{
        userUseCase: uc,
        jwtService:  jwt,
    }
}

// Login обробляє вхід користувача
func (h *AuthHandler) Login(c *gin.Context) {
    var req dto.LoginRequest
    
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    // Перевірка облікових даних
    user, err := h.userUseCase.Authenticate(c.Request.Context(), req.Email, req.Password)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{
            "error": "Invalid credentials",
        })
        return
    }
    
    // Генерація токенів
    accessToken, err := h.jwtService.GenerateAccessToken(user.ID, user.Email, user.Role)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
        return
    }
    
    refreshToken, err := h.jwtService.GenerateRefreshToken(user.ID)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
        return
    }
    
    // Відповідь із токенами
    c.JSON(http.StatusOK, dto.AuthResponse{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        TokenType:    "Bearer",
        ExpiresIn:    900, // 15 хвилин у секундах
    })
}

// RefreshToken оновлює Access Token за допомогою Refresh Token
func (h *AuthHandler) RefreshToken(c *gin.Context) {
    var req dto.RefreshTokenRequest
    
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    // Валідація Refresh Token
    claims, err := h.jwtService.ValidateToken(req.RefreshToken)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{
            "error": "Invalid refresh token",
        })
        return
    }
    
    // Отримання актуальних даних користувача
    user, err := h.userUseCase.GetByID(c.Request.Context(), claims.Subject)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{
            "error": "User not found",
        })
        return
    }
    
    // Генерація нового Access Token
    newAccessToken, err := h.jwtService.GenerateAccessToken(user.ID, user.Email, user.Role)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
        return
    }
    
    c.JSON(http.StatusOK, dto.RefreshTokenResponse{
        AccessToken: newAccessToken,
        TokenType:   "Bearer",
        ExpiresIn:   900,
    })
}

Автоматична генерація документації Swagger / OpenAPI

OpenAPI (раніше відомий як Swagger) — це стандарт для опису REST API, який дозволяє автоматично генерувати інтерактивну документацію, клієнтські SDK та серверні заглушки (stubs).

Swagger UI — це вебінтерфейс, який читає специфікацію OpenAPI та надає інтерактивну документацію з можливістю тестування ендпоінтів прямо у браузері.

Встановлення Swag та Gin Swagger

Для автоматичної генерації документації використовуємо інструмент swag:

go install github.com/swaggo/swag/cmd/swag@latest

Базова конфігурація Swagger у main.go

Додамо загальну інформацію про API у коментарях до функції main():

package main

import (
    "myapp/docs" // Буде згенеровано командою swag init
    "myapp/internal/handler"
    "github.com/gin-gonic/gin"
    swaggerFiles "github.com/swaggo/files"
    ginSwagger "github.com/swaggo/gin-swagger"
)

// @title           MyApp REST API
// @version         1.0
// @description     Серверний API для застосунку MyApp із підтримкою автентифікації JWT
// @termsOfService  http://myapp.example.com/terms/

// @contact.name   API Support Team
// @contact.url    http://myapp.example.com/support
// @contact.email  support@myapp.example.com

// @license.name  MIT
// @license.url   https://opensource.org/licenses/MIT

// @host      localhost:8080
// @BasePath  /api/v1

// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description JWT Authorization header using the Bearer scheme. Example: "Bearer {token}"

func main() {
    r := gin.Default()
    
    // Маршрут для Swagger UI
    r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    
    // ... налаштування маршрутів
    
    r.Run(":8080")
}

Пояснення анотацій:

  • @title — назва API
  • @version — версія API
  • @description — опис застосунку
  • @host — адреса сервера
  • @BasePath — базовий префікс всіх маршрутів
  • @securityDefinitions.apikey — опис схеми автентифікації JWT

Документування ендпоінтів

Тепер додамо анотації до обробників:

package handler

import (
    "net/http"
    "myapp/internal/dto"
    "github.com/gin-gonic/gin"
)

type UserHandler struct {
    // ... залежності
}

// GetProfile отримує профіль поточного користувача
// @Summary      Отримати профіль користувача
// @Description  Повертає детальну інформацію про профіль автентифікованого користувача
// @Tags         Users
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Success      200  {object}  dto.UserProfileResponse
// @Failure      401  {object}  dto.ErrorResponse "Unauthorized"
// @Failure      500  {object}  dto.ErrorResponse "Internal Server Error"
// @Router       /users/profile [get]
func (h *UserHandler) GetProfile(c *gin.Context) {
    userID := c.GetString("user_id")
    
    profile, err := h.userUseCase.GetProfile(c.Request.Context(), userID)
    if err != nil {
        c.JSON(http.StatusInternalServerError, dto.ErrorResponse{
            Error: "Failed to fetch profile",
        })
        return
    }
    
    c.JSON(http.StatusOK, profile)
}

// UpdateProfile оновлює профіль користувача
// @Summary      Оновити профіль
// @Description  Оновлює інформацію профілю автентифікованого користувача
// @Tags         Users
// @Accept       json
// @Produce      json
// @Security     BearerAuth
// @Param        request  body      dto.UpdateProfileRequest  true  "Дані для оновлення"
// @Success      200      {object}  dto.UserProfileResponse
// @Failure      400      {object}  dto.ErrorResponse "Bad Request"
// @Failure      401      {object}  dto.ErrorResponse "Unauthorized"
// @Failure      500      {object}  dto.ErrorResponse "Internal Server Error"
// @Router       /users/profile [put]
func (h *UserHandler) UpdateProfile(c *gin.Context) {
    var req dto.UpdateProfileRequest
    
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, dto.ErrorResponse{
            Error:   "Validation failed",
            Details: err.Error(),
        })
        return
    }
    
    userID := c.GetString("user_id")
    
    profile, err := h.userUseCase.UpdateProfile(c.Request.Context(), userID, req)
    if err != nil {
        c.JSON(http.StatusInternalServerError, dto.ErrorResponse{
            Error: "Failed to update profile",
        })
        return
    }
    
    c.JSON(http.StatusOK, profile)
}

// Register реєструє нового користувача
// @Summary      Реєстрація користувача
// @Description  Створює новий обліковий запис користувача
// @Tags         Authentication
// @Accept       json
// @Produce      json
// @Param        request  body      dto.RegisterRequest  true  "Дані реєстрації"
// @Success      201      {object}  dto.AuthResponse
// @Failure      400      {object}  dto.ErrorResponse "Bad Request"
// @Failure      409      {object}  dto.ErrorResponse "Conflict - Email already exists"
// @Failure      500      {object}  dto.ErrorResponse "Internal Server Error"
// @Router       /auth/register [post]
func (h *AuthHandler) Register(c *gin.Context) {
    // ... реалізація
}

Ключові анотації Swagger:

  • @Summary — короткий опис ендпоінту (відображається у списку)
  • @Description — детальний опис функціональності
  • @Tags — категорія ендпоінту для групування у UI
  • @Accept — формат вхідних даних (json, xml, multipart/form-data)
  • @Produce — формат відповіді
  • @Security — схема автентифікації (BearerAuth з @securityDefinitions)
  • @Param — опис параметрів (path, query, body, header, form)
  • @Success — успішна відповідь (код статусу, тип об'єкта, опис)
  • @Failure — помилки (код статусу, тип об'єкта, опис)
  • @Router — шлях та HTTP-метод

Документування моделей даних (DTO)

Swagger автоматично аналізує структури Go та генерує схеми об'єктів. Для кращої документації додайте теги example:

package dto

// RegisterRequest описує дані для реєстрації користувача
type RegisterRequest struct {
    Email    string `json:"email" binding:"required,email" example:"user@example.com"`
    Password string `json:"password" binding:"required,min=8,strongpwd" example:"MyPassword123"`
    FullName string `json:"full_name" binding:"required,min=2,max=100" example:"Іван Петренко"`
    Age      int    `json:"age" binding:"omitempty,gte=18,lte=120" example:"25"`
}

// UserProfileResponse повертає інформацію про профіль користувача
type UserProfileResponse struct {
    ID        string `json:"id" example:"550e8400-e29b-41d4-a716-446655440000"`
    Email     string `json:"email" example:"user@example.com"`
    FullName  string `json:"full_name" example:"Іван Петренко"`
    Bio       string `json:"bio" example:"Розробник програмного забезпечення"`
    AvatarURL string `json:"avatar_url" example:"https://example.com/avatars/user123.jpg"`
    CreatedAt string `json:"created_at" example:"2026-01-15T10:30:00Z"`
}

// ErrorResponse стандартний формат повідомлення про помилку
type ErrorResponse struct {
    Error   string `json:"error" example:"Validation failed"`
    Details string `json:"details,omitempty" example:"field 'email' is required"`
}

// AuthResponse повертає токени після успішної автентифікації
type AuthResponse struct {
    AccessToken  string `json:"access_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
    RefreshToken string `json:"refresh_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
    TokenType    string `json:"token_type" example:"Bearer"`
    ExpiresIn    int    `json:"expires_in" example:"900"`
}

Генерація документації

Після додавання всіх анотацій, виконайте команду генерації:

swag init

Ця команда:

  1. Сканує всі файли Go у проєкті
  2. Аналізує анотації Swagger
  3. Генерує директорію docs/ із файлами docs.go, swagger.json та swagger.yaml
Команду swag init потрібно запускати кожного разу після зміни анотацій Swagger у коді. Для автоматизації можна додати її до pre-commit хуку або CI/CD pipeline.

Перегляд документації

Запустіть сервер та відкрийте у браузері:

http://localhost:8080/swagger/index.html

Ви побачите інтерактивний інтерфейс із переліком усіх ендпоінтів, згрупованих за тегами. Кожен ендпоінт можна розгорнути, переглянути параметри, моделі даних та виконати тестовий запит прямо з браузера.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Вихідний код Go" #F1F5F9 {
    [Handler з\nSwagger анотаціями] as Handler
    [DTO структури\nз example тегами] as DTO
}

package "Генерація документації" #DBEAFE {
    [swag init\nсканування коду] as Swag
    [docs/swagger.json\nOpenAPI специфікація] as Spec
}

package "Веб-інтерфейс" #DCFCE7 {
    [Swagger UI\n/swagger/index.html] as UI
    [Інтерактивне\nтестування API] as Testing
}

Handler --> Swag : аналіз анотацій
DTO --> Swag : аналіз схем
Swag --> Spec : генерація OpenAPI 3.0
Spec --> UI : рендеринг документації
UI --> Testing : виконання запитів

note right of Spec #FEF3C7
  Файл swagger.json містить
  повну специфікацію API:
  - Шляхи та методи
  - Параметри та відповіді
  - Схеми автентифікації
  - Моделі даних
end note

@enduml

Тестування ендпоінтів через Swagger UI

У Swagger UI можна:

  1. Розгорнути ендпоінт — клацнути на назву методу (GET, POST тощо)
  2. Переглянути параметри — побачити всі параметри, їх типи та приклади
  3. Натиснути "Try it out" — активувати режим редагування
  4. Ввести дані — змінити значення параметрів або JSON-тіла
  5. Додати авторизацію — натиснути кнопку "Authorize" та ввести JWT токен
  6. Виконати запит — натиснути "Execute"
  7. Переглянути відповідь — побачити статус-код, заголовки та тіло відповіді
Інтеграція Swagger з іншими інструментами:
  • Postman може імпортувати файл swagger.json та автоматично створити колекцію запитів.
  • Інструменти кодогенерації (OpenAPI Generator) можуть згенерувати клієнтські SDK для Java, C#, TypeScript, Python тощо.
  • CI/CD pipeline може автоматично перевіряти актуальність документації порівнянням змін у коді та swagger.json.

Практичні рекомендації та безпека

Валідація на різних рівнях

У промислових застосунках валідація здійснюється на кількох рівнях:

  1. Клієнтська валідація — миттєвий зворотний зв'язок для користувача (JavaScript у веб, нативна валідація у мобільних застосунках).
  2. HTTP-рівень (Gin Binding) — перевірка синтаксису, типів даних та базових обмежень через теги binding.
  3. Бізнес-рівень (UseCase) — перевірка бізнес-правил, які не можуть бути виражені простими тегами:
    func (uc *UserUseCase) RegisterUser(ctx context.Context, req dto.RegisterRequest) error {
        // Перевірка унікальності email у базі даних
        exists, err := uc.repo.EmailExists(ctx, req.Email)
        if err != nil {
            return err
        }
        if exists {
            return errors.New("email already registered")
        }
        
        // Перевірка, чи не знаходиться email у чорному списку
        if uc.isBlacklisted(req.Email) {
            return errors.New("email domain not allowed")
        }
        
        // ... інші бізнес-правила
    }
    
Принцип Defense in Depth: Кожен рівень валідації додає додатковий шар захисту. Навіть якщо зловмисник обійде клієнтську валідацію, серверна валідація зупинить некоректні дані.

Безпека JWT токенів

Обов'язкові практики:

  1. Використовуйте сильний секретний ключ:
    // ❌ НІКОЛИ НЕ ВИКОРИСТОВУЙТЕ СЛАБКІ КЛЮЧІ
    secretKey := "secret"
    
    // ✅ ГЕНЕРУЙТЕ ВИПАДКОВИЙ КЛЮЧ (мінімум 256 біт)
    secretKey := os.Getenv("JWT_SECRET") // Зберігати у змінних оточення
    
  2. Встановлюйте короткі терміни дії Access токенів:
    accessExpiry := 15 * time.Minute  // ✅ Рекомендовано
    accessExpiry := 7 * 24 * time.Hour // ❌ Надто довго
    
  3. Перевіряйте алгоритм підпису:
    func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) {
        token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
            // Захист від атаки "алгоритм None"
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
            }
            return s.secretKey, nil
        })
        // ...
    }
    
  4. Використовуйте HTTPS для передачі токенів:
    • Токени передаються у відкритому вигляді (base64, не шифрування)
    • HTTPS шифрує весь HTTP-трафік, включаючи заголовки
  5. Реалізуйте blacklist для відкликання токенів:
    // При виході користувача додайте токен у чорний список (Redis)
    func (s *AuthService) Logout(ctx context.Context, tokenID string) error {
        // Зберегти tokenID у Redis до моменту його експірації
        return s.redis.Set(ctx, "blacklist:"+tokenID, "1", s.accessExpiry).Err()
    }
    
    // Перевірка у middleware
    func AuthMiddleware(jwtService *auth.JWTService, redis *redis.Client) gin.HandlerFunc {
        return func(c *gin.Context) {
            // ... парсинг токена
            
            // Перевірка blacklist
            exists, _ := redis.Exists(c, "blacklist:"+claims.ID).Result()
            if exists > 0 {
                c.JSON(http.StatusUnauthorized, gin.H{"error": "Token revoked"})
                c.Abort()
                return
            }
            
            c.Next()
        }
    }
    

Обмеження розміру запитів

Gin за замовчуванням обмежує розмір тіла запиту до 32 МБ. Для роботи з файлами можна налаштувати ліміт:

func main() {
    r := gin.Default()
    
    // Обмеження розміру тіла запиту до 10 МБ
    r.MaxMultipartMemory = 10 << 20 // 10 MB
    
    // Для окремих маршрутів можна використати middleware
    r.POST("/upload", limitRequestSize(5<<20), handlers.UploadFile)
    
    r.Run(":8080")
}

func limitRequestSize(maxBytes int64) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
        c.Next()
    }
}

Стандартизація відповідей про помилки

Використовуйте єдиний формат помилок згідно зі стандартом RFC 7807 (Problem Details):

package dto

// ProblemDetails стандартний формат помилок RFC 7807
type ProblemDetails struct {
    Type     string                 `json:"type" example:"https://api.example.com/errors/validation-error"`
    Title    string                 `json:"title" example:"Validation Error"`
    Status   int                    `json:"status" example:"400"`
    Detail   string                 `json:"detail" example:"The 'email' field must be a valid email address"`
    Instance string                 `json:"instance" example:"/api/v1/users/register"`
    Errors   map[string]interface{} `json:"errors,omitempty"`
}

Використання у middleware обробки помилок:

func ErrorHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()
        
        // Перевірка, чи були помилки під час обробки
        if len(c.Errors) > 0 {
            err := c.Errors.Last()
            
            problem := dto.ProblemDetails{
                Type:     "https://api.example.com/errors/internal-error",
                Title:    "Internal Server Error",
                Status:   http.StatusInternalServerError,
                Detail:   err.Error(),
                Instance: c.Request.URL.Path,
            }
            
            c.JSON(problem.Status, problem)
        }
    }
}

Підсумки

На цій лекції ми розглянули ключові аспекти створення промислових REST API на фреймворку Gin, які виходять за межі базової маршрутизації:

Зв'язування та валідація даних:

  • Механізм Model Binding автоматизує парсинг даних із різних джерел (JSON, Query, URI, Form)
  • Декларативна валідація через теги binding забезпечує перевірку даних без написання рутинного коду
  • Можливість створення власних валідаторів для специфічних бізнес-правил

Робота з файлами:

  • Обробка одиночних та множинних завантажень через FormFile та MultipartForm
  • Валідація розміру, типу та імені файлів для безпеки
  • Комбінування файлів зі структурованими даними у одному запиті

Автентифікація JWT:

  • JWT токени надають безстанову (stateless) автентифікацію без звернення до бази даних на кожному запиті
  • Патерн Access/Refresh токенів балансує між безпекою та зручністю
  • Middleware автоматично перевіряє токени та забезпечує контроль доступу на основі ролей

Документація API:

  • Swagger/OpenAPI автоматизує створення актуальної документації
  • Інтерактивний інтерфейс дозволяє тестувати ендпоінти без зовнішніх інструментів
  • Анотації у коді гарантують синхронізацію документації з реалізацією
Всі механізми, розглянуті на цій лекції, є стандартними практиками індустрії та використовуються у промислових застосунках. Вони значно підвищують швидкість розробки, надійність та підтримуваність коду.

Питання для самоперевірки


Додаткові матеріали

📚 Документація бібліотек

🔐 Стандарти безпеки

Практичне завдання:Створіть REST API для системи управління завданнями (ToDo List) із такими функціями:
  1. Реєстрація/вхід користувачів із JWT автентифікацією
  2. CRUD операції для завдань (лише власні завдання користувача)
  3. Завантаження файлів-вкладень до завдань
  4. Валідація даних через теги binding
  5. Повна Swagger документація
  6. Роль "admin" може переглядати всі завдання
Цей проєкт об'єднає всі теми, розглянуті на лекції, та підготує вас до роботи з реляційними базами даних на наступній лекції.
Copyright © 2026