Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~30 мин +50 XP

Итоговый проект: API на Echo

проект echo — урок, который двадцать четвёртый обещал дословно: «двадцать пятый урок курса echo — Итоговый проект: API на Echo с JWT, валидацией и тестами. Всё, что вы собрали за курс — маршруты, слои, ошибки, тесты, наблюдаемость, деплой, — склеится в один задачник, который можно отдать в портфолио». Обещание исполняется. За двадцать четыре урока вы подняли первый сервер на [e.Start](/courses/echo/lessons/02-pervyj-server/), научились читать [параметры маршрутов](/courses/echo/lessons/03-marshrutizaciya/) и [запрос целиком](/courses/echo/lessons/04-request-response/), ввели [валидацию с честным 400](/courses/echo/lessons/05-bind-validaciya/), выстроили [цепочки middleware](/courses/echo/lessons/06-middleware/) и [свой конверт ошибок](/courses/echo/lessons/10-httperror/), охраняли [группу /api/v1 с JWT](/courses/echo/lessons/15-jwt/), разложили канон по [слоям](/courses/echo/lessons/21-struktura/), доказали его [тестами](/courses/echo/lessons/20-testirovanie/) и [выкатили](/courses/echo/lessons/24-deploy/). Сегодня это станет одним rest api echo: ТЗ, полный CRUD с владением задачами, роли в клеймах, идемпотентный POST, матрица тестов и приёмка приборами. Домашнее задание урока — выпускной экзамен: он проверяет не память, а собранный проект. Приступаем — проект echo начинается с задания, а не с кода.

Содержание урока · 13 разделов

проект echo: задание на выпускной#

Тема «проект echo» начинается не с mkdir, а с ТЗ. Двадцать четыре урока каждое новое знание приезжало в канон — задачник на :1323 — по одному: маршруты из третьего урока, bind из четвёртого, validator из пятого, middleware из седьмого, slog из девятого, конверт ошибок из десятого, группа /api/v1 из одиннадцатого, JWT из пятнадцатого, REST-контракт из семнадцатого, тесты из двадцатого, слои из двадцать первого, прод-режим и таймауты из двадцать второго, пробы и метрики из двадцать третьего, Docker и graceful shutdown из двадцать четвёртого. Финальный проект — это не новый материал, а сборка: те же файлы, те же решения, но связанные одним заданием и одним чеклистом приёмки. Именно так выглядит настоящая работа: не «изучить тему», а «довести rest api echo до определения готовности».

Три качества отличают итоговый проект от учебных примеров. Первое: контракт предсказуем — любой эндпоинт отвечает по документированной таблице статусов, и ошибка всегда приходит в одном конверте. Второе: слои заменяемы — хранилище на карте подменяется базой без правки сервиса, потому что сервис зависит от интерфейса, объявленного потребителем. Третье: приёмка приборами — готовность доказывают зелёные тесты, пробы и код выхода при docker stop, а не уверенность автора. Всё остальное в этом уроке — исполнение этих трёх требований теми механиками, которые вы уже знаете.

ТЗ канона: десять требований#

  1. CRUD задач: POST/GET/PUT(PATCH)/DELETE на /api/v1/tasks и /api/v1/tasks/:id — ресурсы, статусы и заголовки по контракту семнадцатого урока.
  2. Аутентификация JWT: POST /api/v1/login и /api/v1/refresh из пятнадцатого урока; доступ к задачам — только с валидным Bearer-токеном.
  3. Авторизация по владению: менять и удалять задачу может только её владелец; чужая задача — 403, не 404 и не 500.
  4. Валидация входа: validate-теги и echo.Validator из пятого урока; битое тело — 400 validation_failed, а не 500.
  5. Единый конверт ошибок: ErrorHandler с таблицей errors.Is из десятого урока; ни один хендлер не форматирует ошибки сам.
  6. Бизнес-правила в сервисе: лимит задач на пользователя и владение; хендлер знает только HTTP, репозиторий — только хранилище.
  7. Идемпотентный POST: повтор с тем же Idempotency-Key возвращает сохранённый ответ из карты replay — дублей нет.
  8. Пагинация: offset/limit с клампами в сервисе и заголовком Link — страница двадцатого размера максимум, отрицательные значения не падают.
  9. Наблюдаемость: slog-JSON, RequestID, /healthz, /readyz и /metrics из двадцать третьего урока; версия в логе старта.
  10. Деплой: multi-stage Dockerfile, nonroot, graceful shutdown по SIGTERM — docker stop завершает процесс кодом 0.

