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

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

Проект стоит последним не случайно: он использует почти каждый урок курса.

Часть его ты уже видел. В вехе 3 мы разобрали слой работы с базой именно этого проекта - репозиторий, миграции и пул соединений. Здесь к нему добавятся регистрация, JWT, middleware и Docker. HTTP и базы данных - основа, но кроме них понадобятся интерфейсы для подмены зависимостей в тестах, обработка ошибок через errors.Is, тестирование, переменные окружения для конфигурации, преобразование типов при разборе id из пути, пакеты для разделения на слои и context в каждом запросе к базе.

Если какой-то из этих уроков пропущен - лучше вернуться сейчас, а не разбираться по ходу.

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

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
│   │   ├── response.go       # Общие ответы и валидация
│   │   └── router.go         # Сборка маршрутов
│   └── database/
│       └── postgres.go       # Подключение к БД
├── migrations/
│   ├── 001_create_users.sql
│   └── 002_create_todos.sql
├── pkg/
│   ├── hash/
│   │   └── password.go       # Хеширование паролей
│   └── token/
│       └── jwt.go            # Работа с JWT
├── go.mod
├── go.sum
├── .env.example
├── Makefile
└── README.md
Код ниже не переписан в текст урока руками - он **вставляется** из реального проекта `examples/todo-api/` при сборке сайта. Тот же код собирается, проходит `go vet` и тесты в CI.

Так сделано после того, как в прошлой версии урока накопились пропущенные импорты, несовместимые сигнатуры и роутинг, при котором опубликованный в этом же уроке curl возвращал 405. Копия документации всегда отстаёт от кода; единственный надёжный способ - не иметь копии.

Клонируй репозиторий и запусти make test в examples/todo-api/, если хочешь сразу увидеть работающий результат.

Зависимости

go mod init todo-api

go get github.com/lib/pq                      # драйвер PostgreSQL
go get github.com/golang-jwt/jwt/v5           # JWT
go get golang.org/x/crypto/bcrypt             # хеширование паролей
go get github.com/go-playground/validator/v10 # валидация запросов

Четыре зависимости на весь проект - это норма для Go. Роутер, логирование и работа с БД взяты из стандартной библиотеки: net/http и database/sql покрывают почти всё, что нужно API такого размера.

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

Модели - это обычные структуры с тегами для 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"`
}

// UpdateTodoRequest использует указатели, чтобы отличить «поле не прислали»
// от «прислали пустое значение». Без указателя `Completed bool` при отсутствии
// поля в JSON остался бы false, и любой PATCH снимал бы отметку о выполнении.
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"`
}

База данных

Схема живёт в SQL-файлах, и только в них. Go-код их читает, а не повторяет: migrations/migrations.go встраивает каталог через go:embed, а Migrate() проходит файлы по порядку имён. Соблазн продублировать CREATE TABLE строками в Go велик - так меньше файлов и «сразу видно схему», - но это вторая копия одного знания. Через месяц правится одна, вторая остаётся правильной на вид и неправильной по факту, а расхождение вылезает при первом развёртывании с чистой базы.

Тот же принцип, по которому код в этом уроке вставляется из работающего проекта, а не переписывается руками.

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
);

-- Индекса на email здесь нет намеренно.
-- UNIQUE в PostgreSQL уже создаёт уникальный индекс (users_email_key),
-- и поиск по 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
);

-- А этот индекс нужен: PostgreSQL не создаёт индекс под внешний ключ сам.
-- Без него выборка задач пользователя идёт последовательным чтением таблицы,
-- и удаление пользователя проверяет каждую строку todos.
CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id);

migrations/migrations.go

// Package migrations хранит схему базы и отдаёт её как embed.FS.
//
// Зачем пакет ради двух SQL-файлов: чтобы у схемы был ровно один источник.
// Раньше те же CREATE TABLE лежали и здесь, и строками внутри Migrate() в Go.
// Две копии одного знания расходятся молча - меняешь одну, вторая остаётся
// правильной на вид и неправильной по факту. Теперь Go-код читает эти файлы,
// а не повторяет их.
//
// `go:embed` не умеет подниматься выше своего каталога, поэтому переменная
// объявлена рядом с самими .sql, а не в пакете database.
package migrations

import "embed"

// FS содержит все SQL-файлы каталога. Порядок применения задаётся именами:
// 001_, 002_ и так далее, поэтому сортировка по имени и есть порядок миграций.
//
//go:embed *.sql
var FS embed.FS

internal/database/postgres.go

package database

import (
	"context"
	"database/sql"
	"fmt"
	"io/fs"
	"sort"
	"time"

	_ "github.com/lib/pq"

	"todo-api/migrations"
)

type DB struct {
	*sql.DB
}

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

	// sql.Open не подключается - он лишь готовит пул, поэтому первая реальная
	// проверка связи это Ping. И если он не прошёл, пул надо закрыть: за ним
	// стоят горутина обслуживания соединений и захваченные ресурсы, а вернув
	// одну ошибку, мы теряем на них последнюю ссылку.
	//
	// PingContext, а не Ping: без контекста вызов ждёт столько, сколько решит
	// драйвер, и запуск сервиса зависает на недоступной базе вместо того,
	// чтобы честно упасть.
	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

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

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

	return &DB{db}, nil
}

// Migrate применяет SQL-файлы из каталога migrations по порядку имён.
//
// Раньше схема была продублирована здесь строками Go, и файлы рядом лежали
// для вида. Копия документации всегда отстаёт от кода - с копией схемы то же
// самое, только чинить дороже: расхождение вылезает на проде при первом
// развёртывании с нуля.
//
// Настоящей системы миграций тут нет намеренно: нет таблицы с историей,
// нет отката. Для учебного проекта достаточно `IF NOT EXISTS` в самих файлах,
// а на реальном проекте берут goose, migrate или Atlas.
func (db *DB) Migrate() error {
	files, err := fs.Glob(migrations.FS, "*.sql")
	if err != nil {
		return fmt.Errorf("list migrations: %w", err)
	}
	if len(files) == 0 {
		return fmt.Errorf("no migrations found: каталог migrations пуст")
	}

	// Имена начинаются с номера, поэтому сортировка по имени и есть порядок.
	sort.Strings(files)

	for _, name := range files {
		query, err := fs.ReadFile(migrations.FS, name)
		if err != nil {
			return fmt.Errorf("read %s: %w", name, err)
		}
		if _, err := db.Exec(string(query)); err != nil {
			return fmt.Errorf("apply %s: %w", name, err)
		}
	}

	return nil
}

Репозитории

internal/repository/user_repo.go

package repository

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

	"todo-api/internal/models"
)

var ErrUserNotFound = errors.New("user not found")

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,
	)

	// errors.Is, а не `err == sql.ErrNoRows`: драйвер вправе обернуть ошибку,
	// и тогда сравнение по значению молча перестанет работать.
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrUserNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("find user by email: %w", err)
	}

	return user, nil
}

