Модуль 8. Создание Web API сервиса

Цели модуля

После модуля студент сможет:

  • Создавать HTTP-серверы на net/http
  • Понимать интерфейсы http.Handler и http.HandlerFunc
  • Использовать новый роутинг http.ServeMux (Go 1.22+)
  • Понимать жизненный цикл запроса/ответа
  • Кодировать и декодировать JSON
  • Проектировать DTO и мапперы
  • Реализовывать health checks (/healthz, /readyz)
  • Структурировать HTTP-слой по шаблону проекта

🔥 Инцидент: успешный деплой и пять минут ошибок

Пятничный деплой прошёл образцово: пайплайн зелёный, под поднялся, балансировщик сразу пустил на него трафик. Через минуту мониторинг показал волну 500-х: сервис заказов принимал запросы, ещё не успев поднять пул соединений к базе. Health-чек этого не заметил — он отвечал 200 всегда, даже когда сервис был неработоспособен:

go
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK) // «здоров», даже когда БД недоступна
})
http.ListenAndServe(":8080", nil) // ни одного таймаута

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

В этом модуле соберём HTTP-слой, который не подводит: обработчики и роутинг, жизненный цикл запроса/ответа, таймауты сервера и клиента, JSON и DTO, честные /healthz и /readyz и структуру HTTP-слоя по шаблону проекта.


8.1 net/http и HTTP-сервер

Минимальный HTTP-сервер

go
package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })

    fmt.Println("server starting on :8080")
    http.ListenAndServe(":8080", nil)
}

http.Server с настройками

Для production всегда настраивайте таймауты:

go
srv := &http.Server{
    Addr:         ":8080",
    Handler:      router,
    ReadTimeout:  15 * time.Second,
    WriteTimeout: 15 * time.Second,
    IdleTimeout:  60 * time.Second,
}

if err := srv.ListenAndServe(); err != nil {
    log.Fatalf("server error: %v", err)
}
ТаймаутНазначение
ReadTimeoutМаксимальное время чтения запроса
WriteTimeoutМаксимальное время записи ответа
IdleTimeoutВремя ожидания в keep-alive соединении

Запуск в горутине с graceful shutdown

go
go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("server error: %v", err)
    }
}()

<-ctx.Done()

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)

8.2 Хэндлеры и http.Handler

Интерфейс http.Handler

Вся обработка HTTP в Go построена на одном интерфейсе:

go
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Любой тип с методом ServeHTTP является хэндлером.

http.HandlerFunc

HandlerFunc - адаптер позволяющий использовать обычную функцию как Handler:

go
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

// Использование
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello")
})

Хэндлер как структура

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

go
type Handler struct {
    repoService   *service.RepoService
    userService   *service.UserService
    logger        *slog.Logger
}

func NewHandler(repoSvc *service.RepoService, userSvc *service.UserService) *Handler {
    return &Handler{
        repoService: repoSvc,
        userService: userSvc,
        logger:      slog.Default(),
    }
}

func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    repo, err := h.repoService.GetRepo(r.Context(), id)
    if err != nil {
        h.handleError(w, err)
        return
    }

    h.writeJSON(w, http.StatusOK, repo)
}

ResponseWriter

http.ResponseWriter - интерфейс для записи ответа:

go
// Запись заголовков (до WriteHeader)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Request-ID", requestID)

// Установка статус кода
w.WriteHeader(http.StatusCreated)

// Запись тела
w.Write([]byte("response body"))

ВАЖНО: порядок операций:

  1. Header().Set() - установка заголовков
  2. WriteHeader() - установка статус кода
  3. Write() - запись тела

Если вызвать Write() без WriteHeader(), автоматически вызовется WriteHeader(200).

Request

http.Request содержит всю информацию о запросе:

text
r.Method            // HTTP метод: GET, POST, PUT, DELETE
r.URL.Path          // путь: /api/v1/repos
r.URL.Query()       // query параметры: ?page=1&limit=10
r.Header            // заголовки запроса
r.Body              // тело запроса (io.ReadCloser)
r.Context()         // context запроса
r.PathValue("id")   // path параметр (Go 1.22+)
r.Host              // хост
r.RemoteAddr        // адрес клиента

8.3 Роутинг (http.ServeMux в Go 1.22+)

Новый роутинг Go 1.22+