Канон: дерево проекта и границы

GO
tasksapp/
├── cmd/
│   └── server/
│       └── main.go          // composition root: env → app.New → e.Start
├── internal/                // чужие модули сюда не импортируют — компилятор
│   ├── domain/
│   │   └── task.go          // Task + sentinel-ошибки ErrNotFound/ErrForbidden
│   ├── repository/
│   │   ├── memory.go        // карта под sync.Mutex, seq, NewMemory()
│   │   └── memory_test.go
│   ├── service/
│   │   ├── tasks.go         // TaskStore (потребитель), лимит, владение
│   │   └── tasks_test.go    // fake-репозиторий
│   ├── handler/
│   │   ├── tasks.go         // TaskHandler + Register(g *echo.Group)
│   │   └── tasks_test.go    // fake-сервис
│   └── app/
│       └── app.go           // echo.New, middleware, ErrorHandler, echojwt
├── Dockerfile               // multi-stage из двадцать четвёртого урока
├── go.mod                   // module tasksapp
└── deploy/
    └── prod.env             // JWT_SECRET, PORT — вне git

Это дерево из двадцать первого урока, доросшее до финала: пять пакетов под internal, тесты рядом с кодом, деплой-артефакты — в корне и deploy/. Ничего не изобретено заново — и в этом оценка: если каждый файл откликается на конкретный урок, структура собрана правильно. Правило internal осталось страховкой компилятора: use of internal package not allowed для чужих модулей. prod.env живёт вне git и вне образа — секреты в историю слоёв не попадают.

Домен и сервис: владение задачей как бизнес-правило#

В финале канон получает одно новое существо — владельца задачи. До сих пор задача была {id, title, done}: анонимной, потому что аутентификации не было. Как только в пятнадцатом уроке у токена появились клеймы, у каждой задачи появился хозяин: sub из JWT. Поле Owner в домене — не украшение, а ось авторизации: аутентификация отвечает «кто ты» (токен проверяет echo-jwt до входа в хендлер), авторизация — «тебе можно трогать это» (правило в сервисе). Разница стоит статуса: нет токена или истёк — 401 Unauthorized; токен валиден, но задача чужая — 403 Forbidden. Перепутать — значит сказать клиенту, что ресурс существует и защищён неправильно.

Где живёт правило владения? Там же, где лимит сотни задач: в сервисе. Хендлер — переводчик HTTP, ему запрещено знать правила; репозиторий — хранитель, ему запрещено знать «можно». Поэтому сервис читает задачу через хранилище, сравнивает t.Owner с клеймом вызывающего и возвращает domain.ErrForbidden, если совпадения нет. Интерфейс TaskStore подрос под полный CRUD: List, ByID, Create, Update, Delete — и по-прежнему объявлен потребителем, сервисом, а не репозиторием: «the bigger the interface, the weaker the abstraction», сужаем до того, что реально зовётся. Sentinel-ошибки — в domain, чтобы repository и service ссылались на одно имя без цикла импортов.

Канон: domain и service с правилами

GO
// internal/domain/task.go
package domain

import "errors"

type Task struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Owner string `json:"owner"` // sub из JWT — владелец
    Done  bool   `json:"done"`
}

var (
    ErrNotFound  = errors.New("task not found")
    ErrForbidden = errors.New("not the owner")
)

// internal/service/tasks.go — фрагмент
package service

var ErrLimitReached = errors.New("task limit reached")

type TaskStore interface { // потребитель объявляет, repository реализует
    List(offset, limit int) ([]domain.Task, int, error)
    ByID(id int) (domain.Task, error)
    Create(t domain.Task) (domain.Task, error)
    Update(t domain.Task) (domain.Task, error)
    Delete(id int) error
}

func (s *TaskService) Create(title, owner string) (domain.Task, error) {
    if s.countByOwner(owner) >= limitPerUser { // бизнес-лимит — здесь
        return domain.Task{}, ErrLimitReached
    }
    return s.store.Create(domain.Task{Title: title, Owner: owner})
}

func (s *TaskService) Delete(id int, caller string) error {
    t, err := s.store.ByID(id)
    if err != nil {
        return err // domain.ErrNotFound едет наверх как есть
    }
    if t.Owner != caller { // владение — правило бизнеса
        return domain.ErrForbidden
    }
    return s.store.Delete(id)
}