internal/repository/todo_repo.go

package repository

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

	"todo-api/internal/models"
)

var ErrTodoNotFound = errors.New("todo not found")

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
}

// FindByID возвращает одну задачу конкретного пользователя.
//
// user_id в условии - не украшение: без него любой авторизованный смог бы
// читать чужие задачи, подставив чужой id. Проверка владельца делается
// в самом запросе, а не после выборки, потому что «забыть сравнить»
// в коде проще, чем забыть условие в SQL.
func (r *TodoRepository) FindByID(ctx context.Context, id, userID int) (*models.Todo, error) {
	todo := &models.Todo{}

	query := `
        SELECT id, user_id, title, description, completed, created_at, updated_at
        FROM todos
        WHERE id = $1 AND user_id = $2
    `

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

	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrTodoNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("find todo by id: %w", err)
	}

	return todo, 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()

	// Инициализируем пустым срезом, а не nil: nil-срез сериализуется в `null`,
	// и клиенту приходится обрабатывать два вида «пусто». Пустой даёт `[]`.
	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)
	}

	// rows.Err() обязателен: цикл заканчивается и при ошибке чтения,
	// и без этой проверки половина строк молча превратится в успешный ответ.
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate todos: %w", err)
	}

	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
}

Сервисы

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"
	"errors"
	"fmt"
	"time"

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

// ErrEmailTaken и ErrInvalidCredentials - часть контракта сервиса.
// Хендлер по ним выбирает статус ответа, поэтому они объявлены явно,
// а не собираются из текста ошибки строкой.
var (
	ErrEmailTaken         = errors.New("email already registered")
	ErrInvalidCredentials = errors.New("invalid credentials")
)

// UserRepository - то, что нужно сервису от хранилища пользователей.
//
// Интерфейс объявлен здесь, а не в пакете repository, по той же причине,
// что и TodoRepository в todo.go: потребитель описывает свои требования,
// поставщик про них не знает. Практическая польза - тест сервиса
// не поднимает PostgreSQL, а видно из списка методов, что сервису нужны
// ровно два из всего репозитория.
type UserRepository interface {
	Create(ctx context.Context, user *models.User) error
	// FindByEmail возвращает repository.ErrUserNotFound, если пользователя нет.
	FindByEmail(ctx context.Context, email string) (*models.User, error)
}

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

func NewAuthService(userRepo 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, _ := ...`, и упавшая база выглядела как «пользователя нет».
	existing, err := s.userRepo.FindByEmail(ctx, req.Email)
	if err != nil && !errors.Is(err, repository.ErrUserNotFound) {
		return nil, fmt.Errorf("find user: %w", err)
	}
	if existing != nil {
		return nil, ErrEmailTaken
	}

	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
}

// dummyPasswordHash - настоящий bcrypt-хеш от случайной строки, cost 10
// (как DefaultCost у hash.HashPassword).
//
// Нужен, чтобы ветка «нет такого пользователя» занимала столько же времени,
// сколько «пароль не подошёл». Без него ответ приходит за микросекунды
// вместо десятков миллисекунд, и по времени ответа можно перебрать,
// какие адреса зарегистрированы, - при том что текст ошибки одинаковый.
//
// Хеш обязан быть валидным и того же cost: строка-заглушка вида
// "$2a$10$dummy..." не пройдёт разбор, CompareHashAndPassword вернёт ошибку
// сразу, и задержки не будет.
const dummyPasswordHash = "$2a$10$J/aomb7E6Ked8V9xANCc3ez86O2NbWWCyEKgwtFrjpItsqd5ZAnei"

func (s *AuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error) {
	user, err := s.userRepo.FindByEmail(ctx, req.Email)

	// Ошибку базы отличаем от «пользователя нет» - ровно как в Register.
	// Иначе недоступный PostgreSQL отвечает клиенту «неверный email или
	// пароль»: пользователь думает, что у него угнали пароль, а мониторинг
	// видит рост 401 и принимает это за брутфорс.
	if err != nil && !errors.Is(err, repository.ErrUserNotFound) {
		return nil, fmt.Errorf("find user: %w", err)
	}

	if err != nil {
		// Пользователя нет. Считаем bcrypt по заглушке, чтобы время ответа
		// не отличалось от ветки с неверным паролем, и возвращаем ту же
		// ошибку: разные ответы позволяют перебором узнать, какие email
		// зарегистрированы.
		hash.CheckPassword(req.Password, dummyPasswordHash)
		return nil, ErrInvalidCredentials
	}

	if !hash.CheckPassword(req.Password, user.PasswordHash) {
		return nil, ErrInvalidCredentials
	}

	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
}

// Проверка на этапе компиляции: настоящий репозиторий обязан подходить
// под интерфейс. Если сигнатура разъедется - не соберётся, а не упадёт в рантайме.
var _ UserRepository = (*repository.UserRepository)(nil)

internal/service/todo.go

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

`UpdateTodoRequest` использует указатели, чтобы отличить «поле не прислали» от «прислали пустое значение». Но SQL-запрос обновляет **все** колонки:
SET title = $1, description = $2, completed = $3

Если хендлер собирает Todo только из присланных полей и отдаёт его репозиторию, то запрос {"completed": true} запишет в title пустую строку. Задача потеряет название.

Указатели в DTO намекали на частичное обновление, но накладывать изменения было не на что: текущее состояние никто не читал.

Правильный порядок: прочитать задачу, наложить присланные поля, сохранить целиком. Заодно проверка владельца происходит на чтении - чужая задача не найдётся, и до UPDATE дело не дойдёт.

package service

import (
	"context"
	"fmt"

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

// TodoRepository - то, что нужно сервису от хранилища.
// Интерфейс объявлен в потребителе, поэтому в тестах подставляется заглушка,
// а сам `*repository.TodoRepository` удовлетворяет ему автоматически.
type TodoRepository interface {
	Create(ctx context.Context, todo *models.Todo) error
	FindByID(ctx context.Context, id, userID int) (*models.Todo, error)
	FindByUserID(ctx context.Context, userID int) ([]*models.Todo, error)
	Update(ctx context.Context, todo *models.Todo) error
	Delete(ctx context.Context, id, userID int) error
}

type TodoService struct {
	repo TodoRepository
}

func NewTodoService(repo TodoRepository) *TodoService {
	return &TodoService{repo: repo}
}

func (s *TodoService) Create(ctx context.Context, userID int, req *models.CreateTodoRequest) (*models.Todo, error) {
	todo := &models.Todo{
		UserID:      userID,
		Title:       req.Title,
		Description: req.Description,
	}

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

	return todo, nil
}

func (s *TodoService) Get(ctx context.Context, id, userID int) (*models.Todo, error) {
	return s.repo.FindByID(ctx, id, userID)
}

func (s *TodoService) List(ctx context.Context, userID int) ([]*models.Todo, error) {
	return s.repo.FindByUserID(ctx, userID)
}

// Update применяет частичное обновление.
//
// Здесь была ошибка, которую стоит разобрать. Раньше хендлер собирал
// `models.Todo` только из присланных полей и отдавал его в репозиторий,
// а SQL пишет **все** три колонки:
//
//	SET title = $1, description = $2, completed = $3
//
// В результате запрос `{"completed": true}` затирал title пустой строкой.
// Тип `*string` в DTO намекал на частичное обновление, но никто не читал
// текущее состояние - обновлять было не от чего.
//
// Правильный порядок: прочитать задачу, наложить присланные поля на неё,
// сохранить целиком. Заодно проверка владельца происходит на чтении:
// чужая задача не найдётся, и до UPDATE дело не дойдёт.
func (s *TodoService) Update(ctx context.Context, id, userID int, req *models.UpdateTodoRequest) (*models.Todo, error) {
	todo, err := s.repo.FindByID(ctx, id, userID)
	if err != nil {
		return nil, err
	}

	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 := s.repo.Update(ctx, todo); err != nil {
		return nil, err
	}

	return todo, nil
}

func (s *TodoService) Delete(ctx context.Context, id, userID int) error {
	return s.repo.Delete(ctx, id, userID)
}

// Проверка на этапе компиляции: настоящий репозиторий обязан подходить
// под интерфейс. Если сигнатура разъедется - не соберётся, а не упадёт в рантайме.
var _ TodoRepository = (*repository.TodoRepository)(nil)

Обрати внимание на последнюю строку файла:

var _ TodoRepository = (*repository.TodoRepository)(nil)

В рантайме она не делает ничего. Её работа - на этапе компиляции: если сигнатура метода в репозитории разъедется с интерфейсом, проект не соберётся. Узнать об этом от компилятора лучше, чем от пользователя.

HTTP Handlers

internal/handler/middleware.go

package handler

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

	"todo-api/pkg/token"
)

// contextKey - собственный тип для ключей контекста.
//
// Строку в качестве ключа использовать нельзя: любой другой пакет может
// положить в контекст значение по тому же ключу "userID" и незаметно
// перезаписать наше. Свой тип делает коллизию невозможной.
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 == "" {
				writeError(w, http.StatusUnauthorized, "нужен заголовок Authorization")
				return
			}

			// Формат: Bearer <token>
			parts := strings.Fields(authHeader)
			if len(parts) != 2 || parts[0] != "Bearer" {
				writeError(w, http.StatusUnauthorized, "неверный формат заголовка Authorization")
				return
			}

			claims, err := token.Parse(parts[1], jwtSecret)
			if err != nil {
				writeError(w, http.StatusUnauthorized, "невалидный токен")
				return
			}

			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 == http.MethodOptions {
			w.WriteHeader(http.StatusOK)
			return
		}

		next.ServeHTTP(w, r)
	})
}

internal/handler/response.go

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

Посмотри на модель из начала урока:
type CreateUserRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=6"`
}

