express-generator → ручная структура cmd/, internal/, pkg/express.Router() → chi.Router с группами маршрутовmorgan + helmet + cors → chi/middleware + go-chi/corsexpress-validator → go-playground/validatorPrisma / TypeORM → pgx + ручные SQL-запросыts-node-dev --watch → air (live-reload для Go)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
Это Clean Architecture (Чистая архитектура) в Go-стиле:
cmd/ — точки входа (может быть несколько: сервер, мигратор, CLI)internal/ — пакеты, недоступные для импорта извне модуляdomain/ — модели данных (чистые структуры, без зависимостей)handler/ — HTTP-слой (принимает запрос, вызывает service, возвращает ответ)service/ — бизнес-логика (валидация, правила, вызов repository)repository/ — слой данных (PostgreSQL, Redis, внешние API)router/ — конфигурация маршрутов и middlewareНаправление зависимостей: handler → service → repository (всегда вниз).
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
}
internal/domain/)domain/note.gopackage 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"`
}
domain/user.gopackage 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"`
}
domain/errors.gopackage 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")
)
internal/repository/)repository/postgres.gopackage 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
}
repository/user.gopackage 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
}
repository/note.gopackage 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(¬e.ID, ¬e.CreatedAt, ¬e.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(¬e.ID, ¬e.UserID, ¬e.Title, ¬e.Content, ¬e.Tags, ¬e.CreatedAt, ¬e.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(¬e.ID, ¬e.UserID, ¬e.Title, ¬e.Content, ¬e.Tags, ¬e.CreatedAt, ¬e.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(¬e.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
}
internal/service/)service/auth.gopackage 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
}
service/note.gopackage 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)
}
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
}
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
}
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})
}
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,
})
}
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)
}
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
}
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")
}
migrations/000001_create_users.sqlCREATE 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.sqlCREATE 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);
# 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:
DockerfileFROM 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"]
| Метод | Путь | Описание | 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 | /health | Health check | Нет |
cmd/ → internal/{domain,handler,service,repository,router} — стандарт Go для production-проектовnet/http, богатый набор middleware