Поток ошибок из двадцать первого урока не изменился: sentinel внизу, движение наверх без переупаковки, перевод один раз — в ErrorHandler. ErrForbidden в domain по той же причине, что ErrNotFound: его производят правила сервиса, а форматит десятый урок. Обратите внимание на Create(title, owner string): хендлер достаёт owner из клейма echo-jwt и передаёт аргументом — сервис не знает ни JWT, ни echo, ни c.Get. Тест правила — три строки на fake-репозитории: чужой владелец даёт ErrForbidden, свой — удаление.

Контракт: методы, пути, статусы и заголовки#

rest api echo узнаётся по таблице, а не по коду: какие методы, какие пути, какие статусы — и что происходит при ошибке. Семнадцатый урок задал правила, финал их скрепляет. Ресурс — tasks, идентификатор — path-параметр :id из третьего урока. Статусы честные: 201 Created с заголовком Location: /api/v1/tasks/7 на создание; 200 на чтение и обновление; 204 NoContent на удаление — c.NoContent из четвёртого урока. Идемпотентность — по RFC 9110: GET, PUT и DELETE повторяются безопасно, POST — только с заголовком Idempotency-Key и картой replay из семнадцатого урока. Пагинация: page и limit как query-параметры, клампы в сервисе (limit ≤ 100, page ≥ 1), ссылка на следующую страницу — заголовком Link по RFC 8288. Лимит на пользователя — 429 TooManyRequests с Retry-After: слишком много — временная проблема, а не 400 и не 413.

Заголовок X-API-Version на группе /api/v1 остался с одиннадцатого урока, Sunset из RFC 8594 — с семнадцатого: контракт должен сам сообщать о своей версии и дате заката. Все ответы об ошибках — в одном конверте {"error": "…", "code": "…"} ErrorHandler-а: клиент парсит не статусы, а коды. Таблица ниже — это и есть ТЗ в сжатом виде: держите её в README проекта, она же — чеклист тестовой матрицы.

Контракт канона на :1323#

Метод и путьКтоУспехОшибкиЧто делает
POST /api/v1/loginвсе200 + {access, refresh}401 invalid_credentialsвыдаёт пару токенов из пятнадцатого урока
POST /api/v1/refreshвсе200 + {access, refresh}401 expiredменяет refresh на свежую пару
GET /api/v1/tasksBearer200 + список + Link401страница задач владельца, клампы в сервисе
GET /api/v1/tasks/:idBearer200 + Task401, 404 task_not_found, 403чужая задача — 403: она есть, но не ваша
POST /api/v1/tasksBearer201 + Location + Task400 validation_failed, 429 limit_reached + Retry-Afterидемпотентен по Idempotency-Key
PUT /api/v1/tasks/:idBearer + владелец200 + Task401, 400, 403, 404обновление: правило владения в сервисе
DELETE /api/v1/tasks/:idBearer + владелец204 NoContent401, 403, 404идемпотентен по определению RFC 9110
GET /healthz, /readyz, /metricsвсе200 / 503 / текст503 not readyпробы и метрики двадцать третьего урока

JWT и роли: echo-jwt на группе, клеймы в контексте#

Пятнадцатый урок оставил вам всё, что нужно финалу, — здесь только сборка. На группе /api/v1 стоит echojwt.Config с HS256 и секретом из окружения; /login и /refresh живут на публичной подгруппе без гварда. Клеймы — sub и role: sub становится владельцем задач, role разграничивает будущие админские эндпоинты (в каноне роль admin умеет List по всем владельцам — одна проверка в сервисе, ещё один аргумент держать правила там). Хендлер достаёт личность из c.Get("user") — echo-jwt кладёт туда token *jwt.Token, из MapClaims вытаскивается sub — и передаёт строкой в сервис. Ни один слой ниже хендлера не знает слова JWT: это HTTP-механика перевода идентичности в аргумент.

Чего в финале быть не должно: чтения Authorization вручную и сравнения строк. bearerAuth восьмого урока был ступенькой, финал её снимает: источник токена, валидация подписи и exp — работа echo-jwt, отказ — его 401, который ErrorHandler форматирует в конверт. Refresh-поток тоже каноничен: access живёт 15 минут, refresh 30 суток, отзыв refresh-ов — отдельная задача реестра, потому что JWT по своей природе безгосударственен; в ТЗ проекта это честно записано ограничением, а не спрятано.