Выглядит так, будто валидация есть. Её нет.

Тег - это просто строка в метаданных типа. Она не выполняется и ни на что не влияет, пока кто-нибудь явно не попросит валидатор её прочитать. Без вызова validate.Struct(req) пустой email и пароль из одного символа спокойно доедут до базы.

Это опасный вид ошибки: код выглядит защищённым. Ревьюер видит теги и идёт дальше, автор помнит, что «валидацию добавляли». А её нет.

Проверить можно за десять секунд - поиском по проекту: если validate.Struct или Validate( нигде не вызывается, значит все теги в проекте декоративные.

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

package handler

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

	"github.com/go-playground/validator/v10"
)

// validate создаётся один раз на весь пакет.
//
// Так рекомендует сама библиотека: экземпляр кеширует разбор тегов
// по типам структур, и создавать его на каждый запрос - значит выбрасывать
// этот кеш. Экземпляр потокобезопасен, параллельные запросы ему не мешают.
var validate = validator.New(validator.WithRequiredStructEnabled())

// ErrorResponse - единый формат ошибки. Клиенту всегда приходит JSON,
// а не иногда JSON, иногда текст от http.Error.
type ErrorResponse struct {
	Error  string            `json:"error"`
	Fields map[string]string `json:"fields,omitempty"`
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if v != nil {
		_ = json.NewEncoder(w).Encode(v)
	}
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, ErrorResponse{Error: message})
}

// decodeAndValidate читает JSON и проверяет его по тегам `validate`.
//
// Именно этого вызова раньше не было. Теги `validate:"required,email"`
// стояли на структурах и не делали ничего: это просто строки в метаданных
// типа, пока кто-нибудь не попросит валидатор их прочитать. Пустой email
// спокойно доезжал до базы.
//
// DisallowUnknownFields: лишнее поле в запросе - обычно опечатка в имени
// («titel» вместо «title»), и молча её проглотить хуже, чем ответить 400.
func decodeAndValidate(w http.ResponseWriter, r *http.Request, dst any) bool {
	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()

	if err := decoder.Decode(dst); err != nil {
		writeError(w, http.StatusBadRequest, "невалидный JSON: "+err.Error())
		return false
	}

	if err := validate.Struct(dst); err != nil {
		var invalid *validator.InvalidValidationError
		if errors.As(err, &invalid) {
			// Ошибка программиста, а не клиента: передали не структуру.
			writeError(w, http.StatusInternalServerError, "ошибка валидации")
			return false
		}

		fields := map[string]string{}
		var validationErrors validator.ValidationErrors
		if errors.As(err, &validationErrors) {
			for _, fieldErr := range validationErrors {
				fields[fieldErr.Field()] = describeRule(fieldErr)
			}
		}

		writeJSON(w, http.StatusBadRequest, ErrorResponse{
			Error:  "проверьте поля запроса",
			Fields: fields,
		})
		return false
	}

	return true
}

// describeRule переводит правило валидатора в человеческий текст.
// Без него клиент получает «Key: 'CreateUserRequest.Email' Error:Field
// validation for 'Email' failed on the 'email' tag» - технически верно
// и бесполезно для того, кто заполняет форму.
func describeRule(fieldErr validator.FieldError) string {
	switch fieldErr.Tag() {
	case "required":
		return "обязательное поле"
	case "email":
		return "некорректный email"
	case "min":
		return "минимальная длина: " + fieldErr.Param()
	case "max":
		return "максимальная длина: " + fieldErr.Param()
	default:
		return "не проходит проверку " + fieldErr.Tag()
	}
}

Отдельно про DisallowUnknownFields. По умолчанию encoding/json молча игнорирует поля, которых нет в структуре. Клиент отправляет {"titel": "..."} с опечаткой, получает 201, а задача создаётся с пустым заголовком. Лучше ответить 400 и назвать проблему.

