Проект: 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
Так сделано после того, как в прошлой версии урока накопились пропущенные
импорты, несовместимые сигнатуры и роутинг, при котором опубликованный
в этом же уроке 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
Задачам тоже нужен сервис, а не прямой поход хендлера в репозиторий. Причина не в симметрии со слоем авторизации, а в конкретной логике, которой негде больше жить: частичное обновление.
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
}
Цена такого удобства: приложение молча поднимется на проде с секретом, который знает каждый, кто читал этот урок. Любой сможет подписать себе токен и войти под чужим аккаунтом. Ошибки в логах не будет - всё «работает».
Падение на старте с внятным сообщением лучше тихой дыры. Приложение, которое не запустилось, чинят за минуту; приложение, которое запустилось с дефолтным секретом, обнаруживают после инцидента.
То же и с 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/register | 201 Created | 400 невалидные поля · 409 email занят |
| POST | /api/auth/login | 200 OK | 400 невалидные поля · 401 неверные данные |
Задачи (нужен заголовок Authorization: Bearer <token>, иначе 401):
| Метод | Путь | Успех | Ошибки |
|---|---|---|---|
| GET | /api/todos | 200 OK | - |
| POST | /api/todos | 201 Created | 400 невалидные поля |
| GET | /api/todos/{id} | 200 OK | 400 кривой id · 404 не найдена |
| PUT | /api/todos/{id} | 200 OK | 400 кривой id или поля · 404 не найдена |
| DELETE | /api/todos/{id} | 204 No Content | 400 кривой id · 404 не найдена |
Проверка владельца стоит в самом 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"
Так сделано осознанно, чтобы у 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 и сертификат |
| CORS | Access-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).