Проверьте себя: 401 или 403

Задача 7 принадлежит борису. Анна пришла с валидным access-токеном (подпись верна, exp не вышел) и вызвала DELETE /api/v1/tasks/7. Что отвечает канон и почему?

Валидация и конверт: два фильтра на входе, один формат на выходе#

Вход проекта охраняют два фильтра подряд. Первый — валидация из пятого урока: struct-теги validate на DTO, echo.Validator, вызов c.Validate в хендлере; пустой title, слишком длинная строка или битое число в query — 400 validation_failed ещё до сервиса. Второй фильтр — бизнес: лимит на пользователя и владение; их к сервису выпускают только с корректными данными. Разводка принципиальна: формат запроса — обязанность границы, правила — обязанность сервиса; тест каждого фильтра не тянет второй.

Выход — один на весь проект. ErrorHandler из десятого урока — единственное место, где доменная ошибка становится HTTP-ответом: errors.Is разворачивает sentinel-цепочку fmt.Errorf с %w, таблица переводов фиксирует статус и код конверта, логирует только 500-е с деталями Internal, клиенту — общий текст. Финальная таблица канона: domain.ErrNotFound → 404 task_not_found, domain.ErrForbidden → 403 forbidden, service.ErrLimitReached → 429 limit_reached + Retry-After: 30, *echo.HTTPError от валидатора → 400 validation_failed, отказ echo-jwt → 401 unauthorized, всё остальное → 500 internal_error. Хендлеры при этом ни разу не зовут echo.NewHTTPError с бизнес-ошибками — только для ошибок перевода запроса вроде нечислового :id, как договорился двадцать первый урок.

Канон: ErrorHandler финальной версии

GO
// internal/app/app.go — фрагмент
e.HTTPErrorHandler = func(err error, c echo.Context) {
    code, apiCode := http.StatusInternalServerError, "internal_error"

    switch {
    case errors.Is(err, domain.ErrNotFound):
        code, apiCode = http.StatusNotFound, "task_not_found"
    case errors.Is(err, domain.ErrForbidden):
        code, apiCode = http.StatusForbidden, "forbidden"
    case errors.Is(err, service.ErrLimitReached):
        code, apiCode = http.StatusTooManyRequests, "limit_reached"
        c.Response().Header().Set("Retry-After", "30")
    }

    var he *echo.HTTPError // ошибки фреймворка: 400 валидатора, 401 echo-jwt, 404 маршрута
    if errors.As(err, &he) && apiCode == "internal_error" {
        code, apiCode = he.Code, apiCodes[he.Code] // map[int]string: 400→validation_failed, 401→unauthorized
    }

    if code >= 500 {
        c.Logger().Error("internal", "request_id", c.Get(echo.HeaderXRequestID), "err", err)
        err = errors.New("internal error") // детали — в лог, не клиенту
    }
    _ = c.JSON(code, map[string]string{"error": err.Error(), "code": apiCode})
}

Один switch держит весь контракт ошибок: добавление нового доменного отказа — строка в таблице и кейс в тест-матрице, ноль правок хендлеров. Retry-After ставится рядом со своим 429 — связка из семнадцатого урока, которую клиент обязан уважать. RequestID из седьмого урока прилетает в лог 500-х — иначе расследование по slog-строкам превратится в угадайку. Не Found превращается в честный generic: стек и внутренности — приборам, не внешнему миру.

Что вернёт повторный POST с тем же Idempotency-Key

Прочитайте код и выберите, что он выведет:

// клиент потерял связь и ретраит создание:
//   1-й POST /api/v1/tasks  Idempotency-Key: abc-123 → 201, {"id":7,...}
//   сеть упала; клиент повторяет точно тот же запрос
//   2-й POST /api/v1/tasks  Idempotency-Key: abc-123 → ?

Тесты проекта: та же пирамида, новые кейсы#

Пирамида двадцатого урока переживает финал без перестройки. Внизу — сервис с fake-репозиторием: лимит, владение, клампы пагинации — три правила, три сценарных отказа ручкой err. Средний уровень — хендлер с fake-сервисом: перевод запроса в вызов и статусы, включая нечисловой :id, который обязан дать 400 на границе. Вершине — честный тест repository.Memory: создать, прочитать, удалить, поймать ErrNotFound. И над всем — стек-тест на сборке app.New с fake-хранилищем: e.ServeHTTP из двадцатого урока прогоняет матрицу контракта целиком, включая echo-jwt и ErrorHandler — то, что юниты поодиночке не увидят.