Начиная с Go 1.22, стандартный http.ServeMux поддерживает:

  • HTTP методы в паттернах

  • Path parameters

  • Wildcards

    mux := http.NewServeMux()

    // Метод + путь mux.HandleFunc("GET /api/v1/repos", h.ListRepos) mux.HandleFunc("POST /api/v1/repos", h.CreateRepo) mux.HandleFunc("GET /api/v1/repos/{id}", h.GetRepo) mux.HandleFunc("PUT /api/v1/repos/{id}", h.UpdateRepo) mux.HandleFunc("DELETE /api/v1/repos/{id}", h.DeleteRepo)

    // Wildcard mux.HandleFunc("GET /static/{path...}", h.StaticFiles)

Path parameters

Параметры извлекаются через r.PathValue():

go
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")  // извлекает {id} из паттерна

    repo, err := h.repoService.GetRepo(r.Context(), id)
    if err != nil {
        h.handleError(w, err)
        return
    }

    h.writeJSON(w, http.StatusOK, repo)
}

Регистрация роутов в шаблоне

Пример из шаблона: internal/handlers/http/api/v1/routes.go

go
package v1

import (
    "net/http"
)

func RegisterRoutes(mux *http.ServeMux, h *Handler) {
    // Repos
    mux.HandleFunc("GET /api/v1/repos", h.ListRepos)
    mux.HandleFunc("POST /api/v1/repos", h.CreateRepo)
    mux.HandleFunc("GET /api/v1/repos/{id}", h.GetRepo)
    mux.HandleFunc("PUT /api/v1/repos/{id}", h.UpdateRepo)
    mux.HandleFunc("DELETE /api/v1/repos/{id}", h.DeleteRepo)

    // Users
    mux.HandleFunc("GET /api/v1/users/{id}", h.GetUser)
    mux.HandleFunc("POST /api/v1/users", h.CreateUser)

    // Health checks
    mux.HandleFunc("GET /healthz", h.Healthz)
    mux.HandleFunc("GET /readyz", h.Readyz)
}

Альтернативные роутеры

Для сложных проектов можно использовать сторонние роутеры:

РоутерОсобенности
chiЛёгкий, middleware-friendly, совместим с net/http
ginБыстрый, много встроенных фич
echoМинималистичный, хорошая документация
gorilla/muxМощный, но на поддержке

Для большинства проектов стандартного http.ServeMux достаточно.


8.4 Жизненный цикл запроса/ответа

Полный цикл HTTP-запроса

text
1. Клиент отправляет запрос
   |
2. http.Server принимает соединение
   |
3. Парсинг запроса (метод, путь, заголовки, тело)
   |
4. Создание http.Request
   |
5. Роутер находит хэндлер по паттерну
   |
6. Middleware-цепочка оборачивает хэндлер
   |
7. Хэндлер обрабатывает запрос
   |  - Валидация входных данных
   |  - Вызов usecase/service
   |  - Формирование ответа
   |
8. Запись ответа (заголовки, статус, тело)
   |
9. Ответ отправляется клиенту
   |
10. Соединение закрывается или переиспользуется (keep-alive)

Middleware в цикле

Middleware оборачивает хэндлер и выполняется до/после:

go
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))
    })
}

Порядок middleware

go
// Middleware выполняется в порядке добавления
handler = LoggingMiddleware(handler)     // 1. logging
handler = RequestIDMiddleware(handler)   // 2. request id
handler = RecoveryMiddleware(handler)    // 3. recovery

// Порядок выполнения запроса:
// Recovery -> RequestID -> Logging -> Handler

8.5 JSON encoding/decoding

Кодирование (Go -> JSON)

go
import "encoding/json"

