Урок 43 (бонус): Production-ready REST API для заметок на go-chi

Урок 43 (бонус). Production-ready REST API для заметок (Notes)

🔄 Node.js/Express → Go/chi (архитектурные аналогии):

📋 Что изучаем


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

notes-api/
├── cmd/
│   └── server/
│       └── main.go           # Точка входа
├── internal/
│   ├── config/
│   │   └── config.go         # Конфигурация (env, defaults)
│   ├── domain/
│   │   ├── note.go           # Модель Note
│   │   ├── user.go           # Модель User
│   │   └── errors.go         # Кастомные ошибки
│   ├── handler/
│   │   ├── auth.go           # Хендлеры аутентификации
│   │   ├── note.go           # Хендлеры заметок
│   │   ├── user.go           # Хендлеры пользователей
│   │   ├── response.go       # Утилиты для ответов
│   │   └── middleware.go     # Кастомная middleware
│   ├── service/
│   │   ├── auth.go           # Бизнес-логика аутентификации
│   │   ├── note.go           # Бизнес-логика заметок
│   │   └── user.go           # Бизнес-логика пользователей
│   ├── repository/
│   │   ├── note.go           # Работа с БД (заметки)
│   │   ├── user.go           # Работа с БД (пользователи)
│   │   └── postgres.go       # Инициализация pgx pool
│   └── router/
│       └── router.go         # Настройка всех маршрутов
├── migrations/
│   ├── 000001_create_users.sql
│   └── 000002_create_notes.sql
├── .env.example
├── go.mod
├── go.sum
├── Dockerfile
└── docker-compose.yml

1.1 Почему такая структура?

Это Clean Architecture (Чистая архитектура) в Go-стиле:

Направление зависимостей: handler → service → repository (всегда вниз).


2. Конфигурация (internal/config/config.go)

package config

import (
    "os"
    "strconv"
    "time"
)

type Config struct {
    ServerPort    string
    DatabaseURL   string
    JWTSecret     string
    JWTExpiration time.Duration
    RateLimit     int
    RateLimitPer  time.Duration
    LogLevel      string
}

func Load() *Config {
    return &Config{
        ServerPort:    getEnv("SERVER_PORT", "8080"),
        DatabaseURL:   getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/notes?sslmode=disable"),
        JWTSecret:     getEnv("JWT_SECRET", "super-secret-key-change-in-production"),
        JWTExpiration: getDurationEnv("JWT_EXPIRATION", 24*time.Hour),
        RateLimit:     getIntEnv("RATE_LIMIT", 100),
        RateLimitPer:  getDurationEnv("RATE_LIMIT_PER", 1*time.Minute),
        LogLevel:      getEnv("LOG_LEVEL", "info"),
    }
}

func getEnv(key, fallback string) string {
    if val := os.Getenv(key); val != "" {
        return val
    }
    return fallback
}

func getIntEnv(key string, fallback int) int {
    if val := os.Getenv(key); val != "" {
        if i, err := strconv.Atoi(val); err == nil {
            return i
        }
    }
    return fallback
}

func getDurationEnv(key string, fallback time.Duration) time.Duration {
    if val := os.Getenv(key); val != "" {
        if d, err := time.ParseDuration(val); err == nil {
            return d
        }
    }
    return fallback
}

3. Модели (internal/domain/)

3.1 domain/note.go

package domain

import "time"