internal/handler/auth.go

package handler

import (
	"context"
	"errors"
	"net/http"

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

// AuthService - то, что нужно хендлеру от сервиса, и ничего больше.
//
// Интерфейс объявлен здесь, а не в пакете service, и это принципиально:
// потребитель описывает свои требования, поставщик про них не знает.
// `*service.AuthService` удовлетворяет этому интерфейсу автоматически,
// импортировать пакет service хендлеру для работы не нужно.
//
// Практическая польза: в тесте вместо настоящего сервиса подставляется
// заглушка, и хендлер проверяется без базы, без bcrypt и без JWT.
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 {
		w.Header().Set("Allow", http.MethodPost)
		writeError(w, http.StatusMethodNotAllowed, "метод не поддерживается")
		return
	}

	var req models.CreateUserRequest
	if !decodeAndValidate(w, r, &req) {
		return
	}

	resp, err := h.authService.Register(r.Context(), &req)
	if err != nil {
		if errors.Is(err, service.ErrEmailTaken) {
			// 409, а не 400: запрос корректен, конфликтует состояние сервера.
			writeError(w, http.StatusConflict, "email уже зарегистрирован")
			return
		}
		writeError(w, http.StatusInternalServerError, "не удалось зарегистрировать")
		return
	}

	writeJSON(w, http.StatusCreated, resp)
}

func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.Header().Set("Allow", http.MethodPost)
		writeError(w, http.StatusMethodNotAllowed, "метод не поддерживается")
		return
	}

	var req models.LoginRequest
	if !decodeAndValidate(w, r, &req) {
		return
	}

	resp, err := h.authService.Login(r.Context(), &req)
	if err != nil {
		writeError(w, http.StatusUnauthorized, "неверный email или пароль")
		return
	}

	writeJSON(w, http.StatusOK, resp)
}

var _ AuthService = (*service.AuthService)(nil)

internal/handler/todo.go

package handler

import (
	"context"
	"errors"
	"net/http"
	"strconv"
	"strings"

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

type TodoService interface {
	Create(ctx context.Context, userID int, req *models.CreateTodoRequest) (*models.Todo, error)
	Get(ctx context.Context, id, userID int) (*models.Todo, error)
	List(ctx context.Context, userID int) ([]*models.Todo, error)
	Update(ctx context.Context, id, userID int, req *models.UpdateTodoRequest) (*models.Todo, error)
	Delete(ctx context.Context, id, userID int) error
}

type TodoHandler struct {
	todos TodoService
}

func NewTodoHandler(todos TodoService) *TodoHandler {
	return &TodoHandler{todos: todos}
}

// todoIDFromPath достаёт id из "/api/todos/42".
//
// Возвращает ok=false, если id нет или он не число - хендлер отвечает 400.
// Отдельная функция, потому что нужна четырём методам, и «скопировать
// разбор пути ещё раз» - как раз тот случай, где копии разъезжаются.
func todoIDFromPath(path string) (int, bool) {
	raw := strings.TrimPrefix(path, "/api/todos/")
	raw = strings.Trim(raw, "/")
	if raw == "" || strings.Contains(raw, "/") {
		return 0, false
	}

	id, err := strconv.Atoi(raw)
	if err != nil || id <= 0 {
		return 0, false
	}

	return id, true
}

func (h *TodoHandler) List(w http.ResponseWriter, r *http.Request) {
	todos, err := h.todos.List(r.Context(), GetUserID(r.Context()))
	if err != nil {
		writeError(w, http.StatusInternalServerError, "не удалось получить список задач")
		return
	}

	writeJSON(w, http.StatusOK, todos)
}

func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
	var req models.CreateTodoRequest
	if !decodeAndValidate(w, r, &req) {
		return
	}

	todo, err := h.todos.Create(r.Context(), GetUserID(r.Context()), &req)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "не удалось создать задачу")
		return
	}

	writeJSON(w, http.StatusCreated, todo)
}

func (h *TodoHandler) Get(w http.ResponseWriter, r *http.Request) {
	id, ok := todoIDFromPath(r.URL.Path)
	if !ok {
		writeError(w, http.StatusBadRequest, "некорректный id задачи")
		return
	}

	todo, err := h.todos.Get(r.Context(), id, GetUserID(r.Context()))
	if err != nil {
		if errors.Is(err, repository.ErrTodoNotFound) {
			writeError(w, http.StatusNotFound, "задача не найдена")
			return
		}
		writeError(w, http.StatusInternalServerError, "не удалось получить задачу")
		return
	}

	writeJSON(w, http.StatusOK, todo)
}

func (h *TodoHandler) Update(w http.ResponseWriter, r *http.Request) {
	id, ok := todoIDFromPath(r.URL.Path)
	if !ok {
		writeError(w, http.StatusBadRequest, "некорректный id задачи")
		return
	}

	var req models.UpdateTodoRequest
	if !decodeAndValidate(w, r, &req) {
		return
	}

	todo, err := h.todos.Update(r.Context(), id, GetUserID(r.Context()), &req)
	if err != nil {
		if errors.Is(err, repository.ErrTodoNotFound) {
			writeError(w, http.StatusNotFound, "задача не найдена")
			return
		}
		writeError(w, http.StatusInternalServerError, "не удалось обновить задачу")
		return
	}

	// Возвращаем обновлённую задачу, а не 204: клиенту нужно знать
	// итоговое состояние, иначе после частичного обновления он вынужден
	// делать ещё один GET.
	writeJSON(w, http.StatusOK, todo)
}