type Repo struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`
}

repo := &Repo{ID: "1", Name: "test", CreatedAt: time.Now()}

// В http.ResponseWriter
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(repo)

Декодирование (JSON -> Go)

go
func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) {
    var req CreateRepoRequest

    // Ограничение размера тела
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)  // 1 MB

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

    // Валидация и обработка
}

Теги структур

Теги управляют сериализацией:

go
type Repo struct {
    ID        string `json:"id"`                  // имя поля в JSON
    Name      string `json:"name"`                // имя поля в JSON
    Internal  string `json:"-"`                   // не сериализуется
    Optional  string `json:"optional,omitempty"`  // пропускается если пусто
}

Обработка ошибок декодирования

go
func decodeJSON(r *http.Request, v interface{}) error {
    defer r.Body.Close()

    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields()  // строгий режим

    if err := decoder.Decode(v); err != nil {
        var syntaxErr *json.SyntaxError
        var typeErr *json.UnmarshalTypeError

        switch {
        case errors.As(err, &syntaxErr):
            return fmt.Errorf("syntax error at position %d", syntaxErr.Offset)
        case errors.As(err, &typeErr):
            return fmt.Errorf("invalid type for field %s", typeErr.Field)
        default:
            return err
        }
    }

    return nil
}

Вспомогательные функции для ответов

go
func (h *Handler) writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(data); err != nil {
        h.logger.Error("failed to encode response", "error", err)
    }
}

func (h *Handler) writeError(w http.ResponseWriter, status int, message string) {
    h.writeJSON(w, status, ErrorResponse{
        Code:    http.StatusText(status),
        Message: message,
    })
}

8.6 DTO и мапперы

Зачем нужны DTO

DTO (Data Transfer Object) - контракт API. Отделяет:

  • Что видит клиент (DTO)
  • Что хранится внутри (Domain Model)
go
// DTO - контракт API
type CreateRepoRequest struct {
    Name      string `json:"name"`
    OwnerID   string `json:"owner_id"`
    IsPrivate bool   `json:"is_private"`
}

type CreateRepoResponse struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`
}

// Domain Model - внутреннее представление
// models.Repo содержит больше полей

Мапперы

Мапперы преобразуют DTO <-> Domain Model:

go
// DTO -> Domain
func CreateRepoRequestToDomain(req CreateRepoRequest) *models.Repo {
    return models.NewRepo(req.Name, req.OwnerID, req.IsPrivate)
}

// Domain -> DTO
func RepoToResponse(repo *models.Repo) *CreateRepoResponse {
    return &CreateRepoResponse{
        ID:        repo.ID,
        Name:      repo.Name,
        CreatedAt: repo.CreatedAt,
    }
}

Пример из шаблона: internal/handlers/http/api/v1/dto.go

go
package v1

import (
    "time"
    "yourproject/internal/models"
)

// Requests

type CreateRepoRequest struct {
    Name      string `json:"name" validate:"required,min=3,max=100"`
    OwnerID   string `json:"owner_id" validate:"required,uuid"`
    IsPrivate bool   `json:"is_private"`
}

func (r *CreateRepoRequest) ToDomain() *models.Repo {
    return models.NewRepo(r.Name, r.OwnerID, r.IsPrivate)
}

// Responses

type RepoResponse struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    OwnerID   string    `json:"owner_id"`
    IsPrivate bool      `json:"is_private"`
    CreatedAt time.Time `json:"created_at"`
}

func NewRepoResponse(repo *models.Repo) *RepoResponse {
    return &RepoResponse{
        ID:        repo.ID,
        Name:      repo.Name,
        OwnerID:   repo.OwnerID,
        IsPrivate: repo.IsPrivate,
        CreatedAt: repo.CreatedAt,
    }
}

type ErrorResponse struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}

8.7 Health checks

Зачем нужны health checks

Health checks используются:

  • Kubernetes liveness probe (/healthz)
  • Kubernetes readiness probe (/readyz)
  • Load balancer checks
  • Мониторинг

/healthz - liveness

Проверяет что процесс жив. Не проверяет зависимости.

json
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
    h.writeJSON(w, http.StatusOK, map[string]string{
        "status": "ok",
    })
}

/readyz - readiness

Проверяет что сервис готов принимать трафик. Проверяет зависимости.

go
func (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    // Проверяем БД
    if err := h.db.Ping(ctx); err != nil {
        h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{
            "status": "not ready",
            "reason": "database unavailable",
        })
        return
    }

    h.writeJSON(w, http.StatusOK, map[string]string{
        "status": "ready",
    })
}

Разница между healthz и readyz

EndpointНазначениеЧто проверяет
/healthzПроцесс живНичего (всегда 200)
/readyzГотов принимать трафикБД, кэш, внешние сервисы

Kubernetes использует:

  • livenessProbe -> /healthz (перезапуск если не жив)
  • readinessProbe -> /readyz (исключение из балансировки если не готов)

8.8 Структура HTTP-слоя в шаблоне

Организация файлов