type Note struct {
    ID        int64     `json:"id"`
    UserID    int64     `json:"user_id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    Tags      []string  `json:"tags"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

type CreateNoteRequest struct {
    Title   string   `json:"title"   validate:"required,min=1,max=200"`
    Content string   `json:"content" validate:"required,min=1"`
    Tags    []string `json:"tags"    validate:"max=5,dive,max=30"`
}

type UpdateNoteRequest struct {
    Title   *string   `json:"title"   validate:"omitempty,min=1,max=200"`
    Content *string   `json:"content" validate:"omitempty,min=1"`
    Tags    *[]string `json:"tags"    validate:"omitempty,max=5,dive,max=30"`
}

3.2 domain/user.go

package domain

import "time"

type User struct {
    ID        int64     `json:"id"`
    Email     string    `json:"email"`
    Password  string    `json:"-"` // never expose in JSON
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`
}

type RegisterRequest struct {
    Email    string `json:"email"    validate:"required,email"`
    Password string `json:"password" validate:"required,min=8,max=72"`
    Name     string `json:"name"     validate:"required,min=2,max=100"`
}

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

3.3 domain/errors.go

package domain

import "errors"

var (
    ErrNotFound       = errors.New("not found")
    ErrAlreadyExists  = errors.New("already exists")
    ErrInvalidInput   = errors.New("invalid input")
    ErrUnauthorized   = errors.New("unauthorized")
    ErrForbidden      = errors.New("forbidden")
    ErrInternalServer = errors.New("internal server error")
)

4. Слой репозитория (internal/repository/)

4.1 repository/postgres.go

package repository

import (
    "context"
    "fmt"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

func NewPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
    config, err := pgxpool.ParseConfig(dsn)
    if err != nil {
        return nil, fmt.Errorf("parse config: %w", err)
    }

    config.MaxConns = 25
    config.MinConns = 5
    config.MaxConnLifetime = 30 * time.Minute
    config.MaxConnIdleTime = 5 * time.Minute

    pool, err := pgxpool.NewWithConfig(ctx, config)
    if err != nil {
        return nil, fmt.Errorf("create pool: %w", err)
    }

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

    return pool, nil
}

4.2 repository/user.go

package repository

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgconn"
    "github.com/jackc/pgx/v5/pgxpool"
    "notes-api/internal/domain"
)

type UserRepository struct {
    pool *pgxpool.Pool
}

func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
    return &UserRepository{pool: pool}
}

func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
    query := `INSERT INTO users (email, password, name) VALUES ($1, $2, $3) RETURNING id, created_at`
    err := r.pool.QueryRow(ctx, query, user.Email, user.Password, user.Name).
        Scan(&user.ID, &user.CreatedAt)
    if err != nil {
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique violation
            return fmt.Errorf("%w: email already exists", domain.ErrAlreadyExists)
        }
        return fmt.Errorf("create user: %w", err)
    }
    return nil
}

func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
    query := `SELECT id, email, password, name, created_at FROM users WHERE email = $1`
    user := &domain.User{}
    err := r.pool.QueryRow(ctx, query, email).
        Scan(&user.ID, &user.Email, &user.Password, &user.Name, &user.CreatedAt)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, fmt.Errorf("%w: user not found", domain.ErrNotFound)
        }
        return nil, fmt.Errorf("get user by email: %w", err)
    }
    return user, nil
}

func (r *UserRepository) GetByID(ctx context.Context, id int64) (*domain.User, error) {
    query := `SELECT id, email, password, name, created_at FROM users WHERE id = $1`
    user := &domain.User{}
    err := r.pool.QueryRow(ctx, query, id).
        Scan(&user.ID, &user.Email, &user.Password, &user.Name, &user.CreatedAt)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, fmt.Errorf("%w: user not found", domain.ErrNotFound)
        }
        return nil, fmt.Errorf("get user by id: %w", err)
    }
    return user, nil
}

4.3 repository/note.go

package repository

import (
    "context"
    "fmt"
    "time"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
    "notes-api/internal/domain"
)

type NoteRepository struct {
    pool *pgxpool.Pool
}

func NewNoteRepository(pool *pgxpool.Pool) *NoteRepository {
    return &NoteRepository{pool: pool}
}

func (r *NoteRepository) Create(ctx context.Context, note *domain.Note) error {
    query := `INSERT INTO notes (user_id, title, content, tags) VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at`
    err := r.pool.QueryRow(ctx, query, note.UserID, note.Title, note.Content, note.Tags).
        Scan(&note.ID, &note.CreatedAt, &note.UpdatedAt)
    if err != nil {
        return fmt.Errorf("create note: %w", err)
    }
    return nil
}

func (r *NoteRepository) GetByID(ctx context.Context, id, userID int64) (*domain.Note, error) {
    query := `SELECT id, user_id, title, content, tags, created_at, updated_at FROM notes WHERE id = $1 AND user_id = $2`
    note := &domain.Note{}
    err := r.pool.QueryRow(ctx, query, id, userID).
        Scan(&note.ID, &note.UserID, &note.Title, &note.Content, &note.Tags, &note.CreatedAt, &note.UpdatedAt)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, fmt.Errorf("%w: note not found", domain.ErrNotFound)
        }
        return nil, fmt.Errorf("get note: %w", err)
    }
    return note, nil
}

func (r *NoteRepository) ListByUser(ctx context.Context, userID int64, limit, offset int) ([]*domain.Note, error) {
    query := `SELECT id, user_id, title, content, tags, created_at, updated_at FROM notes WHERE user_id = $1 ORDER BY updated_at DESC LIMIT $2 OFFSET $3`
    rows, err := r.pool.Query(ctx, query, userID, limit, offset)
    if err != nil {
        return nil, fmt.Errorf("list notes: %w", err)
    }
    defer rows.Close()

    var notes []*domain.Note
    for rows.Next() {
        note := &domain.Note{}
        if err := rows.Scan(&note.ID, &note.UserID, &note.Title, &note.Content, &note.Tags, &note.CreatedAt, &note.UpdatedAt); err != nil {
            return nil, fmt.Errorf("scan note: %w", err)
        }
        notes = append(notes, note)
    }
    return notes, nil
}

func (r *NoteRepository) Update(ctx context.Context, note *domain.Note) error {
    query := `UPDATE notes SET title = $1, content = $2, tags = $3, updated_at = NOW() WHERE id = $4 AND user_id = $5 RETURNING updated_at`
    err := r.pool.QueryRow(ctx, query, note.Title, note.Content, note.Tags, note.ID, note.UserID).
        Scan(&note.UpdatedAt)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return fmt.Errorf("%w: note not found", domain.ErrNotFound)
        }
        return fmt.Errorf("update note: %w", err)
    }
    return nil
}

func (r *NoteRepository) Delete(ctx context.Context, id, userID int64) error {
    query := `DELETE FROM notes WHERE id = $1 AND user_id = $2`
    tag, err := r.pool.Exec(ctx, query, id, userID)
    if err != nil {
        return fmt.Errorf("delete note: %w", err)
    }
    if tag.RowsAffected() == 0 {
        return fmt.Errorf("%w: note not found", domain.ErrNotFound)
    }
    return nil
}

5. Слой сервиса (internal/service/)

5.1 service/auth.go

package service

import (
    "context"
    "fmt"
    "time"

    "golang.org/x/crypto/bcrypt"
    "notes-api/internal/domain"
    "notes-api/internal/repository"
)

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

func NewAuthService(userRepo *repository.UserRepository, jwtSecret string, jwtExp time.Duration) *AuthService {
    return &AuthService{userRepo: userRepo, jwtSecret: jwtSecret, jwtExp: jwtExp}
}

func (s *AuthService) Register(ctx context.Context, req domain.RegisterRequest) (*domain.User, error) {
    // Хешируем пароль
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return nil, fmt.Errorf("hash password: %w", err)
    }

    user := &domain.User{
        Email:    req.Email,
        Password: string(hashedPassword),
        Name:     req.Name,
    }

    if err := s.userRepo.Create(ctx, user); err != nil {
        return nil, err
    }

    return user, nil
}

func (s *AuthService) Login(ctx context.Context, req domain.LoginRequest) (string, *domain.User, error) {
    user, err := s.userRepo.GetByEmail(ctx, req.Email)
    if err != nil {
        return "", nil, fmt.Errorf("%w: invalid credentials", domain.ErrUnauthorized)
    }

    if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
        return "", nil, fmt.Errorf("%w: invalid credentials", domain.ErrUnauthorized)
    }

    // Генерируем JWT
    token, err := generateJWT(user.ID, s.jwtSecret, s.jwtExp)
    if err != nil {
        return "", nil, fmt.Errorf("generate token: %w", err)
    }

    return token, user, nil
}

5.2 service/note.go

package service

import (
    "context"
    "fmt"

    "notes-api/internal/domain"
    "notes-api/internal/repository"
)

type NoteService struct {
    noteRepo *repository.NoteRepository
}

func NewNoteService(noteRepo *repository.NoteRepository) *NoteService {
    return &NoteService{noteRepo: noteRepo}
}

func (s *NoteService) Create(ctx context.Context, userID int64, req domain.CreateNoteRequest) (*domain.Note, error) {
    note := &domain.Note{
        UserID:  userID,
        Title:   req.Title,
        Content: req.Content,
        Tags:    req.Tags,
    }

    if err := s.noteRepo.Create(ctx, note); err != nil {
        return nil, err
    }
    return note, nil
}

func (s *NoteService) GetByID(ctx context.Context, id, userID int64) (*domain.Note, error) {
    return s.noteRepo.GetByID(ctx, id, userID)
}

func (s *NoteService) List(ctx context.Context, userID int64, page, pageSize int) ([]*domain.Note, error) {
    if page < 1 {
        page = 1
    }
    if pageSize < 1 || pageSize > 100 {
        pageSize = 20
    }
    offset := (page - 1) * pageSize
    return s.noteRepo.ListByUser(ctx, userID, pageSize, offset)
}

func (s *NoteService) Update(ctx context.Context, id, userID int64, req domain.UpdateNoteRequest) (*domain.Note, error) {
    note, err := s.noteRepo.GetByID(ctx, id, userID)
    if err != nil {
        return nil, err
    }

    if req.Title != nil {
        note.Title = *req.Title
    }
    if req.Content != nil {
        note.Content = *req.Content
    }
    if req.Tags != nil {
        note.Tags = *req.Tags
    }

    if err := s.noteRepo.Update(ctx, note); err != nil {
        return nil, err
    }
    return note, nil
}

func (s *NoteService) Delete(ctx context.Context, id, userID int64) error {
    return s.noteRepo.Delete(ctx, id, userID)
}

5.3 JWT-утилиты (service/jwt.go)

package service

import (
    "time"

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

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

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

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

func ParseJWT(tokenString, secret string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
        return []byte(secret), nil
    })
    if err != nil {
        return nil, err
    }
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, jwt.ErrSignatureInvalid
    }
    return claims, nil
}

6. HTTP-слой (handler + middleware)

6.1 Кастомная middleware (handler/middleware.go)

package handler

import (
    "context"
    "net/http"
    "strings"
    "notes-api/internal/service"
)

type contextKey string

const UserIDKey contextKey = "user_id"

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) {
            header := r.Header.Get("Authorization")
            if header == "" {
                writeError(w, "missing authorization header", http.StatusUnauthorized)
                return
            }

            token := strings.TrimPrefix(header, "Bearer ")
            if token == header { // не найден префикс Bearer
                writeError(w, "invalid authorization format", http.StatusUnauthorized)
                return
            }

            claims, err := service.ParseJWT(token, jwtSecret)
            if err != nil {
                writeError(w, "invalid token: "+err.Error(), http.StatusUnauthorized)
                return
            }

            ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func GetUserID(r *http.Request) int64 {
    if id, ok := r.Context().Value(UserIDKey).(int64); ok {
        return id
    }
    return 0
}

6.2 Утилиты ответов (handler/response.go)

package handler

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

type APIResponse struct {
    Success bool        `json:"success"`
    Data    interface{} `json:"data,omitempty"`
    Error   string      `json:"error,omitempty"`
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(APIResponse{Success: true, Data: data})
}

func writeError(w http.ResponseWriter, msg string, status int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(APIResponse{Success: false, Error: msg})
}

6.3 Хендлер аутентификации (handler/auth.go)

package handler

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

    "notes-api/internal/domain"
    "notes-api/internal/service"
)

type AuthHandler struct {
    authService *service.AuthService
}

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

func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
    var req domain.RegisterRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, "invalid request body", http.StatusBadRequest)
        return
    }

    user, err := h.authService.Register(r.Context(), req)
    if err != nil {
        writeError(w, err.Error(), http.StatusConflict)
        return
    }

    writeJSON(w, http.StatusCreated, user)
}

func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
    var req domain.LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, "invalid request body", http.StatusBadRequest)
        return
    }

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

    writeJSON(w, http.StatusOK, map[string]interface{}{
        "token": token,
        "user":  user,
    })
}

6.4 Хендлер заметок (handler/note.go)

package handler

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

    "github.com/go-chi/chi/v5"
    "notes-api/internal/domain"
    "notes-api/internal/service"
)

type NoteHandler struct {
    noteService *service.NoteService
}

func NewNoteHandler(noteService *service.NoteService) *NoteHandler {
    return &NoteHandler{noteService: noteService}
}

func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req domain.CreateNoteRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, "invalid request body", http.StatusBadRequest)
        return
    }

    userID := GetUserID(r)
    note, err := h.noteService.Create(r.Context(), userID, req)
    if err != nil {
        writeError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    writeJSON(w, http.StatusCreated, note)
}

func (h *NoteHandler) GetByID(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
    if err != nil {
        writeError(w, "invalid id", http.StatusBadRequest)
        return
    }

    userID := GetUserID(r)
    note, err := h.noteService.GetByID(r.Context(), id, userID)
    if err != nil {
        writeError(w, err.Error(), http.StatusNotFound)
        return
    }

    writeJSON(w, http.StatusOK, note)
}

func (h *NoteHandler) List(w http.ResponseWriter, r *http.Request) {
    userID := GetUserID(r)
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))

    notes, err := h.noteService.List(r.Context(), userID, page, pageSize)
    if err != nil {
        writeError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    writeJSON(w, http.StatusOK, notes)
}

func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
    if err != nil {
        writeError(w, "invalid id", http.StatusBadRequest)
        return
    }

    var req domain.UpdateNoteRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, "invalid request body", http.StatusBadRequest)
        return
    }

    userID := GetUserID(r)
    note, err := h.noteService.Update(r.Context(), id, userID, req)
    if err != nil {
        writeError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    writeJSON(w, http.StatusOK, note)
}

func (h *NoteHandler) Delete(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
    if err != nil {
        writeError(w, "invalid id", http.StatusBadRequest)
        return
    }

    userID := GetUserID(r)
    if err := h.noteService.Delete(r.Context(), id, userID); err != nil {
        writeError(w, err.Error(), http.StatusNotFound)
        return
    }

    writeJSON(w, http.StatusOK, nil)
}

7. Роутер (internal/router/router.go)

package router

import (
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/go-chi/cors"
    "github.com/go-chi/httprate"
    "notes-api/internal/config"
    "notes-api/internal/handler"
)

func New(cfg *config.Config, authHandler *handler.AuthHandler, noteHandler *handler.NoteHandler) *chi.Mux {
    r := chi.NewRouter()

    // Глобальные middleware
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(30 * time.Second))
    r.Use(cors.Handler(cors.Options{
        AllowedOrigins:   []string{"*"},
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
        AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
        ExposedHeaders:   []string{"X-Request-ID"},
        AllowCredentials: false,
        MaxAge:           300,
    }))
    r.Use(httprate.LimitByIP(cfg.RateLimit, cfg.RateLimitPer))

    // Health check
    r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"status":"ok"}`))
    })

    // Публичные маршруты (без авторизации)
    r.Route("/api/v1/auth", func(r chi.Router) {
        r.Post("/register", authHandler.Register)
        r.Post("/login", authHandler.Login)
    })

    // Защищённые маршруты (требуют JWT)
    r.Route("/api/v1/notes", func(r chi.Router) {
        r.Use(handler.AuthMiddleware(cfg.JWTSecret))

        r.Get("/", noteHandler.List)
        r.Post("/", noteHandler.Create)
        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", noteHandler.GetByID)
            r.Put("/", noteHandler.Update)
            r.Delete("/", noteHandler.Delete)
        })
    })

    return r
}