Финал добавляет в матрицу два класса кейсов. Авторизационные: без токена — 401, валидный токен чужой задачи — 403, свой — 204; они держат таблицу ТЗ на замке. Идемпотентные: два POST с одним ключом — одинаковые тела и одинаковый статус, задача одна. Запуск обязателен с гонками: go test -race ./... — карта replay и репозиторий под мьютексом обязаны молчать; это тот же инструмент, что ловил двойной Create ещё в восьмом уроке. Команда приёмки проекта: go vet ./... && go test -race ./... — зелёная, прежде чем соберётся первый образ.

Канон: стек-матрица контракта e.ServeHTTP

GO
func TestContractMatrix(t *testing.T) {
    tests := []struct {
        name   string
        req    func() *http.Request
        want   int
        inCode string // ожидаемый код конверта, "" — не ошибка
    }{
        {"create без токена", rPOST("/api/v1/tasks", body), 401, "unauthorized"},
        {"create битое тело", rPOSTAuth("/api/v1/tasks", "{"), 400, "validation_failed"},
        {"create ок", rPOSTAuth("/api/v1/tasks", body), 201, ""},
        {"create повтор с тем же ключом", rPOSTAuthIdem("/api/v1/tasks", body, "k1"), 201, ""},
        {"list чужих страниц нет", rGETAuth("/api/v1/tasks?page=-3"), 200, ""},
        {"get чужой задачи", rGETAuthAs("/api/v1/tasks/7", anna), 403, "forbidden"},
        {"get не существует", rGETAuth("/api/v1/tasks/999"), 404, "task_not_found"},
        {"delete своего", rDELAuth("/api/v1/tasks/7", boris), 204, ""},
        {"delete своего повторно", rDELAuth("/api/v1/tasks/7", boris), 404, "task_not_found"},
        {"нечисловой id", rGETAuth("/api/v1/tasks/abc"), 400, "validation_failed"},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            e := app.New(newFakeStore(), testSecret, false) // свежая сборка на кейс
            rec := httptest.NewRecorder()
            e.ServeHTTP(rec, tt.req())
            if rec.Code != tt.want {
                t.Fatalf("status: got %d, want %d", rec.Code, tt.want)
            }
            assertConvert(rec.Body, tt.inCode) // конверт {error, code}
        })
    }
}

Свежий app.New на каждый кейс — то правило из двадцатого урока, которое в финале важнее всего: повторное удаление обязано уйти в 404, а не наткнуться на след предыдущего кейса. Матрица — это исполняемая версия таблицы контракта: каждая строка ТЗ имеет кейс, каждый статус из таблицы проверен числом. Такие матрицы читаются ревьюером без запуска — и именно они продают проект: видно, что контракт придуман до кода.

Прод: наблюдаемость и выкат уже настроены#

Последняя треть проекта не пишет нового кода — она включает переключатели, которые вы собрали в трёх предыдущих уроках. Прод-режим из двадцать второго: prod := os.Getenv("APP_ENV") == "production", Release-настройки и таймауты http.Server параметрами конструктора. Наблюдаемость из двадцать третьего: middleware RequestID и Logger со slog-JSON из девятого, пробы /healthz и /readyz (ready гасится до прогрева и отвечает 503), /metrics с echo_requests_total и ярлыком версии — HEALTHCHECK Docker-образа бьёт ровно в эти ручки. Выкат из двадцать четвёртого: CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=$(git describe)", multi-stage Dockerfile с USER nonroot и ca-certificates, SIGTERM → e.Shutdown(8s) внутри 10-секундного бюджета docker stop, errors.Is(err, http.ErrServerClosed) в горутине запуска, hub.Close() после Shutdown, если держите WebSocket-комнату из восемнадцатого урока.

Сборка финала — момент истины для архитектуры: main.go остаётся трёхстрочным (хранилище → app.New → e.Start), потому что двадцать первый урок уже развёл слои, а двадцать четвёртый научил main останавливаться. Если при сборке проекта main пришлось править слоями — где-то протекла зависимость; вернитесь к дереву из начала урока и найдите, кто вызывает чужого. Красота финала в том, что сборка должна занять вечер, а не неделю: всё уже написано по дороге.

Проверьте себя: первый запрос на свежем деплое