text
internal/handlers/http/
├── api/
│   └── v1/
│       ├── handler.go       # HTTP-хэндлеры
│       ├── dto.go           # DTO: requests и responses
│       ├── mapper.go        # Мапперы DTO <-> Domain
│       └── routes.go        # Регистрация роутов
├── git/
│   └── handler.go           # Git Smart HTTP (отдельный модуль)
└── middleware/
    ├── request_id.go
    ├── logging.go
    ├── recovery.go
    ├── timeout.go
    └── pii.go

Ответственность файлов

ФайлОтветственность
handler.goHTTP-хэндлеры: декодирование, вызов usecase, кодирование ответа
dto.goDTO: структура запросов и ответов API
mapper.goМапперы: преобразование DTO <-> Domain Model
routes.goРегистрация роутов в mux
middleware/Middleware для обработки запросов

Handler в шаблоне

go
// internal/handlers/http/api/v1/handler.go
package v1

import (
    "net/http"
    "yourproject/internal/service"
    "yourproject/internal/usecase"
)

type Handler struct {
    createRepoUC *usecase.CreateRepoUseCase
    getRepoUC    *usecase.GetRepoUseCase
    logger       *slog.Logger
}

func NewHandler(
    createRepoUC *usecase.CreateRepoUseCase,
    getRepoUC *usecase.GetRepoUseCase,
    logger *slog.Logger,
) *Handler {
    return &Handler{
        createRepoUC: createRepoUC,
        getRepoUC:    getRepoUC,
        logger:       logger,
    }
}

func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) {
    var req CreateRepoRequest
    if err := decodeJSON(r, &req); err != nil {
        h.writeError(w, http.StatusBadRequest, "invalid request body")
        return
    }

    repo, err := h.createRepoUC.Execute(r.Context(), req)
    if err != nil {
        h.handleError(w, err)
        return
    }

    h.writeJSON(w, http.StatusCreated, NewRepoResponse(repo))
}

func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    repo, err := h.getRepoUC.Execute(r.Context(), id)
    if err != nil {
        h.handleError(w, err)
        return
    }

    h.writeJSON(w, http.StatusOK, NewRepoResponse(repo))
}

func (h *Handler) handleError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, service.ErrRepoNotFound):
        h.writeError(w, http.StatusNotFound, "repo not found")
    case errors.Is(err, service.ErrRepoAlreadyExists):
        h.writeError(w, http.StatusConflict, "repo already exists")
    default:
        h.logger.Error("unexpected error", "error", err)
        h.writeError(w, http.StatusInternalServerError, "internal server error")
    }
}

func (h *Handler) writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func (h *Handler) writeError(w http.ResponseWriter, status int, message string) {
    h.writeJSON(w, status, ErrorResponse{
        Code:    http.StatusText(status),
        Message: message,
    })
}

Привязка к шаблону проекта

Где HTTP-слой в шаблоне

text
internal/handlers/http/api/v1/
├── handler.go       # HTTP-хэндлеры
├── dto.go           # DTO
├── mapper.go        # Мапперы
└── routes.go        # Роуты

Что изучаем на примере шаблона

  1. Handler зависит от usecase, не от service
  2. DTO отделяют API-контракт от доменных моделей
  3. Мапперы преобразуют DTO <-> Domain
  4. Роуты регистрируются в отдельном файле
  5. Health checks на отдельных эндпоинтах

Задание: изучите internal/handlers/http/api/v1/

  • Какие эндпоинты реализованы?
  • Как хэндлеры получают зависимости?
  • Как обрабатываются ошибки?
  • Есть ли валидация запросов?

💱 Компромиссы

  • http.ServeMux vs сторонний роутер: с Go 1.22 stdlib умеет методы и {id}-параметры без единой зависимости, но middleware-цепочки собираем вручную; chi даёт middleware и группы роутов из коробки, но это внешняя зависимость в go.mod.
  • DTO vs доменные модели в ответе: DTO фиксируют контракт API, и домен меняется не ломая клиентов, но каждый эндпоинт обрастает маппером; доменная модель напрямую — меньше кода, но внутренние поля протекают наружу.
  • DisallowUnknownFields vs мягкий decode: строгий режим сразу ловит опечатки клиента и отвечает 400, но добавление нового поля ломает старых клиентов; мягкий совместим вперёд, но мусор в запросе молча игнорируется.
  • /readyz с проверкой БД vs всегда 200: честный readiness снимает неработоспособный под с балансировщика, но при лежащей БД весь сервис уходит из ротации разом; всегда 200 не даёт каскада, но пропускает деплой «зелёный, но мёртвый» — сценарий из начала модуля.
  • Таймауты сервера vs умолчания: ReadTimeout/WriteTimeout обрывают зависшие запросы и не дают соединениям копиться, но режут и медленные легитимные ответы — выгрузки, стриминг; без таймаутов медленный клиент доезжает, но зависший клиент держит соединение и горутину часами.