func (h *TodoHandler) Delete(w http.ResponseWriter, r *http.Request) {
	id, ok := todoIDFromPath(r.URL.Path)
	if !ok {
		writeError(w, http.StatusBadRequest, "некорректный id задачи")
		return
	}

	if err := h.todos.Delete(r.Context(), id, GetUserID(r.Context())); err != nil {
		if errors.Is(err, repository.ErrTodoNotFound) {
			writeError(w, http.StatusNotFound, "задача не найдена")
			return
		}
		writeError(w, http.StatusInternalServerError, "не удалось удалить задачу")
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

var _ TodoService = (*service.TodoService)(nil)

internal/handler/router.go

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

package handler

import (
	"net/http"
)

// NewRouter собирает все маршруты приложения.
//
// Роутинг вынесен из main в отдельную функцию не ради красоты: пока он жил
// внутри main, проверить его можно было только запустив приложение с базой.
// Теперь тест поднимает httptest.Server поверх этого же роутера и сверяет
// каждый документированный запрос с фактическим ответом.
//
// Про http.ServeMux важно помнить: путь без слэша на конце - точное
// совпадение, со слэшем - поддерево. Коллекция и элемент это два разных
// ресурса, поэтому и шаблона два:
//
//	/api/todos   - вся коллекция: список (GET), создание (POST)
//	/api/todos/N - конкретная задача: чтение (GET), обновление (PUT), удаление (DELETE)
//
// Если повесить создание на "/api/todos/", то POST на "/api/todos" попадёт
// в точный шаблон и вернёт 405 - ровно там, где документация обещает 201.
func NewRouter(authHandler *AuthHandler, todoHandler *TodoHandler, jwtSecret string) http.Handler {
	mux := http.NewServeMux()

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

	// Защищённые маршруты
	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:
			// Заголовок Allow обязателен для 405 по RFC 9110:
			// клиент должен узнать, какие методы здесь вообще есть.
			w.Header().Set("Allow", "GET, POST")
			writeError(w, http.StatusMethodNotAllowed, "метод не поддерживается")
		}
	})

	authMux.HandleFunc("/api/todos/", func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodGet:
			todoHandler.Get(w, r)
		case http.MethodPut:
			todoHandler.Update(w, r)
		case http.MethodDelete:
			todoHandler.Delete(w, r)
		default:
			w.Header().Set("Allow", "GET, PUT, DELETE")
			writeError(w, http.StatusMethodNotAllowed, "метод не поддерживается")
		}
	})

	authMiddleware := AuthMiddleware(jwtSecret)
	mux.Handle("/api/todos", authMiddleware(authMux))
	mux.Handle("/api/todos/", authMiddleware(authMux))

	return CORSMiddleware(LoggingMiddleware(mux))
}

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 (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"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(context.Background(), 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)
	todoService := service.NewTodoService(todoRepo)

	// Хендлеры
	authHandler := handler.NewAuthHandler(authService)
	todoHandler := handler.NewTodoHandler(todoService)

	srv := &http.Server{
		Addr:         ":" + cfg.Port,
		Handler:      handler.NewRouter(authHandler, todoHandler, cfg.JWTSecret),
		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"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

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

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

type mockAuthService struct {
	registerFn func(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error)
	loginFn    func(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error)

	// called фиксирует, дошёл ли запрос до сервиса. Для проверок валидации
	// это главное: невалидные данные не должны доходить дальше хендлера.
	called bool
}

func (m *mockAuthService) Register(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error) {
	m.called = true
	if m.registerFn != nil {
		return m.registerFn(ctx, req)
	}
	return &models.AuthResponse{Token: "test-token", User: &models.User{ID: 1, Email: req.Email}}, nil
}

func (m *mockAuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error) {
	m.called = true
	if m.loginFn != nil {
		return m.loginFn(ctx, req)
	}
	return &models.AuthResponse{Token: "test-token", User: &models.User{ID: 1, Email: req.Email}}, nil
}

func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
	req.Header.Set("Content-Type", "application/json")
	rr := httptest.NewRecorder()
	h(rr, req)
	return rr
}

func TestRegister_Success(t *testing.T) {
	svc := &mockAuthService{}
	h := handler.NewAuthHandler(svc)

	rr := postJSON(t, h.Register, "/api/auth/register", `{"email":"user@example.com","password":"password123"}`)

	if rr.Code != http.StatusCreated {
		t.Fatalf("status = %d; want %d. body: %s", rr.Code, http.StatusCreated, rr.Body.String())
	}

	var resp models.AuthResponse
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("ответ не разбирается как JSON: %v", err)
	}
	if resp.Token == "" {
		t.Error("в ответе нет токена")
	}
	if resp.User == nil || resp.User.Email != "user@example.com" {
		t.Errorf("в ответе не тот пользователь: %+v", resp.User)
	}
}

// Ради этого теста и делалась валидация. Раньше теги `validate:"..."`
// стояли на структуре и не проверялись - пустой email доезжал до сервиса,
// а оттуда в базу.
func TestRegister_Validation(t *testing.T) {
	tests := []struct {
		name       string
		body       string
		wantStatus int
		wantField  string
	}{
		{"пустой email", `{"email":"","password":"password123"}`, http.StatusBadRequest, "Email"},
		{"email без @", `{"email":"not-an-email","password":"password123"}`, http.StatusBadRequest, "Email"},
		{"email отсутствует", `{"password":"password123"}`, http.StatusBadRequest, "Email"},
		{"пустой пароль", `{"email":"user@example.com","password":""}`, http.StatusBadRequest, "Password"},
		{"пароль короче 6", `{"email":"user@example.com","password":"12345"}`, http.StatusBadRequest, "Password"},
		{"пароль отсутствует", `{"email":"user@example.com"}`, http.StatusBadRequest, "Password"},
		{"битый JSON", `{"email":`, http.StatusBadRequest, ""},
		{"пустое тело", ``, http.StatusBadRequest, ""},
		{"неизвестное поле", `{"email":"user@example.com","password":"password123","admin":true}`, http.StatusBadRequest, ""},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			svc := &mockAuthService{}
			h := handler.NewAuthHandler(svc)

			rr := postJSON(t, h.Register, "/api/auth/register", tt.body)

			if rr.Code != tt.wantStatus {
				t.Fatalf("status = %d; want %d. body: %s", rr.Code, tt.wantStatus, rr.Body.String())
			}

			if svc.called {
				t.Error("невалидный запрос дошёл до сервиса - валидация не сработала")
			}

			if tt.wantField != "" {
				var resp handler.ErrorResponse
				if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
					t.Fatalf("ответ не разбирается: %v", err)
				}
				if _, ok := resp.Fields[tt.wantField]; !ok {
					t.Errorf("в ответе нет поля %q; fields = %v", tt.wantField, resp.Fields)
				}
			}
		})
	}
}

func TestRegister_EmailTaken(t *testing.T) {
	svc := &mockAuthService{
		registerFn: func(context.Context, *models.CreateUserRequest) (*models.AuthResponse, error) {
			return nil, service.ErrEmailTaken
		},
	}
	h := handler.NewAuthHandler(svc)

	rr := postJSON(t, h.Register, "/api/auth/register", `{"email":"taken@example.com","password":"password123"}`)

	// 409, а не 400: сам запрос корректен, конфликтует состояние сервера.
	if rr.Code != http.StatusConflict {
		t.Errorf("status = %d; want %d", rr.Code, http.StatusConflict)
	}
}

func TestRegister_MethodNotAllowed(t *testing.T) {
	h := handler.NewAuthHandler(&mockAuthService{})

	req := httptest.NewRequest(http.MethodGet, "/api/auth/register", nil)
	rr := httptest.NewRecorder()
	h.Register(rr, req)

	if rr.Code != http.StatusMethodNotAllowed {
		t.Errorf("status = %d; want %d", rr.Code, http.StatusMethodNotAllowed)
	}
	if allow := rr.Header().Get("Allow"); allow != http.MethodPost {
		t.Errorf("Allow = %q; want %q", allow, http.MethodPost)
	}
}

