Проект: TODO API с авторизацией

Применим все изученное, создав REST API для управления задачами с JWT авторизацией. По ходу будем опираться на уроки про HTTP, базы данных, интерфейсы, обработку ошибок и тестирование.

Структура проекта

todo-api/
├── cmd/
│   └── api/
│       └── main.go           # Точка входа
├── internal/
│   ├── config/
│   │   └── config.go         # Конфигурация
│   ├── models/
│   │   ├── user.go           # Модель пользователя
│   │   └── todo.go           # Модель задачи
│   ├── repository/
│   │   ├── user_repo.go      # Репозиторий пользователей
│   │   └── todo_repo.go      # Репозиторий задач
│   ├── service/
│   │   ├── auth.go           # Сервис авторизации
│   │   └── todo.go           # Бизнес-логика задач
│   ├── handler/
│   │   ├── auth.go           # HTTP handlers авторизации
│   │   ├── todo.go           # HTTP handlers задач
│   │   └── middleware.go     # Middleware
│   └── database/
│       └── postgres.go       # Подключение к БД
├── migrations/
│   ├── 001_create_users.sql
│   └── 002_create_todos.sql
├── pkg/
│   ├── hash/
│   │   └── password.go       # Хеширование паролей
│   └── token/
│       └── jwt.go            # Работа с JWT
├── go.mod
├── go.sum
├── Makefile
└── README.md

Модели данных

Модели - это обычные структуры с тегами для JSON и БД.

internal/models/user.go

package models

import (
    "time"
)

type User struct {
    ID           int       `json:"id" db:"id"`
    Email        string    `json:"email" db:"email"`
    PasswordHash string    `json:"-" db:"password_hash"`
    CreatedAt    time.Time `json:"created_at" db:"created_at"`
    UpdatedAt    time.Time `json:"updated_at" db:"updated_at"`
}

type CreateUserRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=6"`
}

type LoginRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required"`
}

type AuthResponse struct {
    Token string `json:"token"`
    User  *User  `json:"user"`
}

internal/models/todo.go

package models

import (
    "time"
)

type Todo struct {
    ID          int       `json:"id" db:"id"`
    UserID      int       `json:"user_id" db:"user_id"`
    Title       string    `json:"title" db:"title"`
    Description string    `json:"description" db:"description"`
    Completed   bool      `json:"completed" db:"completed"`
    CreatedAt   time.Time `json:"created_at" db:"created_at"`
    UpdatedAt   time.Time `json:"updated_at" db:"updated_at"`
}

type CreateTodoRequest struct {
    Title       string `json:"title" validate:"required,min=1,max=200"`
    Description string `json:"description" validate:"max=1000"`
}

type UpdateTodoRequest struct {
    Title       *string `json:"title" validate:"omitempty,min=1,max=200"`
    Description *string `json:"description" validate:"omitempty,max=1000"`
    Completed   *bool   `json:"completed"`
}

База данных

migrations/001_create_users.sql

CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);

migrations/002_create_todos.sql

CREATE TABLE IF NOT EXISTS todos (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title VARCHAR(200) NOT NULL,
    description TEXT,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_todos_user_id ON todos(user_id);

internal/database/postgres.go

package database

import (
    "database/sql"
    "fmt"
    "time"

    _ "github.com/lib/pq"
)

type DB struct {
    *sql.DB
}

// New подключается к базе по строке из конфигурации.
//
// Принимаем готовый DSN, а не пять отдельных параметров: тогда невозможно
// случайно подключиться не туда, потому что подключаться просто некуда,
// кроме как по тому, что пришло из окружения. Логин и пароль в сигнатуре
// функции - это приглашение подставить их прямо в вызове и забыть.
//
// В строке подключения для локальной разработки будет `sslmode=disable`.
// Для прода это надо менять: см. чеклист в конце урока.
func New(dsn string) (*DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, fmt.Errorf("open database: %w", err)
    }

    if err := db.Ping(); err != nil {
        return nil, fmt.Errorf("ping database: %w", err)
    }

    // Настройка пула соединений
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(5 * time.Minute)

    return &DB{db}, nil
}