Контейнер канона только что стартовал. Вы вызываете POST /api/v1/tasks без заголовка Authorization. Кто и с чем ответит?

Соберите композицию main.go

Восстановите три осмысленные строки composition root: хранилище, сборка приложения, старт.

secret := os.Getenv("JWT_SECRET") // пустой секрет — отказ до старта prod := os.Getenv("APP_ENV") == "production" store := repository.() e := app.(store, []byte(secret), prod) slog.Info("starting", "version", version, "addr", ":1323") e.(":1323") // в реальном main — в горутине с errors.Is(err, http.ErrServerClosed)

Порядок: путь проекта от ТЗ до приёмки

Расставьте шаги реализации проекта в порядке, при котором каждый следующий опирается на готовый предыдущий.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: выпускной словарь курса

Термин

REST API echo

нажмите, чтобы перевернуть
Определение

Ресурсы вместо глаголов, честные статусы (201+Location, 204, 403, 404, 429), один конверт {error, code} на все ошибки, идемпотентность по RFC 9110: GET/PUT/DELETE — всегда, POST — с Idempotency-Key и картой replay.

1 / 6

Проект сдан: двадцать пять уроков за плечами#

Оглянемся на маршрут, который прошли. Мы начали с вопроса «что такое Echo и чем оно отличается от соседей», подняли первый сервер на :1323, научились маршрутизировать и читать запрос, ввели валидацию, построили свои цепочки middleware и встроили стандартные, завели slog и единый конверт ошибок, охраняли группы, выдавали cookie-сессии и JWT, закрывали CORS и CSRF, договаривались REST-контрактом, держали WebSocket и SSE, доказали всё тестами, разложили по слоям, ускорили (производительность), научили отвечать приборам и останавливаться вежливо — и сегодня собрали итоговый rest api echo, где каждый файл откликается на урок, а каждый пункт ТЗ — на строку тест-матрицы.

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; финальный проект собирается и запускается у вас, и в этом его ценность: это настоящий репозиторий с историей, тестами и Docker-образом, который можно показать работодателю. Куда дальше: курс Gin покажет те же принципы на чистом net/http-фундаменте — сравнение станет практикой, а не таблицей; go-advanced углубит сам язык — горутины, каналы и конкурентность в рабочем исполнении. Домашнее задание ниже — выпускной экзамен: пройдите его целиком, не подсматривая в уроки. Навигация по курсу — на странице курса Echo.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Итоговый проект echo — сборка канона, а не новый материал: задачник на :1323 получает владельцев (Owner = sub из JWT), полный CRUD, роли, идемпотентный POST и приёмку приборами. ТЗ — десять требований, чеклист приёмки — Definition of Done из десяти «да».
  • Авторизация живёт в сервисе: t.Owner != caller → domain.ErrForbidden → 403 forbidden в ErrorHandler. Аутентификация — echo-jwt на группе /api/v1 (HS256, клеймы sub/role, /login и /refresh публичны): 401, когда сервер не узнал; 403 — когда узнал, но нельзя.
  • Контракт в таблице: 201+Location на создание, 204 на удаление, 429 limit_reached с Retry-After, 400 validation_failed на границе, 404 task_not_found, 403 forbidden, 401 unauthorized, 500 generic. Пагинация с клампами и Link; Idempotency-Key с картой replay против дублей.
  • Слои не изменились: handler переводит HTTP, service владеет TaskStore (потребитель объявляет) и правилами, repository прячет хранилище за мьютексом, app.New собирает middleware/ErrorHandler/echojwt, main — composition root на три строки.
  • Тесты — та же пирамида: fake-репозиторий для сервиса, fake-сервис для хендлера, честный memory_test, стек-матрица e.ServeHTTP поверх app.New со свежей сборкой на кейс; запуск только с go test -race ./... — карта replay и Memory под мьютексом обязаны молчать.
  • Прод собран из трёх последних уроков: APP_ENV и таймауты из двадцать второго, /healthz /readyz /metrics и slog-JSON из двадцать третьего, Dockerfile multi-stage + nonroot + graceful shutdown (SIGTERM → e.Shutdown 8s → код 0) из двадцать четвёртого.
  • Курс закрыт: 25 уроков от echo.New до docker stop; дальше — курс Gin как вторая реализация тех же принципов и go-advanced для глубины языка; выпускной экзамен — домашнее задание этого урока.

Домашнее задание