func TestLogin_Success(t *testing.T) {
	h := handler.NewAuthHandler(&mockAuthService{})

	rr := postJSON(t, h.Login, "/api/auth/login", `{"email":"user@example.com","password":"password123"}`)

	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d; want %d. body: %s", rr.Code, http.StatusOK, rr.Body.String())
	}
	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		t.Errorf("Content-Type = %q; want application/json", ct)
	}
}

func TestLogin_InvalidCredentials(t *testing.T) {
	svc := &mockAuthService{
		loginFn: func(context.Context, *models.LoginRequest) (*models.AuthResponse, error) {
			return nil, service.ErrInvalidCredentials
		},
	}
	h := handler.NewAuthHandler(svc)

	rr := postJSON(t, h.Login, "/api/auth/login", `{"email":"user@example.com","password":"wrong-password"}`)

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

func TestLogin_Validation(t *testing.T) {
	svc := &mockAuthService{}
	h := handler.NewAuthHandler(svc)

	// У логина нет min=6 на пароле: старые пользователи могли завести
	// короткий пароль до введения правила, и им нужно уметь войти.
	rr := postJSON(t, h.Login, "/api/auth/login", `{"email":"bad","password":"x"}`)

	if rr.Code != http.StatusBadRequest {
		t.Errorf("status = %d; want %d", rr.Code, http.StatusBadRequest)
	}
	if svc.called {
		t.Error("невалидный запрос дошёл до сервиса")
	}
}

internal/handler/api_contract_test.go

Тесты выше проверяют один хендлер. Этот - весь HTTP-контракт: поднимает настоящий роутер через httptest.NewServer и выполняет каждый запрос из раздела «API Документация».

Postgres для этого не нужен. Хранилище подменяется реализацией в памяти - ради этого репозиторий и объявлен интерфейсом в пакете service. Сервис при этом берётся настоящий: логика частичного обновления живёт в нём, и подменять её заглушкой значило бы не проверить главное.

package handler_test

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

	"todo-api/internal/handler"
	"todo-api/internal/models"
	"todo-api/internal/repository"
	"todo-api/internal/service"
	"todo-api/pkg/token"
)

// Этот файл - исполняемая версия раздела «API Документация» из урока.
//
// Каждый документированный запрос выполняется через настоящий роутер
// и сверяется с обещанным статусом. Если кто-то поменяет маршрут, метод
// или код ответа, а документацию забудет - падает здесь, а не у читателя.

const testSecret = "test-secret-for-signing"

// --- заглушка хранилища: тесты контракта не должны требовать Postgres ---

type memTodoRepo struct {
	todos  map[int]*models.Todo
	nextID int
}

func newMemTodoRepo() *memTodoRepo {
	return &memTodoRepo{todos: map[int]*models.Todo{}, nextID: 1}
}

func (m *memTodoRepo) Create(_ context.Context, todo *models.Todo) error {
	todo.ID = m.nextID
	m.nextID++
	todo.CreatedAt = time.Now()
	todo.UpdatedAt = todo.CreatedAt
	snapshot := *todo
	m.todos[todo.ID] = &snapshot
	return nil
}

func (m *memTodoRepo) FindByID(_ context.Context, id, userID int) (*models.Todo, error) {
	todo, ok := m.todos[id]
	if !ok || todo.UserID != userID {
		return nil, repository.ErrTodoNotFound
	}
	snapshot := *todo
	return &snapshot, nil
}

func (m *memTodoRepo) FindByUserID(_ context.Context, userID int) ([]*models.Todo, error) {
	result := []*models.Todo{}
	for _, todo := range m.todos {
		if todo.UserID == userID {
			snapshot := *todo
			result = append(result, &snapshot)
		}
	}
	return result, nil
}

func (m *memTodoRepo) Update(_ context.Context, todo *models.Todo) error {
	existing, ok := m.todos[todo.ID]
	if !ok || existing.UserID != todo.UserID {
		return repository.ErrTodoNotFound
	}
	snapshot := *todo
	snapshot.UpdatedAt = time.Now()
	m.todos[todo.ID] = &snapshot
	return nil
}

func (m *memTodoRepo) Delete(_ context.Context, id, userID int) error {
	todo, ok := m.todos[id]
	if !ok || todo.UserID != userID {
		return repository.ErrTodoNotFound
	}
	delete(m.todos, id)
	return nil
}

// --- сборка тестового приложения ---

type testApp struct {
	server *httptest.Server
	repo   *memTodoRepo
	token  string
}

func newTestApp(t *testing.T) *testApp {
	t.Helper()

	repo := newMemTodoRepo()

	// Сервис берём настоящий: логика частичного обновления живёт в нём,
	// и подменять её заглушкой значило бы не проверить главное.
	// Подменяется только хранилище - ради этого репозиторий и объявлен
	// интерфейсом в пакете service.
	todoService := service.NewTodoService(repo)
	authHandler := handler.NewAuthHandler(&mockAuthService{})
	todoHandler := handler.NewTodoHandler(todoService)

	srv := httptest.NewServer(handler.NewRouter(authHandler, todoHandler, testSecret))
	t.Cleanup(srv.Close)

	userToken, err := token.Generate(1, testSecret, time.Hour)
	if err != nil {
		t.Fatalf("не удалось выпустить тестовый токен: %v", err)
	}

	return &testApp{server: srv, repo: repo, token: userToken}
}

func (a *testApp) do(t *testing.T, method, path, body string, authorized bool) (*http.Response, []byte) {
	t.Helper()

	var reader io.Reader
	if body != "" {
		reader = bytes.NewBufferString(body)
	}

	req, err := http.NewRequest(method, a.server.URL+path, reader)
	if err != nil {
		t.Fatalf("запрос не собрался: %v", err)
	}
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	if authorized {
		req.Header.Set("Authorization", "Bearer "+a.token)
	}

	resp, err := a.server.Client().Do(req)
	if err != nil {
		t.Fatalf("запрос не выполнился: %v", err)
	}
	defer resp.Body.Close()

	payload, _ := io.ReadAll(resp.Body)
	return resp, payload
}

// --- сами проверки контракта ---

func TestContract_AuthRoutes(t *testing.T) {
	app := newTestApp(t)

	t.Run("POST /api/auth/register → 201", func(t *testing.T) {
		resp, body := app.do(t, http.MethodPost, "/api/auth/register",
			`{"email":"user@example.com","password":"password123"}`, false)
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("status = %d; want 201. body: %s", resp.StatusCode, body)
		}
	})

	t.Run("POST /api/auth/login → 200", func(t *testing.T) {
		resp, body := app.do(t, http.MethodPost, "/api/auth/login",
			`{"email":"user@example.com","password":"password123"}`, false)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("status = %d; want 200. body: %s", resp.StatusCode, body)
		}
	})

	t.Run("регистрация не требует токена", func(t *testing.T) {
		resp, _ := app.do(t, http.MethodPost, "/api/auth/register",
			`{"email":"another@example.com","password":"password123"}`, false)
		if resp.StatusCode == http.StatusUnauthorized {
			t.Error("публичный маршрут потребовал авторизацию")
		}
	})
}