func (db *DB) Migrate() error {
    // Простая миграция для примера
    migrations := []string{
        `CREATE TABLE IF NOT EXISTS users (
            id SERIAL PRIMARY KEY,
            email VARCHAR(255) UNIQUE NOT NULL,
            password_hash VARCHAR(255) NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )`,
        `CREATE TABLE IF NOT EXISTS todos (
            id SERIAL PRIMARY KEY,
            user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            title VARCHAR(200) NOT NULL,
            description TEXT,
            completed BOOLEAN DEFAULT FALSE,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )`,
    }

    for _, migration := range migrations {
        if _, err := db.Exec(migration); err != nil {
            return fmt.Errorf("execute migration: %w", err)
        }
    }

    return nil
}

Репозитории

internal/repository/user_repo.go

package repository

import (
    "context"
    "database/sql"
    "fmt"

    "todo-api/internal/models"
)

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, user *models.User) error {
    query := `
        INSERT INTO users (email, password_hash)
        VALUES ($1, $2)
        RETURNING id, created_at, updated_at
    `

    err := r.db.QueryRowContext(
        ctx,
        query,
        user.Email,
        user.PasswordHash,
    ).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)

    if err != nil {
        return fmt.Errorf("create user: %w", err)
    }

    return nil
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) {
    user := &models.User{}

    query := `
        SELECT id, email, password_hash, created_at, updated_at
        FROM users
        WHERE email = $1
    `

    err := r.db.QueryRowContext(ctx, query, email).Scan(
        &user.ID,
        &user.Email,
        &user.PasswordHash,
        &user.CreatedAt,
        &user.UpdatedAt,
    )

    if err == sql.ErrNoRows {
        return nil, ErrUserNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("find user by email: %w", err)
    }

    return user, nil
}

var ErrUserNotFound = fmt.Errorf("user not found")

internal/repository/todo_repo.go

package repository

import (
    "context"
    "database/sql"
    "fmt"

    "todo-api/internal/models"
)

type TodoRepository struct {
    db *sql.DB
}

func NewTodoRepository(db *sql.DB) *TodoRepository {
    return &TodoRepository{db: db}
}

func (r *TodoRepository) Create(ctx context.Context, todo *models.Todo) error {
    query := `
        INSERT INTO todos (user_id, title, description)
        VALUES ($1, $2, $3)
        RETURNING id, created_at, updated_at
    `

    err := r.db.QueryRowContext(
        ctx,
        query,
        todo.UserID,
        todo.Title,
        todo.Description,
    ).Scan(&todo.ID, &todo.CreatedAt, &todo.UpdatedAt)

    if err != nil {
        return fmt.Errorf("create todo: %w", err)
    }

    return nil
}

func (r *TodoRepository) FindByUserID(ctx context.Context, userID int) ([]*models.Todo, error) {
    query := `
        SELECT id, user_id, title, description, completed, created_at, updated_at
        FROM todos
        WHERE user_id = $1
        ORDER BY created_at DESC
    `

    rows, err := r.db.QueryContext(ctx, query, userID)
    if err != nil {
        return nil, fmt.Errorf("find todos: %w", err)
    }
    defer rows.Close()

    var todos []*models.Todo
    for rows.Next() {
        todo := &models.Todo{}
        err := rows.Scan(
            &todo.ID,
            &todo.UserID,
            &todo.Title,
            &todo.Description,
            &todo.Completed,
            &todo.CreatedAt,
            &todo.UpdatedAt,
        )
        if err != nil {
            return nil, fmt.Errorf("scan todo: %w", err)
        }
        todos = append(todos, todo)
    }

    return todos, nil
}

func (r *TodoRepository) Update(ctx context.Context, todo *models.Todo) error {
    query := `
        UPDATE todos
        SET title = $1, description = $2, completed = $3, updated_at = CURRENT_TIMESTAMP
        WHERE id = $4 AND user_id = $5
    `

    result, err := r.db.ExecContext(
        ctx,
        query,
        todo.Title,
        todo.Description,
        todo.Completed,
        todo.ID,
        todo.UserID,
    )
    if err != nil {
        return fmt.Errorf("update todo: %w", err)
    }

    rows, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("rows affected: %w", err)
    }

    if rows == 0 {
        return ErrTodoNotFound
    }

    return nil
}

func (r *TodoRepository) Delete(ctx context.Context, id, userID int) error {
    query := `DELETE FROM todos WHERE id = $1 AND user_id = $2`

    result, err := r.db.ExecContext(ctx, query, id, userID)
    if err != nil {
        return fmt.Errorf("delete todo: %w", err)
    }

    rows, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("rows affected: %w", err)
    }

    if rows == 0 {
        return ErrTodoNotFound
    }

    return nil
}