Выпускной экзамен: соберите проект с нуля и пройдите приёмку по частям. Первое — скелет: go mod init tasksapp, дерево из начала урока, domain с Task{ID, Title, Owner, Done} и двумя sentinel-ошибками; go build ./... обязан пройти при пустых слоях. Второе — хранилище и сервис: repository.Memory с мьютексом и seq, TaskStore (List/ByID/Create/Update/Delete), лимит 100 задач на владельца, владение в Delete/Update, клампы пагинации; fake-репозиторий покрывает ErrLimitReached, ErrForbidden, ErrNotFound — по тесту на каждый. Третье — контракт: handler с Register на группе, ErrorHandler финальной версии со всеми строками таблицы, Retry-After у 429; нечисловой :id даёт 400 в хендлере. Четвёртое — аутентификация: /login и /refresh из пятнадцатого урока (access 15 минут, refresh 30 суток), echojwt на /api/v1, sub становится Owner-ом; напишите кейс «валидный токен анны, задача бориса → 403 forbidden» и кейс «просроченный токен → 401 unauthorized». Пятое — идемпотентность: middleware Idempotency-Key с картой replay и TTL; два POST с одним ключом дают одинаковое тело и статус, задача одна; тест с -race на параллельные повторы. Шестое — матрица: стек-тест из урока (10 кейсов минимум) на app.New с fake-хранилищем, свежая сборка на кейс; go vet ./... && go test -race ./... — зелёные. Седьмое — выкат: версия через -ldflags, Dockerfile multi-stage с nonroot и HEALTHCHECK по /healthz, prod.env вне git; под нагрузкой выполните docker stop и покажите в логах, что Shutdown дождался in-flight запросов, а код выхода — 0. Восьмое — защита: напишите README с таблицей контракта, перечнем уроков курса на каждый файл проекта и списком известных ограничений (отзыв refresh-токенов, TTL карты replay); устно проговорите, почему владение — в сервисе, а не в хендлере, и что сломается при переносе хранилища в PostgreSQL. Зачёт — десять «да» чеклиста Definition of Done.

Личные заметкиПусто
Хранятся только в вашем браузере (Echo · Итоговый проект: API на Echo)

Вопросы и ответы по уроку

Чем итоговый проект echo отличается от аналогичного на Gin или Fiber?

Требования контракта совпадают почти дословно — CRUD, JWT, валидация, тесты, деплой не зависят от фреймворка. Различия — в механике уровня фреймворка: Echo даёт типизированный echo.Context, HTTPErrorHandler как единую точку перевода ошибок и echo-jwt из echo-contrib; у Gin — gin.Context и ShouldBind, у Fiber — fasthttp-фундамент и свой обработчик ошибок. Слои, интерфейс потребителя, sentinel-ошибки и матрица тестов переносятся между фреймворками один в один — поэтому проект ценен не как «пример на Echo», а как умение строить контракт и границы.

Что расширять в проекте после курса, чтобы он рос в портфолио?

Замена repository.Memory на PostgreSQL — самый честный следующий шаг: сервис не меняется ни на строку, если интерфейс TaskStore не тронут, а миграции и sql-слой лягут в отдельный пакет. Дальше по вкусу: refresh-токены с реестром отзывов, rate limiting per-IP из кастомных middleware, OpenAPI-схема, сгенерированная из таблицы контракта, Prometheus-алерты на долю 5-хх, CI с go test -race и docker build на каждый тег. Каждое расширение — это тот же цикл: пункт ТЗ → строка в таблице → кейс в матрице → приёмка.

Как понять, что проект недостаточно хорош, и что чаще всего снимают на защите?

Три красных флага: правило в хендлере (владение или лимит, найденные в tasks.go слоя handler), статус, которого нет в таблице контракта, и тест, который падает при втором запуске (состояние протекло между кейсами). На защите чаще всего проверяют именно границы: где проверяется владение (сервис), кто форматирует ошибку (один ErrorHandler), что случится при подмене хранилища (ничего), почему 403, а не 404 (решение контракта, зафиксированное в ТЗ и тесте). Если можете ответить на эти четыре вопроса, не глядя в код, — проект ваш.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

GORM25 уроков~14 чпрактика25 уроков уже доступны

GORM: ORM-библиотека для Go

Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.

  • Подключать БД и настраивать пул соединений
  • Описывать модели и связи: 1:1, 1:N, M:N
  • Писать CRUD и сложные выборки chainable-стилем
  • Контролировать N+1 через Preload