func TestContract_TodosRequireAuth(t *testing.T) {
	app := newTestApp(t)

	cases := []struct{ method, path string }{
		{http.MethodGet, "/api/todos"},
		{http.MethodPost, "/api/todos"},
		{http.MethodGet, "/api/todos/1"},
		{http.MethodPut, "/api/todos/1"},
		{http.MethodDelete, "/api/todos/1"},
	}

	for _, c := range cases {
		t.Run(c.method+" "+c.path, func(t *testing.T) {
			resp, _ := app.do(t, c.method, c.path, `{}`, false)
			if resp.StatusCode != http.StatusUnauthorized {
				t.Errorf("без токена status = %d; want 401", resp.StatusCode)
			}
		})
	}
}

func TestContract_TodoCRUD(t *testing.T) {
	app := newTestApp(t)

	// POST /api/todos → 201
	resp, body := app.do(t, http.MethodPost, "/api/todos",
		`{"title":"Купить хлеб","description":"в булочной у дома"}`, true)
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("POST /api/todos status = %d; want 201. body: %s", resp.StatusCode, body)
	}

	var created models.Todo
	if err := json.Unmarshal(body, &created); err != nil {
		t.Fatalf("ответ создания не разбирается: %v", err)
	}
	if created.ID == 0 {
		t.Fatal("в ответе создания нет id")
	}
	if created.Title != "Купить хлеб" {
		t.Errorf("title = %q; want %q", created.Title, "Купить хлеб")
	}

	// GET /api/todos → 200, список из одной задачи
	resp, body = app.do(t, http.MethodGet, "/api/todos", "", true)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("GET /api/todos status = %d; want 200", resp.StatusCode)
	}
	var list []models.Todo
	if err := json.Unmarshal(body, &list); err != nil {
		t.Fatalf("список не разбирается: %v. body: %s", err, body)
	}
	if len(list) != 1 {
		t.Errorf("в списке %d задач; want 1", len(list))
	}

	// GET /api/todos/{id} → 200
	resp, body = app.do(t, http.MethodGet, "/api/todos/1", "", true)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("GET /api/todos/1 status = %d; want 200. body: %s", resp.StatusCode, body)
	}

	// PUT /api/todos/{id} → 200
	resp, body = app.do(t, http.MethodPut, "/api/todos/1", `{"completed":true}`, true)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("PUT /api/todos/1 status = %d; want 200. body: %s", resp.StatusCode, body)
	}

	var updated models.Todo
	if err := json.Unmarshal(body, &updated); err != nil {
		t.Fatalf("ответ обновления не разбирается: %v", err)
	}
	if !updated.Completed {
		t.Error("completed не выставился")
	}
	// Ради этой строчки и переписывалось обновление: раньше SQL писал все
	// колонки, и частичный запрос затирал title пустой строкой.
	if updated.Title != "Купить хлеб" {
		t.Errorf("частичное обновление затёрло title: %q", updated.Title)
	}

	// DELETE /api/todos/{id} → 204
	resp, _ = app.do(t, http.MethodDelete, "/api/todos/1", "", true)
	if resp.StatusCode != http.StatusNoContent {
		t.Fatalf("DELETE /api/todos/1 status = %d; want 204", resp.StatusCode)
	}

	// Повторное удаление → 404
	resp, _ = app.do(t, http.MethodDelete, "/api/todos/1", "", true)
	if resp.StatusCode != http.StatusNotFound {
		t.Errorf("повторный DELETE status = %d; want 404", resp.StatusCode)
	}
}

func TestContract_EmptyListIsArray(t *testing.T) {
	app := newTestApp(t)

	_, body := app.do(t, http.MethodGet, "/api/todos", "", true)

	// nil-срез сериализуется в `null`, и клиенту приходится обрабатывать
	// два вида «пусто». Ответ обязан быть массивом.
	if string(bytes.TrimSpace(body)) != "[]" {
		t.Errorf("пустой список = %s; want []", body)
	}
}

func TestContract_MethodNotAllowed(t *testing.T) {
	app := newTestApp(t)

	t.Run("PATCH /api/todos → 405 + Allow", func(t *testing.T) {
		resp, _ := app.do(t, http.MethodPatch, "/api/todos", `{}`, true)
		if resp.StatusCode != http.StatusMethodNotAllowed {
			t.Fatalf("status = %d; want 405", resp.StatusCode)
		}
		if allow := resp.Header.Get("Allow"); allow != "GET, POST" {
			t.Errorf("Allow = %q; want %q", allow, "GET, POST")
		}
	})

	t.Run("PATCH /api/todos/1 → 405 + Allow", func(t *testing.T) {
		resp, _ := app.do(t, http.MethodPatch, "/api/todos/1", `{}`, true)
		if resp.StatusCode != http.StatusMethodNotAllowed {
			t.Fatalf("status = %d; want 405", resp.StatusCode)
		}
		if allow := resp.Header.Get("Allow"); allow != "GET, PUT, DELETE" {
			t.Errorf("Allow = %q; want %q", allow, "GET, PUT, DELETE")
		}
	})
}

func TestContract_BadRequests(t *testing.T) {
	app := newTestApp(t)

	cases := []struct {
		name       string
		method     string
		path       string
		body       string
		wantStatus int
	}{
		{"пустой title", http.MethodPost, "/api/todos", `{"title":""}`, http.StatusBadRequest},
		{"нет title", http.MethodPost, "/api/todos", `{"description":"только описание"}`, http.StatusBadRequest},
		{"битый JSON", http.MethodPost, "/api/todos", `{"title":`, http.StatusBadRequest},
		{"id не число", http.MethodGet, "/api/todos/abc", "", http.StatusBadRequest},
		{"id отрицательный", http.MethodGet, "/api/todos/-1", "", http.StatusBadRequest},
		{"несуществующий id", http.MethodGet, "/api/todos/999", "", http.StatusNotFound},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			resp, body := app.do(t, c.method, c.path, c.body, true)
			if resp.StatusCode != c.wantStatus {
				t.Errorf("status = %d; want %d. body: %s", resp.StatusCode, c.wantStatus, body)
			}
		})
	}
}

func TestContract_ForeignTodoIsNotFound(t *testing.T) {
	app := newTestApp(t)

	// Задача чужого пользователя
	app.repo.todos[42] = &models.Todo{ID: 42, UserID: 999, Title: "Чужая задача"}

	// 404, а не 403: существование чужой задачи - тоже информация,
	// и подтверждать её незачем.
	for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} {
		resp, _ := app.do(t, method, "/api/todos/42", `{"completed":true}`, true)
		if resp.StatusCode != http.StatusNotFound {
			t.Errorf("%s чужой задачи: status = %d; want 404", method, resp.StatusCode)
		}
	}
}