var ErrTodoNotFound = fmt.Errorf("todo not found")

Сервисы

pkg/hash/password.go

package hash

import (
    "fmt"

    "golang.org/x/crypto/bcrypt"
)

func HashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return "", fmt.Errorf("generate password hash: %w", err)
    }
    return string(bytes), nil
}

func CheckPassword(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

pkg/token/jwt.go

package token

import (
    "fmt"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

type Claims struct {
    UserID int `json:"user_id"`
    jwt.RegisteredClaims
}

func Generate(userID int, secret string, expiry time.Duration) (string, error) {
    claims := &Claims{
        UserID: userID,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(secret))
}

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

    if err != nil {
        return nil, err
    }

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

    return claims, nil
}

internal/service/auth.go

package service

import (
    "context"
    "fmt"
    "time"

    "todo-api/internal/models"
    "todo-api/internal/repository"
    "todo-api/pkg/hash"
    "todo-api/pkg/token"
)

type AuthService struct {
    userRepo   *repository.UserRepository
    jwtSecret  string
    jwtExpiry  time.Duration
}

func NewAuthService(userRepo *repository.UserRepository, jwtSecret string) *AuthService {
    return &AuthService{
        userRepo:  userRepo,
        jwtSecret: jwtSecret,
        jwtExpiry: 24 * time.Hour,
    }
}

func (s *AuthService) Register(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error) {
    // Проверяем, существует ли пользователь
    existing, _ := s.userRepo.FindByEmail(ctx, req.Email)
    if existing != nil {
        return nil, fmt.Errorf("email already registered")
    }

    // Хешируем пароль
    passwordHash, err := hash.HashPassword(req.Password)
    if err != nil {
        return nil, fmt.Errorf("hash password: %w", err)
    }

    // Создаем пользователя
    user := &models.User{
        Email:        req.Email,
        PasswordHash: passwordHash,
    }

    if err := s.userRepo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }

    // Генерируем токен
    tokenString, err := token.Generate(user.ID, s.jwtSecret, s.jwtExpiry)
    if err != nil {
        return nil, fmt.Errorf("generate token: %w", err)
    }

    return &models.AuthResponse{
        Token: tokenString,
        User:  user,
    }, nil
}

func (s *AuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error) {
    // Находим пользователя
    user, err := s.userRepo.FindByEmail(ctx, req.Email)
    if err != nil {
        return nil, fmt.Errorf("invalid credentials")
    }

    // Проверяем пароль
    if !hash.CheckPassword(req.Password, user.PasswordHash) {
        return nil, fmt.Errorf("invalid credentials")
    }

    // Генерируем токен
    tokenString, err := token.Generate(user.ID, s.jwtSecret, s.jwtExpiry)
    if err != nil {
        return nil, fmt.Errorf("generate token: %w", err)
    }

    return &models.AuthResponse{
        Token: tokenString,
        User:  user,
    }, nil
}

HTTP Handlers

internal/handler/middleware.go

package handler

import (
    "context"
    "log"
    "net/http"
    "strings"
    "time"

    "todo-api/pkg/token"
)

type contextKey string

const userIDKey contextKey = "userID"

func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Извлекаем токен из заголовка
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                http.Error(w, "Unauthorized", http.StatusUnauthorized)
                return
            }

            // Проверяем формат: Bearer <token>
            parts := strings.Split(authHeader, " ")
            if len(parts) != 2 || parts[0] != "Bearer" {
                http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
                return
            }

            // Парсим токен
            claims, err := token.Parse(parts[1], jwtSecret)
            if err != nil {
                http.Error(w, "Invalid token", http.StatusUnauthorized)
                return
            }

            // Добавляем userID в контекст
            ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func GetUserID(ctx context.Context) int {
    userID, _ := ctx.Value(userIDKey).(int)
    return userID
}

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// CORSMiddleware разрешает запросы с любого домена.
//
// `*` годится для локальной разработки и НЕ годится для прода: с ним твой API
// может дёргать любой сайт, на который зайдёт твой пользователь. Правда, браузер
// не отправит с `*` куки и не отдаст ответ, если запрос шёл с `credentials`, -
// это единственное, что здесь спасает. Как только появится авторизация по кукам,
// придётся перейти на список конкретных доменов, и лучше сделать это заранее.
func CORSMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusOK)
            return
        }

        next.ServeHTTP(w, r)
    })
}