Практическое задание

Задание 1: Минимальный HTTP-сервер

Создайте HTTP-сервер с двумя эндпоинтами:

go
func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, World!")
    })

    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
    }

    log.Println("server starting on :8080")
    srv.ListenAndServe()
}

Задание 2: CRUD API для Repo

Реализуйте полный CRUD:

text
mux.HandleFunc("GET /api/v1/repos", h.ListRepos)
mux.HandleFunc("POST /api/v1/repos", h.CreateRepo)
mux.HandleFunc("GET /api/v1/repos/{id}", h.GetRepo)
mux.HandleFunc("PUT /api/v1/repos/{id}", h.UpdateRepo)
mux.HandleFunc("DELETE /api/v1/repos/{id}", h.DeleteRepo)

Задание 3: DTO и мапперы

Создайте DTO для CreateRepo:

go
type CreateRepoRequest struct {
    Name      string `json:"name"`
    OwnerID   string `json:"owner_id"`
    IsPrivate bool   `json:"is_private"`
}

type RepoResponse struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    OwnerID   string    `json:"owner_id"`
    CreatedAt time.Time `json:"created_at"`
}

func (r *CreateRepoRequest) ToDomain() *models.Repo { ... }
func NewRepoResponse(repo *models.Repo) *RepoResponse { ... }

Задание 4: Health checks

Реализуйте /healthz и /readyz:

text
mux.HandleFunc("GET /healthz", h.Healthz)
mux.HandleFunc("GET /readyz", h.Readyz)

Задание 5: Обработка ошибок

Реализуйте handleError с маппингом ошибок на HTTP-статусы:

go
func (h *Handler) handleError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, service.ErrRepoNotFound):
        h.writeError(w, http.StatusNotFound, "repo not found")
    case errors.Is(err, service.ErrRepoAlreadyExists):
        h.writeError(w, http.StatusConflict, "repo already exists")
    case errors.Is(err, context.Canceled):
        return  // клиент закрыл соединение
    default:
        h.logger.Error("unexpected error", "error", err)
        h.writeError(w, http.StatusInternalServerError, "internal server error")
    }
}

Задание 6: Тесты для хэндлеров

Напишите тесты с httptest:

go
func TestHandler_GetRepo(t *testing.T) {
    mockUC := new(MockGetRepoUseCase)
    mockUC.On("Execute", mock.Anything, "repo-123").
        Return(&models.Repo{ID: "repo-123", Name: "test"}, nil)

    h := NewHandler(nil, mockUC, slog.Default())

    req := httptest.NewRequest("GET", "/api/v1/repos/repo-123", nil)
    w := httptest.NewRecorder()

    h.GetRepo(w, req)

    assert.Equal(t, http.StatusOK, w.Code)
    assert.Contains(t, w.Body.String(), "repo-123")
}

Quiz "Проверь себя"

1. Что произойдёт, если не вызвать w.WriteHeader() перед записью body?
2. Как ограничить размер тела запроса?
3. Зачем нужен /healthz и чем он отличается от /readyz?
4. Что верно про middleware-цепочку? — выберите все подходящие
5. Зачем нужны DTO, если есть Domain Models?
6. Как правильно обработать ошибку JSON-декодирования в хэндлере?
go
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    // что здесь?
}
7. Что делает r.Context() и зачем его передавать дальше?
8. Чем http.Handler отличается от http.HandlerFunc?

✅ Итоги модуля

Студент должен:

  • Создавать HTTP-серверы с настройкой таймаутов
  • Понимать http.Handler и http.HandlerFunc
  • Использовать http.ServeMux с path parameters
  • Кодировать и декодировать JSON
  • Проектировать DTO и мапперы
  • Реализовывать health checks
  • Тестировать хэндлеры с httptest
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 9

Следующий модуль: Модуль 9. Middleware и валидация - middleware-паттерн, request ID, logging, recovery, timeout, валидация запросов. Привязка к internal/handlers/http/middleware/ шаблона.