func TestContract_InvalidToken(t *testing.T) {
	app := newTestApp(t)

	req, _ := http.NewRequest(http.MethodGet, app.server.URL+"/api/todos", nil)
	req.Header.Set("Authorization", "Bearer явно-не-токен")
	resp, err := app.server.Client().Do(req)
	if err != nil {
		t.Fatalf("запрос не выполнился: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusUnauthorized {
		t.Errorf("status = %d; want 401", resp.StatusCode)
	}
}

Такой тест ловит то, что не поймает ни один линтер: маршрут переименовали, метод перепутали, статус поменяли с 200 на 204. Документация в уроке перестаёт быть обещанием и становится проверяемым утверждением.

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

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

# Скопируй в .env и заполни. Сам .env в git не коммитим - он в .gitignore.
PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/tododb?sslmode=disable

# Пустой намеренно: заполненный секрет из примера кто-нибудь скопирует как есть.
# Сгенерируй свой: openssl rand -base64 32
JWT_SECRET=

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

Makefile

.PHONY: build run test lint migrate dev tidy

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

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

run: build
	./bin/api

dev:
	go run ./cmd/api

test:
	go test ./... -count=1

# Полный набор проверок - то же самое гоняет CI.
lint:
	gofmt -l .
	go vet ./...

tidy:
	go mod tidy

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

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

Таблица ниже - не описание намерений, а то, что проверяет тест internal/handler/api_contract_test.go. Он поднимает настоящий роутер и выполняет каждый запрос из этого раздела. Если кто-то поменяет маршрут или код ответа, а документацию забудет, упадёт сборка, а не читатель.

Endpoints

Авторизация (токен не нужен):

МетодПутьУспехОшибки
POST/api/auth/register201 Created400 невалидные поля · 409 email занят
POST/api/auth/login200 OK400 невалидные поля · 401 неверные данные

Задачи (нужен заголовок Authorization: Bearer <token>, иначе 401):

МетодПутьУспехОшибки
GET/api/todos200 OK-
POST/api/todos201 Created400 невалидные поля
GET/api/todos/{id}200 OK400 кривой id · 404 не найдена
PUT/api/todos/{id}200 OK400 кривой id или поля · 404 не найдена
DELETE/api/todos/{id}204 No Content400 кривой id · 404 не найдена
Запрос к задаче другого пользователя возвращает `404`, хотя технически задача существует. `403 Forbidden` подтвердил бы факт её существования - а это тоже информация, и делиться ей незачем.

Проверка владельца стоит в самом SQL-запросе (WHERE id = $1 AND user_id = $2), а не после выборки. Забыть условие в запросе труднее, чем забыть сравнение в коде на пять строк ниже.

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

Каждая команда отрабатывает на буквальном копировании. Сначала регистрируемся и сохраняем токен:

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

# Вход → 200, тот же формат ответа
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
 -H "Content-Type: application/json" \
 -d '{"email":"user@example.com","password":"password123"}' \
 | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')

Дальше все запросы идут с этим токеном:

# Создание задачи → 201, в ответе созданная задача с id
curl -X POST http://localhost:8080/api/todos \
 -H "Authorization: Bearer $TOKEN" \
 -H "Content-Type: application/json" \
 -d '{"title":"Выучить Go","description":"пройти курс до конца"}'

# Список задач → 200, массив (пустой список это [], не null)
curl -X GET http://localhost:8080/api/todos \
 -H "Authorization: Bearer $TOKEN"

# Одна задача → 200
curl -X GET http://localhost:8080/api/todos/1 \
 -H "Authorization: Bearer $TOKEN"

# Обновление → 200, в ответе задача целиком.
# Присылаем только completed - title и description сохраняются:
# семантика тут ближе к PATCH, см. врезку ниже.
curl -X PUT http://localhost:8080/api/todos/1 \
 -H "Authorization: Bearer $TOKEN" \
 -H "Content-Type: application/json" \
 -d '{"completed":true}'

# Удаление → 204, тело пустое
curl -X DELETE http://localhost:8080/api/todos/1 \
 -H "Authorization: Bearer $TOKEN"
По [семантике методов](../general/04-api-design.md) `PUT` - это полная замена ресурса: непереданные поля должны обнулиться. Здесь не так - `UpdateTodoRequest` собран на указателях, чтобы отличить «поле не прислали» от «прислали пустое», и непереданное остаётся как было. То есть по поведению это `PATCH`, а по методу - `PUT`.

Так сделано осознанно, чтобы у API была одна операция обновления вместо двух, но цена есть: клиент, который следует стандарту и ждёт от PUT replace-семантику, получит не то, что заказывал. PATCH при этом отдаёт 405 со списком разрешённых методов.

Как поступить в своём проекте - выбор из двух честных вариантов:

  • оставить один метод, но назвать его PATCH и написать в документации, что полной замены нет;
  • сделать оба: PUT заменяет ресурс целиком и требует все поля, PATCH меняет присланные.

Чего делать не стоит - молча оставлять PUT с частичной семантикой и не упоминать об этом: разойдётся ожидание клиента с поведением сервера, и выяснится это на данных.

Что происходит при ошибке

Ответ всегда JSON, даже когда ошибка. Смешивать форматы («успех - JSON, ошибка - текст от http.Error») означает заставлять клиента угадывать, что ему пришло.

curl -X POST http://localhost:8080/api/auth/register \
 -H "Content-Type: application/json" \
 -d '{"email":"не-email","password":"123"}'
{
  "error": "проверьте поля запроса",
  "fields": {
    "Email": "некорректный email",
    "Password": "минимальная длина: 6"
  }
}

Проверь на своём проекте: если этот запрос вернул 201, значит валидатор не вызывается, а теги validate:"..." на структурах - просто строки в метаданных типа. Об этом дальше.

Мини-задание

  • Прогони полный набор перед сдачей: gofmt -l ., go vet ./..., go test -race -cover ./...
  • Собери бинарник и запусти его в контейнере alpine. Если падает - разберись с CGO
  • Проверь, что секреты читаются из окружения, а не лежат в коде: grep -rn "password\|secret" --include=*.go .
  • Сымитируй недоступную базу и убедись, что сервис отвечает понятной ошибкой, а не паникует
  • Дай проект другому человеку и попроси поднять его по твоему README, ничего не спрашивая. Всё, что он спросит, надо дописать

Итоги

Мы создали полноценное 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
Резервный ключ подписиодин JWT_SECRETротация ключей: смена секрета сейчас разлогинивает всех разом

Список не абстрактный: каждый пункт - причина реального инцидента у кого-то, кто выкатил учебный проект в интернет. Пройди его прежде, чем показывать свой API кому-то кроме себя.

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

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

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

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

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

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