internal/handler/auth.go

package handler

import (
    "context"
    "encoding/json"
    "net/http"

    "todo-api/internal/models"
)

// AuthService - то, что нужно хендлеру от сервиса, и ничего больше.
//
// Интерфейс объявлен здесь, а не в пакете service, и это принципиально:
// потребитель описывает свои требования, поставщик про них не знает.
// `*service.AuthService` удовлетворяет этому интерфейсу автоматически,
// импортировать пакет service хендлеру больше не нужно.
//
// Практическая польза: в тесте вместо настоящего сервиса подставляется
// заглушка, и хендлер проверяется без базы, без bcrypt и без JWT.
// Если бы конструктор принимал конкретный `*service.AuthService`,
// подменить его было бы нечем - пришлось бы поднимать базу ради проверки
// того, что на кривой JSON возвращается 400.
type AuthService interface {
    Register(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error)
    Login(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error)
}

type AuthHandler struct {
    authService AuthService
}

func NewAuthHandler(authService AuthService) *AuthHandler {
    return &AuthHandler{authService: authService}
}

func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    var req models.CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    resp, err := h.authService.Register(r.Context(), &req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(resp)
}

func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    var req models.LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    resp, err := h.authService.Login(r.Context(), &req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusUnauthorized)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

internal/handler/todo.go

package handler

import (
    "encoding/json"
    "net/http"
    "strconv"
    "strings"

    "todo-api/internal/models"
    "todo-api/internal/repository"
)

type TodoHandler struct {
    todoRepo *repository.TodoRepository
}

func NewTodoHandler(todoRepo *repository.TodoRepository) *TodoHandler {
    return &TodoHandler{todoRepo: todoRepo}
}