8. Точка входа (cmd/server/main.go)

package main

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

    "notes-api/internal/config"
    "notes-api/internal/handler"
    "notes-api/internal/repository"
    "notes-api/internal/router"
    "notes-api/internal/service"
)

func main() {
    cfg := config.Load()

    // PostgreSQL
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    pool, err := repository.NewPostgresPool(ctx, cfg.DatabaseURL)
    if err != nil {
        log.Fatalf("Failed to connect to database: %v", err)
    }
    defer pool.Close()
    log.Println("Connected to PostgreSQL")

    // Repositories
    userRepo := repository.NewUserRepository(pool)
    noteRepo := repository.NewNoteRepository(pool)

    // Services
    authService := service.NewAuthService(userRepo, cfg.JWTSecret, cfg.JWTExpiration)
    noteService := service.NewNoteService(noteRepo)

    // Handlers
    authHandler := handler.NewAuthHandler(authService)
    noteHandler := handler.NewNoteHandler(noteService)

    // Router
    r := router.New(cfg, authHandler, noteHandler)

    // HTTP Server
    srv := &http.Server{
        Addr:         ":" + cfg.ServerPort,
        Handler:      r,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Graceful shutdown
    go func() {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
        <-sigCh

        log.Println("Shutting down server...")
        shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer shutdownCancel()

        if err := srv.Shutdown(shutdownCtx); err != nil {
            log.Fatalf("Server shutdown error: %v", err)
        }
    }()

    log.Printf("Server starting on :%s", cfg.ServerPort)
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("Server error: %v", err)
    }

    log.Println("Server stopped gracefully")
}

