Проект: TODO API с авторизацией
Применим все изученное, создав REST API для управления задачами с JWT авторизацией. По ходу будем опираться на уроки про HTTP, базы данных, интерфейсы, обработку ошибок и тестирование.
Структура проекта
todo-api/
├── cmd/
│ └── api/
│ └── main.go # Точка входа
├── internal/
│ ├── config/
│ │ └── config.go # Конфигурация
│ ├── models/
│ │ ├── user.go # Модель пользователя
│ │ └── todo.go # Модель задачи
│ ├── repository/
│ │ ├── user_repo.go # Репозиторий пользователей
│ │ └── todo_repo.go # Репозиторий задач
│ ├── service/
│ │ ├── auth.go # Сервис авторизации
│ │ └── todo.go # Бизнес-логика задач
│ ├── handler/
│ │ ├── auth.go # HTTP handlers авторизации
│ │ ├── todo.go # HTTP handlers задач
│ │ └── middleware.go # Middleware
│ └── database/
│ └── postgres.go # Подключение к БД
├── migrations/
│ ├── 001_create_users.sql
│ └── 002_create_todos.sql
├── pkg/
│ ├── hash/
│ │ └── password.go # Хеширование паролей
│ └── token/
│ └── jwt.go # Работа с JWT
├── go.mod
├── go.sum
├── Makefile
└── README.md
Модели данных
Модели - это обычные структуры с тегами для JSON и БД.
internal/models/user.go
package models
import (
"time"
)
type User struct {
ID int `json:"id" db:"id"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
User *User `json:"user"`
}
internal/models/todo.go
package models
import (
"time"
)
type Todo struct {
ID int `json:"id" db:"id"`
UserID int `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Description string `json:"description" db:"description"`
Completed bool `json:"completed" db:"completed"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CreateTodoRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Description string `json:"description" validate:"max=1000"`
}
type UpdateTodoRequest struct {
Title *string `json:"title" validate:"omitempty,min=1,max=200"`
Description *string `json:"description" validate:"omitempty,max=1000"`
Completed *bool `json:"completed"`
}
База данных
migrations/001_create_users.sql
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
migrations/002_create_todos.sql
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_todos_user_id ON todos(user_id);
internal/database/postgres.go
package database
import (
"database/sql"
"fmt"
"time"
_ "github.com/lib/pq"
)
type DB struct {
*sql.DB
}
// New подключается к базе по строке из конфигурации.
//
// Принимаем готовый DSN, а не пять отдельных параметров: тогда невозможно
// случайно подключиться не туда, потому что подключаться просто некуда,
// кроме как по тому, что пришло из окружения. Логин и пароль в сигнатуре
// функции - это приглашение подставить их прямо в вызове и забыть.
//
// В строке подключения для локальной разработки будет `sslmode=disable`.
// Для прода это надо менять: см. чеклист в конце урока.
func New(dsn string) (*DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
// Настройка пула соединений
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return &DB{db}, nil
}
func (db *DB) Migrate() error {
// Простая миграция для примера
migrations := []string{
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
}
for _, migration := range migrations {
if _, err := db.Exec(migration); err != nil {
return fmt.Errorf("execute migration: %w", err)
}
}
return nil
}
Репозитории
internal/repository/user_repo.go
package repository
import (
"context"
"database/sql"
"fmt"
"todo-api/internal/models"
)
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, user *models.User) error {
query := `
INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, created_at, updated_at
`
err := r.db.QueryRowContext(
ctx,
query,
user.Email,
user.PasswordHash,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) {
user := &models.User{}
query := `
SELECT id, email, password_hash, created_at, updated_at
FROM users
WHERE email = $1
`
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.Email,
&user.PasswordHash,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("find user by email: %w", err)
}
return user, nil
}
var ErrUserNotFound = fmt.Errorf("user not found")
internal/repository/todo_repo.go
package repository
import (
"context"
"database/sql"
"fmt"
"todo-api/internal/models"
)
type TodoRepository struct {
db *sql.DB
}
func NewTodoRepository(db *sql.DB) *TodoRepository {
return &TodoRepository{db: db}
}
func (r *TodoRepository) Create(ctx context.Context, todo *models.Todo) error {
query := `
INSERT INTO todos (user_id, title, description)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`
err := r.db.QueryRowContext(
ctx,
query,
todo.UserID,
todo.Title,
todo.Description,
).Scan(&todo.ID, &todo.CreatedAt, &todo.UpdatedAt)
if err != nil {
return fmt.Errorf("create todo: %w", err)
}
return nil
}
func (r *TodoRepository) FindByUserID(ctx context.Context, userID int) ([]*models.Todo, error) {
query := `
SELECT id, user_id, title, description, completed, created_at, updated_at
FROM todos
WHERE user_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("find todos: %w", err)
}
defer rows.Close()
var todos []*models.Todo
for rows.Next() {
todo := &models.Todo{}
err := rows.Scan(
&todo.ID,
&todo.UserID,
&todo.Title,
&todo.Description,
&todo.Completed,
&todo.CreatedAt,
&todo.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("scan todo: %w", err)
}
todos = append(todos, todo)
}
return todos, nil
}
func (r *TodoRepository) Update(ctx context.Context, todo *models.Todo) error {
query := `
UPDATE todos
SET title = $1, description = $2, completed = $3, updated_at = CURRENT_TIMESTAMP
WHERE id = $4 AND user_id = $5
`
result, err := r.db.ExecContext(
ctx,
query,
todo.Title,
todo.Description,
todo.Completed,
todo.ID,
todo.UserID,
)
if err != nil {
return fmt.Errorf("update todo: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return ErrTodoNotFound
}
return nil
}
func (r *TodoRepository) Delete(ctx context.Context, id, userID int) error {
query := `DELETE FROM todos WHERE id = $1 AND user_id = $2`
result, err := r.db.ExecContext(ctx, query, id, userID)
if err != nil {
return fmt.Errorf("delete todo: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return ErrTodoNotFound
}
return nil
}
var ErrTodoNotFound = fmt.Errorf("todo not found")
Сервисы
pkg/hash/password.go
package hash
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("generate password hash: %w", err)
}
return string(bytes), nil
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
pkg/token/jwt.go
package token
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int `json:"user_id"`
jwt.RegisteredClaims
}
func Generate(userID int, secret string, expiry time.Duration) (string, error) {
claims := &Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func Parse(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
internal/service/auth.go
package service
import (
"context"
"fmt"
"time"
"todo-api/internal/models"
"todo-api/internal/repository"
"todo-api/pkg/hash"
"todo-api/pkg/token"
)
type AuthService struct {
userRepo *repository.UserRepository
jwtSecret string
jwtExpiry time.Duration
}
func NewAuthService(userRepo *repository.UserRepository, jwtSecret string) *AuthService {
return &AuthService{
userRepo: userRepo,
jwtSecret: jwtSecret,
jwtExpiry: 24 * time.Hour,
}
}
func (s *AuthService) Register(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error) {
// Проверяем, существует ли пользователь
existing, _ := s.userRepo.FindByEmail(ctx, req.Email)
if existing != nil {
return nil, fmt.Errorf("email already registered")
}
// Хешируем пароль
passwordHash, err := hash.HashPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
// Создаем пользователя
user := &models.User{
Email: req.Email,
PasswordHash: passwordHash,
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
// Генерируем токен
tokenString, err := token.Generate(user.ID, s.jwtSecret, s.jwtExpiry)
if err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
return &models.AuthResponse{
Token: tokenString,
User: user,
}, nil
}
func (s *AuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error) {
// Находим пользователя
user, err := s.userRepo.FindByEmail(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("invalid credentials")
}
// Проверяем пароль
if !hash.CheckPassword(req.Password, user.PasswordHash) {
return nil, fmt.Errorf("invalid credentials")
}
// Генерируем токен
tokenString, err := token.Generate(user.ID, s.jwtSecret, s.jwtExpiry)
if err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
return &models.AuthResponse{
Token: tokenString,
User: user,
}, nil
}
HTTP Handlers
internal/handler/middleware.go
package handler
import (
"context"
"log"
"net/http"
"strings"
"time"
"todo-api/pkg/token"
)
type contextKey string
const userIDKey contextKey = "userID"
func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Извлекаем токен из заголовка
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Проверяем формат: Bearer <token>
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
return
}
// Парсим токен
claims, err := token.Parse(parts[1], jwtSecret)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// Добавляем userID в контекст
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUserID(ctx context.Context) int {
userID, _ := ctx.Value(userIDKey).(int)
return userID
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
// CORSMiddleware разрешает запросы с любого домена.
//
// `*` годится для локальной разработки и НЕ годится для прода: с ним твой API
// может дёргать любой сайт, на который зайдёт твой пользователь. Правда, браузер
// не отправит с `*` куки и не отдаст ответ, если запрос шёл с `credentials`, -
// это единственное, что здесь спасает. Как только появится авторизация по кукам,
// придётся перейти на список конкретных доменов, и лучше сделать это заранее.
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
internal/handler/auth.go
package handler
import (
"context"
"encoding/json"
"net/http"
"todo-api/internal/models"
)
// AuthService - то, что нужно хендлеру от сервиса, и ничего больше.
//
// Интерфейс объявлен здесь, а не в пакете service, и это принципиально:
// потребитель описывает свои требования, поставщик про них не знает.
// `*service.AuthService` удовлетворяет этому интерфейсу автоматически,
// импортировать пакет service хендлеру больше не нужно.
//
// Практическая польза: в тесте вместо настоящего сервиса подставляется
// заглушка, и хендлер проверяется без базы, без bcrypt и без JWT.
// Если бы конструктор принимал конкретный `*service.AuthService`,
// подменить его было бы нечем - пришлось бы поднимать базу ради проверки
// того, что на кривой JSON возвращается 400.
type AuthService interface {
Register(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error)
Login(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error)
}
type AuthHandler struct {
authService AuthService
}
func NewAuthHandler(authService AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req models.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
resp, err := h.authService.Register(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req models.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
resp, err := h.authService.Login(r.Context(), &req)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
internal/handler/todo.go
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"todo-api/internal/models"
"todo-api/internal/repository"
)
type TodoHandler struct {
todoRepo *repository.TodoRepository
}
func NewTodoHandler(todoRepo *repository.TodoRepository) *TodoHandler {
return &TodoHandler{todoRepo: todoRepo}
}
func (h *TodoHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := GetUserID(r.Context())
todos, err := h.todoRepo.FindByUserID(r.Context(), userID)
if err != nil {
http.Error(w, "Failed to fetch todos", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req models.CreateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
todo := &models.Todo{
UserID: GetUserID(r.Context()),
Title: req.Title,
Description: req.Description,
}
if err := h.todoRepo.Create(r.Context(), todo); err != nil {
http.Error(w, "Failed to create todo", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(todo)
}
func (h *TodoHandler) Update(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Извлекаем ID из пути
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 3 {
http.Error(w, "Invalid URL", http.StatusBadRequest)
return
}
todoID, err := strconv.Atoi(pathParts[len(pathParts)-1])
if err != nil {
http.Error(w, "Invalid todo ID", http.StatusBadRequest)
return
}
var req models.UpdateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Обновляем todo
todo := &models.Todo{
ID: todoID,
UserID: GetUserID(r.Context()),
}
if req.Title != nil {
todo.Title = *req.Title
}
if req.Description != nil {
todo.Description = *req.Description
}
if req.Completed != nil {
todo.Completed = *req.Completed
}
if err := h.todoRepo.Update(r.Context(), todo); err != nil {
if err == repository.ErrTodoNotFound {
http.Error(w, "Todo not found", http.StatusNotFound)
} else {
http.Error(w, "Failed to update todo", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TodoHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Извлекаем ID из пути
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 3 {
http.Error(w, "Invalid URL", http.StatusBadRequest)
return
}
todoID, err := strconv.Atoi(pathParts[len(pathParts)-1])
if err != nil {
http.Error(w, "Invalid todo ID", http.StatusBadRequest)
return
}
userID := GetUserID(r.Context())
if err := h.todoRepo.Delete(r.Context(), todoID, userID); err != nil {
if err == repository.ErrTodoNotFound {
http.Error(w, "Todo not found", http.StatusNotFound)
} else {
http.Error(w, "Failed to delete todo", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
Main и конфигурация
internal/config/config.go
package config
import (
"fmt"
"os"
)
type Config struct {
Port string
DatabaseURL string
JWTSecret string
}
// Load читает конфигурацию из окружения.
//
// Обрати внимание на разницу между двумя видами значений.
// У порта есть разумный дефолт: не задан - берём 8080, ничего страшного.
// У секрета дефолта быть не может.
func Load() (*Config, error) {
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
return nil, fmt.Errorf("JWT_SECRET не задан")
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL не задан")
}
return &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: dbURL,
JWTSecret: jwtSecret,
}, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
Цена такого удобства: приложение молча поднимется на проде с секретом, который знает каждый, кто читал этот урок. Любой сможет подписать себе токен и войти под чужим аккаунтом. Ошибки в логах не будет - всё «работает».
Падение на старте с внятным сообщением лучше тихой дыры. Приложение, которое не запустилось, чинят за минуту; приложение, которое запустилось с дефолтным секретом, обнаруживают после инцидента.
То же и с DATABASE_URL: дефолт postgres://user:password@localhost/...
приведёт к тому, что на проде сервис будет ходить не туда, куда вы думаете.
cmd/api/main.go
package main
import (
"log"
"net/http"
"os"
"os/signal"
"context"
"syscall"
"time"
"todo-api/internal/config"
"todo-api/internal/database"
"todo-api/internal/handler"
"todo-api/internal/repository"
"todo-api/internal/service"
)
func main() {
// Загружаем конфигурацию. Не хватает обязательной переменной -
// падаем сразу и с внятным текстом, а не позже и загадочно.
cfg, err := config.Load()
if err != nil {
log.Fatal("config: ", err)
}
// Подключаемся к БД по строке из конфигурации.
// Не по захардкоженным параметрам: иначе DATABASE_URL из окружения
// читается, но никуда не идёт, и на проде сервис ходит не туда.
db, err := database.New(cfg.DatabaseURL)
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer db.Close()
// Выполняем миграции
if err := db.Migrate(); err != nil {
log.Fatal("Failed to run migrations:", err)
}
// Создаем репозитории
userRepo := repository.NewUserRepository(db.DB)
todoRepo := repository.NewTodoRepository(db.DB)
// Создаем сервисы
authService := service.NewAuthService(userRepo, cfg.JWTSecret)
// Создаем handlers
authHandler := handler.NewAuthHandler(authService)
todoHandler := handler.NewTodoHandler(todoRepo)
// Настраиваем роуты
mux := http.NewServeMux()
// Публичные роуты
mux.HandleFunc("/api/auth/register", authHandler.Register)
mux.HandleFunc("/api/auth/login", authHandler.Login)
// Защищенные роуты
//
// Коллекция и элемент - два разных ресурса, поэтому и роута два:
// /api/todos - вся коллекция: получить список, создать новую задачу
// /api/todos/N - конкретная задача: обновить, удалить
//
// В http.ServeMux путь без слэша на конце - точное совпадение, со слэшем -
// поддерево. Если повесить создание на "/api/todos/", то POST на
// "/api/todos" попадёт в точный роут (список) и вернёт 405. Метод
// и путь надо разводить осознанно, иначе клиент получает ошибку там,
// где по документации всё должно работать.
authMux := http.NewServeMux()
authMux.HandleFunc("/api/todos", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
todoHandler.List(w, r)
case http.MethodPost:
todoHandler.Create(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
authMux.HandleFunc("/api/todos/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
todoHandler.Update(w, r)
case http.MethodDelete:
todoHandler.Delete(w, r)
default:
w.Header().Set("Allow", "PUT, DELETE")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Применяем middleware
authMiddleware := handler.AuthMiddleware(cfg.JWTSecret)
mux.Handle("/api/todos", authMiddleware(authMux))
mux.Handle("/api/todos/", authMiddleware(authMux))
// Глобальные middleware
h := handler.CORSMiddleware(handler.LoggingMiddleware(mux))
// Создаем сервер
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: h,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Запускаем сервер
go func() {
log.Printf("Server starting on port %s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Failed to start server:", err)
}
}()
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exited")
}
Тестирование
internal/handler/auth_test.go
Заглушка реализует тот же интерфейс handler.AuthService, что и настоящий
сервис. Компилятор проверит это за нас: если сигнатура разъедется, тест
не соберётся.
package handler_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"todo-api/internal/handler"
"todo-api/internal/models"
)
// mockAuthService - заглушка вместо настоящего сервиса.
// Поля-функции позволяют задать поведение прямо в тесте,
// не заводя отдельный тип на каждый сценарий.
type mockAuthService struct {
RegisterFunc func(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error)
LoginFunc func(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error)
}
func (m *mockAuthService) Register(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error) {
return m.RegisterFunc(ctx, req)
}
func (m *mockAuthService) Login(ctx context.Context, req *models.LoginRequest) (*models.AuthResponse, error) {
return m.LoginFunc(ctx, req)
}
// Проверка на этапе компиляции, что заглушка действительно
// удовлетворяет интерфейсу. Стоит ноль, ловит расхождение сразу.
var _ handler.AuthService = (*mockAuthService)(nil)
func TestAuthHandler_Register(t *testing.T) {
mockService := &mockAuthService{
RegisterFunc: func(ctx context.Context, req *models.CreateUserRequest) (*models.AuthResponse, error) {
if req.Email == "exists@example.com" {
return nil, fmt.Errorf("email already registered")
}
return &models.AuthResponse{
Token: "test-token",
User: &models.User{
ID: 1,
Email: req.Email,
},
}, nil
},
}
// Имя переменной не `handler`: оно затенило бы одноимённый пакет,
// и обратиться к `handler.AuthService` в этой же функции стало бы нельзя.
authHandler := handler.NewAuthHandler(mockService)
tests := []struct {
name string
body interface{}
wantStatus int
}{
{
name: "successful registration",
body: models.CreateUserRequest{
Email: "new@example.com",
Password: "password123",
},
wantStatus: http.StatusCreated,
},
{
name: "email already exists",
body: models.CreateUserRequest{
Email: "exists@example.com",
Password: "password123",
},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid body",
body: "invalid",
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
authHandler.Register(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("status = %d; want %d", rr.Code, tt.wantStatus)
}
})
}
}
Переменные окружения
config.Load() падает без JWT_SECRET и DATABASE_URL, поэтому просто
go run уже не сработает - и это правильно. Кладём рядом файл .env.example,
который коммитим (он показывает, какие переменные нужны), и .env,
который добавляем в .gitignore (в нём настоящие значения).
# .env.example
PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/tododb?sslmode=disable
JWT_SECRET= # сгенерируй свой: openssl rand -base64 32
Пустой JWT_SECRET в примере - намеренно. Заполненный кто-нибудь скопирует
как есть, и секрет из репозитория уедет на прод.
Makefile
.PHONY: build run test clean migrate dev
# Подхватываем .env, если он есть: локально удобно, в CI переменные придут
# из окружения и файл не понадобится.
ifneq (,$(wildcard .env))
include .env
export
endif
build:
go build -o bin/api cmd/api/main.go
run: build
./bin/api
test:
go test -v -cover ./...
clean:
rm -rf bin/
# DSN берём из окружения, а не пишем в Makefile: иначе миграции поедут
# в одну базу, а приложение - в другую, и рассинхрон вылезет не сразу.
migrate:
migrate -path migrations -database "$(DATABASE_URL)" up
dev:
go run cmd/api/main.go
docker-build:
docker build -t todo-api .
docker-run:
docker run -p 8080:8080 todo-api
API Документация
Endpoints
Авторизация:
POST /api/auth/register- РегистрацияPOST /api/auth/login- Вход
Todos (требуется авторизация):
GET /api/todos- Список задачPOST /api/todos- Создать задачуPUT /api/todos/{id}- Обновить задачуDELETE /api/todos/{id}- Удалить задачу
Примеры запросов
# Регистрация
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
# Вход
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
# Создание задачи
curl -X POST http://localhost:8080/api/todos \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Learn Go","description":"Complete the Go course"}'
# Получение списка задач
curl -X GET http://localhost:8080/api/todos \
-H "Authorization: Bearer YOUR_TOKEN"
# Обновление задачи (id в пути)
curl -X PUT http://localhost:8080/api/todos/1 \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Learn Go","completed":true}'
# Удаление задачи
curl -X DELETE http://localhost:8080/api/todos/1 \
-H "Authorization: Bearer YOUR_TOKEN"
Каждая из этих команд должна отработать на буквальном копировании. Если
что-то возвращает 405 Method Not Allowed - значит метод ушёл не в тот роут,
и стоит перечитать раздел про http.ServeMux выше: путь без слэша на конце
это точное совпадение, со слэшем - поддерево.
Итоги
Мы создали полноценное REST API приложение с:
- JWT авторизацией
- CRUD операциями
- Чистой архитектурой
- Тестами
- Graceful shutdown
- Middleware pipeline
Это приближенная к продакшену структура: разделение на слои, внедрение зависимостей через интерфейсы, конфигурация из окружения, корректное завершение. Такую архитектуру можно расширять и масштабировать.
Что обязательно доделать перед продом
Слово «production-ready» в туториалах обычно означает «структура правильная». Между правильной структурой и сервисом, которому можно доверить чужие данные, лежит ещё вот это:
| Что | Сейчас в проекте | Что нужно |
|---|---|---|
| TLS у базы | sslmode=disable в строке подключения | sslmode=verify-full и сертификат |
| CORS | Access-Control-Allow-Origin: * | список конкретных доменов |
| Секреты | из окружения, но лежат в .env рядом с кодом | хранилище секретов, ротация, .env в .gitignore |
| Rate limiting | нет | лимит на попытки логина, иначе пароли переберут |
| Логи | log.Printf в stdout | структурные логи (log/slog), без токенов и паролей внутри |
| Метрики и трейсы | нет | хотя бы /metrics и время ответа по ручкам |
| Миграции | выполняются при старте приложения | отдельный шаг деплоя, иначе два инстанса стартуют одновременно |
| Бэкапы | нет | расписание и проверенное восстановление |
| Заголовки безопасности | нет | HSTS, X-Content-Type-Options, CSP |
| Валидация запросов | теги validate:"..." расставлены | вызов валидатора: теги сами по себе ничего не проверяют |
Последний пункт стоит проверить прямо сейчас: найди в коде место, где вызывается
валидатор. Если такого места нет - значит validate:"required,email" на структуре
это просто комментарий, и пустой email спокойно доедет до базы.
🎉 Поздравляю с завершением курса Go!
Типичная ошибка
Пытаться «сразу написать всё». Лучше сначала минимальный скелет, потом улучшения.
Мини-практика
Собери мини‑CLI «таск‑лист»: добавить задачу, показать список, отметить выполненной (можно в памяти или в файле JSON).