func (h *TodoHandler) List(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    userID := GetUserID(r.Context())
    todos, err := h.todoRepo.FindByUserID(r.Context(), userID)
    if err != nil {
        http.Error(w, "Failed to fetch todos", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(todos)
}

func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    var req models.CreateTodoRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    todo := &models.Todo{
        UserID:      GetUserID(r.Context()),
        Title:       req.Title,
        Description: req.Description,
    }

    if err := h.todoRepo.Create(r.Context(), todo); err != nil {
        http.Error(w, "Failed to create todo", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(todo)
}

func (h *TodoHandler) Update(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPut {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    // Извлекаем ID из пути
    pathParts := strings.Split(r.URL.Path, "/")
    if len(pathParts) < 3 {
        http.Error(w, "Invalid URL", http.StatusBadRequest)
        return
    }

    todoID, err := strconv.Atoi(pathParts[len(pathParts)-1])
    if err != nil {
        http.Error(w, "Invalid todo ID", http.StatusBadRequest)
        return
    }

    var req models.UpdateTodoRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    // Обновляем todo
    todo := &models.Todo{
        ID:     todoID,
        UserID: GetUserID(r.Context()),
    }

    if req.Title != nil {
        todo.Title = *req.Title
    }
    if req.Description != nil {
        todo.Description = *req.Description
    }
    if req.Completed != nil {
        todo.Completed = *req.Completed
    }

    if err := h.todoRepo.Update(r.Context(), todo); err != nil {
        if err == repository.ErrTodoNotFound {
            http.Error(w, "Todo not found", http.StatusNotFound)
        } else {
            http.Error(w, "Failed to update todo", http.StatusInternalServerError)
        }
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

func (h *TodoHandler) Delete(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodDelete {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    // Извлекаем ID из пути
    pathParts := strings.Split(r.URL.Path, "/")
    if len(pathParts) < 3 {
        http.Error(w, "Invalid URL", http.StatusBadRequest)
        return
    }

    todoID, err := strconv.Atoi(pathParts[len(pathParts)-1])
    if err != nil {
        http.Error(w, "Invalid todo ID", http.StatusBadRequest)
        return
    }

    userID := GetUserID(r.Context())
    if err := h.todoRepo.Delete(r.Context(), todoID, userID); err != nil {
        if err == repository.ErrTodoNotFound {
            http.Error(w, "Todo not found", http.StatusNotFound)
        } else {
            http.Error(w, "Failed to delete todo", http.StatusInternalServerError)
        }
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

Main и конфигурация

internal/config/config.go

package config

import (
    "fmt"
    "os"
)

type Config struct {
    Port        string
    DatabaseURL string
    JWTSecret   string
}

// Load читает конфигурацию из окружения.
//
// Обрати внимание на разницу между двумя видами значений.
// У порта есть разумный дефолт: не задан - берём 8080, ничего страшного.
// У секрета дефолта быть не может.
func Load() (*Config, error) {
    jwtSecret := os.Getenv("JWT_SECRET")
    if jwtSecret == "" {
        return nil, fmt.Errorf("JWT_SECRET не задан")
    }

    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        return nil, fmt.Errorf("DATABASE_URL не задан")
    }

    return &Config{
        Port:        getEnv("PORT", "8080"),
        DatabaseURL: dbURL,
        JWTSecret:   jwtSecret,
    }, nil
}

func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}
Соблазнительно написать `getEnv("JWT_SECRET", "your-secret-key")` - тогда приложение запустится в любой обстановке и не будет мешать разработке.

Цена такого удобства: приложение молча поднимется на проде с секретом, который знает каждый, кто читал этот урок. Любой сможет подписать себе токен и войти под чужим аккаунтом. Ошибки в логах не будет - всё «работает».

Падение на старте с внятным сообщением лучше тихой дыры. Приложение, которое не запустилось, чинят за минуту; приложение, которое запустилось с дефолтным секретом, обнаруживают после инцидента.

То же и с DATABASE_URL: дефолт postgres://user:password@localhost/... приведёт к тому, что на проде сервис будет ходить не туда, куда вы думаете.

cmd/api/main.go

package main

import (
    "log"
    "net/http"
    "os"
    "os/signal"
    "context"
    "syscall"
    "time"

    "todo-api/internal/config"
    "todo-api/internal/database"
    "todo-api/internal/handler"
    "todo-api/internal/repository"
    "todo-api/internal/service"
)

func main() {
    // Загружаем конфигурацию. Не хватает обязательной переменной -
    // падаем сразу и с внятным текстом, а не позже и загадочно.
    cfg, err := config.Load()
    if err != nil {
        log.Fatal("config: ", err)
    }

    // Подключаемся к БД по строке из конфигурации.
    // Не по захардкоженным параметрам: иначе DATABASE_URL из окружения
    // читается, но никуда не идёт, и на проде сервис ходит не туда.
    db, err := database.New(cfg.DatabaseURL)
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }
    defer db.Close()

    // Выполняем миграции
    if err := db.Migrate(); err != nil {
        log.Fatal("Failed to run migrations:", err)
    }

    // Создаем репозитории
    userRepo := repository.NewUserRepository(db.DB)
    todoRepo := repository.NewTodoRepository(db.DB)

    // Создаем сервисы
    authService := service.NewAuthService(userRepo, cfg.JWTSecret)

    // Создаем handlers
    authHandler := handler.NewAuthHandler(authService)
    todoHandler := handler.NewTodoHandler(todoRepo)

    // Настраиваем роуты
    mux := http.NewServeMux()

    // Публичные роуты
    mux.HandleFunc("/api/auth/register", authHandler.Register)
    mux.HandleFunc("/api/auth/login", authHandler.Login)

    // Защищенные роуты
    //
    // Коллекция и элемент - два разных ресурса, поэтому и роута два:
    //   /api/todos   - вся коллекция: получить список, создать новую задачу
    //   /api/todos/N - конкретная задача: обновить, удалить
    //
    // В http.ServeMux путь без слэша на конце - точное совпадение, со слэшем -
    // поддерево. Если повесить создание на "/api/todos/", то POST на
    // "/api/todos" попадёт в точный роут (список) и вернёт 405. Метод
    // и путь надо разводить осознанно, иначе клиент получает ошибку там,
    // где по документации всё должно работать.
    authMux := http.NewServeMux()

    authMux.HandleFunc("/api/todos", func(w http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case http.MethodGet:
            todoHandler.List(w, r)
        case http.MethodPost:
            todoHandler.Create(w, r)
        default:
            w.Header().Set("Allow", "GET, POST")
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    })

    authMux.HandleFunc("/api/todos/", func(w http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case http.MethodPut:
            todoHandler.Update(w, r)
        case http.MethodDelete:
            todoHandler.Delete(w, r)
        default:
            w.Header().Set("Allow", "PUT, DELETE")
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    })

    // Применяем middleware
    authMiddleware := handler.AuthMiddleware(cfg.JWTSecret)
    mux.Handle("/api/todos", authMiddleware(authMux))
    mux.Handle("/api/todos/", authMiddleware(authMux))

    // Глобальные middleware
    h := handler.CORSMiddleware(handler.LoggingMiddleware(mux))

    // Создаем сервер
    srv := &http.Server{
        Addr:         ":" + cfg.Port,
        Handler:      h,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Запускаем сервер
    go func() {
        log.Printf("Server starting on port %s", cfg.Port)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal("Failed to start server:", err)
        }
    }()

    // Graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
    <-quit

    log.Println("Shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }

    log.Println("Server exited")
}

Тестирование

internal/handler/auth_test.go

Заглушка реализует тот же интерфейс handler.AuthService, что и настоящий сервис. Компилятор проверит это за нас: если сигнатура разъедется, тест не соберётся.

package handler_test

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"

    "todo-api/internal/handler"
    "todo-api/internal/models"
)

// mockAuthService - заглушка вместо настоящего сервиса.
// Поля-функции позволяют задать поведение прямо в тесте,
// не заводя отдельный тип на каждый сценарий.
type mockAuthService struct {
    RegisterFunc func(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error)
    LoginFunc    func(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error)
}

func (m *mockAuthService) Register(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error) {
    return m.RegisterFunc(ctx, req)
}

func (m *mockAuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error) {
    return m.LoginFunc(ctx, req)
}

// Проверка на этапе компиляции, что заглушка действительно
// удовлетворяет интерфейсу. Стоит ноль, ловит расхождение сразу.
var _ handler.AuthService = (*mockAuthService)(nil)

func TestAuthHandler_Register(t *testing.T) {
    mockService := &mockAuthService{
        RegisterFunc: func(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error) {
            if req.Email == "exists@example.com" {
                return nil, fmt.Errorf("email already registered")
            }
            return &models.AuthResponse{
                Token: "test-token",
                User: &models.User{
                    ID:    1,
                    Email: req.Email,
                },
            }, nil
        },
    }

    // Имя переменной не `handler`: оно затенило бы одноимённый пакет,
    // и обратиться к `handler.AuthService` в этой же функции стало бы нельзя.
    authHandler := handler.NewAuthHandler(mockService)

    tests := []struct {
        name       string
        body       interface{}
        wantStatus int
    }{
        {
            name: "successful registration",
            body: models.CreateUserRequest{
                Email:    "new@example.com",
                Password: "password123",
            },
            wantStatus: http.StatusCreated,
        },
        {
            name: "email already exists",
            body: models.CreateUserRequest{
                Email:    "exists@example.com",
                Password: "password123",
            },
            wantStatus: http.StatusBadRequest,
        },
        {
            name:       "invalid body",
            body:       "invalid",
            wantStatus: http.StatusBadRequest,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            body, _ := json.Marshal(tt.body)
            req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
            req.Header.Set("Content-Type", "application/json")

            rr := httptest.NewRecorder()
            authHandler.Register(rr, req)

            if rr.Code != tt.wantStatus {
                t.Errorf("status = %d; want %d", rr.Code, tt.wantStatus)
            }
        })
    }
}

Переменные окружения

config.Load() падает без JWT_SECRET и DATABASE_URL, поэтому просто go run уже не сработает - и это правильно. Кладём рядом файл .env.example, который коммитим (он показывает, какие переменные нужны), и .env, который добавляем в .gitignore (в нём настоящие значения).

# .env.example
PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/tododb?sslmode=disable
JWT_SECRET=  # сгенерируй свой: openssl rand -base64 32

Пустой JWT_SECRET в примере - намеренно. Заполненный кто-нибудь скопирует как есть, и секрет из репозитория уедет на прод.

Makefile

.PHONY: build run test clean migrate dev

# Подхватываем .env, если он есть: локально удобно, в CI переменные придут
# из окружения и файл не понадобится.
ifneq (,$(wildcard .env))
include .env
export
endif

build:
	go build -o bin/api cmd/api/main.go

run: build
	./bin/api

test:
	go test -v -cover ./...

clean:
	rm -rf bin/

# DSN берём из окружения, а не пишем в Makefile: иначе миграции поедут
# в одну базу, а приложение - в другую, и рассинхрон вылезет не сразу.
migrate:
	migrate -path migrations -database "$(DATABASE_URL)" up

dev:
	go run cmd/api/main.go

docker-build:
	docker build -t todo-api .

docker-run:
	docker run -p 8080:8080 todo-api

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

Endpoints

Авторизация:

  • POST /api/auth/register - Регистрация
  • POST /api/auth/login - Вход

Todos (требуется авторизация):

  • GET /api/todos - Список задач
  • POST /api/todos - Создать задачу
  • PUT /api/todos/{id} - Обновить задачу
  • DELETE /api/todos/{id} - Удалить задачу

Примеры запросов

# Регистрация
curl -X POST http://localhost:8080/api/auth/register \
 -H "Content-Type: application/json" \
 -d '{"email":"user@example.com","password":"password123"}'

# Вход
curl -X POST http://localhost:8080/api/auth/login \
 -H "Content-Type: application/json" \
 -d '{"email":"user@example.com","password":"password123"}'

# Создание задачи
curl -X POST http://localhost:8080/api/todos \
 -H "Authorization: Bearer YOUR_TOKEN" \
 -H "Content-Type: application/json" \
 -d '{"title":"Learn Go","description":"Complete the Go course"}'

# Получение списка задач
curl -X GET http://localhost:8080/api/todos \
 -H "Authorization: Bearer YOUR_TOKEN"

# Обновление задачи (id в пути)
curl -X PUT http://localhost:8080/api/todos/1 \
 -H "Authorization: Bearer YOUR_TOKEN" \
 -H "Content-Type: application/json" \
 -d '{"title":"Learn Go","completed":true}'

# Удаление задачи
curl -X DELETE http://localhost:8080/api/todos/1 \
 -H "Authorization: Bearer YOUR_TOKEN"

Каждая из этих команд должна отработать на буквальном копировании. Если что-то возвращает 405 Method Not Allowed - значит метод ушёл не в тот роут, и стоит перечитать раздел про http.ServeMux выше: путь без слэша на конце это точное совпадение, со слэшем - поддерево.

Итоги

Мы создали полноценное REST API приложение с:

  • JWT авторизацией
  • CRUD операциями
  • Чистой архитектурой
  • Тестами
  • Graceful shutdown
  • Middleware pipeline

Это приближенная к продакшену структура: разделение на слои, внедрение зависимостей через интерфейсы, конфигурация из окружения, корректное завершение. Такую архитектуру можно расширять и масштабировать.

Что обязательно доделать перед продом

Слово «production-ready» в туториалах обычно означает «структура правильная». Между правильной структурой и сервисом, которому можно доверить чужие данные, лежит ещё вот это:

ЧтоСейчас в проектеЧто нужно
TLS у базыsslmode=disable в строке подключенияsslmode=verify-full и сертификат
CORSAccess-Control-Allow-Origin: *список конкретных доменов
Секретыиз окружения, но лежат в .env рядом с кодомхранилище секретов, ротация, .env в .gitignore
Rate limitingнетлимит на попытки логина, иначе пароли переберут
Логиlog.Printf в stdoutструктурные логи (log/slog), без токенов и паролей внутри
Метрики и трейсынетхотя бы /metrics и время ответа по ручкам
Миграциивыполняются при старте приложенияотдельный шаг деплоя, иначе два инстанса стартуют одновременно
Бэкапынетрасписание и проверенное восстановление
Заголовки безопасностинетHSTS, X-Content-Type-Options, CSP
Валидация запросовтеги validate:"..." расставленывызов валидатора: теги сами по себе ничего не проверяют

Последний пункт стоит проверить прямо сейчас: найди в коде место, где вызывается валидатор. Если такого места нет - значит validate:"required,email" на структуре это просто комментарий, и пустой email спокойно доедет до базы.

🎉 Поздравляю с завершением курса Go!

Типичная ошибка

Пытаться «сразу написать всё». Лучше сначала минимальный скелет, потом улучшения.

Мини-практика

Собери мини‑CLI «таск‑лист»: добавить задачу, показать список, отметить выполненной (можно в памяти или в файле JSON).

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.