9. Миграции БД

migrations/000001_create_users.sql

CREATE TABLE IF NOT EXISTS users (
    id         BIGSERIAL PRIMARY KEY,
    email      VARCHAR(255) UNIQUE NOT NULL,
    password   VARCHAR(255) NOT NULL,
    name       VARCHAR(100) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);

migrations/000002_create_notes.sql

CREATE TABLE IF NOT EXISTS notes (
    id         BIGSERIAL PRIMARY KEY,
    user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title      VARCHAR(200) NOT NULL,
    content    TEXT NOT NULL,
    tags       TEXT[] DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_notes_user_id ON notes(user_id);
CREATE INDEX idx_notes_updated_at ON notes(updated_at DESC);

10. Docker Compose

# docker-compose.yml
version: '3.8'

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: notes
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./migrations:/docker-entrypoint-initdb.d

  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      SERVER_PORT: "8080"
      DATABASE_URL: "postgres://postgres:postgres@db:5432/notes?sslmode=disable"
      JWT_SECRET: "change-me-in-production"
      RATE_LIMIT: "100"
    depends_on:
      - db

volumes:
  pgdata:

Dockerfile

FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]

11. API-эндпоинты (шпаргалка)

МетодПутьОписаниеAuth
POST/api/v1/auth/registerРегистрацияНет
POST/api/v1/auth/loginВход, получение JWTНет
GET/api/v1/notesСписок заметок пользователяДа
POST/api/v1/notesСоздать заметкуДа
GET/api/v1/notes/{id}Получить заметкуДа
PUT/api/v1/notes/{id}Обновить заметкуДа
DELETE/api/v1/notes/{id}Удалить заметкуДа
GET/healthHealth checkНет

🔑 Ключевые выводы

← Предыдущий урок