---
title: "Кастомные middleware в Echo: фабрика, Bearer-аутентификация и метрики"
description: "Пишем echo кастомный middleware: фабрика с конфигом и памятью, Bearer-аутентификация, метрики вокруг next(c), c.Set/c.Get, сквозная валидация и Skipper."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/08-kastomnye-middleware/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Кастомные middleware в Echo: фабрика, Bearer-аутентификация и метрики

*Курс «Echo: минималистичный быстрый фреймворк», урок 8 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/08-kastomnye-middleware/*

echo кастомный middleware — это звено, которое вы пишете сами, когда каталога из [прошлого урока](/courses/echo/lessons/07-vstroennye-middleware/) перестаёт хватать: токены проверяются по вашей базе, метрики нужны с бизнес-размерностью, а правило проекта обязано исполняться на каждом маршруте без исключений. Анатомия та же, что у самодельного логгера из [урока про цепочки](/courses/echo/lessons/06-middleware/): func(next echo.HandlerFunc) echo.HandlerFunc, возврат замыкания func(c echo.Context) error и вызов next(c) — никакого c.Next в Echo нет. Соберём пять звеньев: фабрику метрик с состоянием, echo auth middleware на Bearer-токене, передачу данных через c.Set/c.Get, сквозную валидацию и скипер по образцу штатных конфигов.

### echo кастомный middleware: когда каталога мало

Каталог встроенных слоёв закрывает то, что одинаково у всех проектов: логи, recovery, сжатие, идентификатор запроса. Собственный слой нужен там, где логика привязана к вашему домену: проверка токена по таблице сессий в базе, лимиты на пользователя из вашего хранилища, счётчики с бизнес-размерностью вроде «запросов на тенианта», сквозные правила API. Три надёжных признака того, что код пора оформить звеном. Первый: один и тот же фрагмент повторяется в трёх и более обработчиках — и уже начал расходиться между копиями. Второй: правило должно исполняться ДО обработчика — отказ на границе дешевле, чем отказ посреди транзакции. Третий: данные, посчитанные один раз на запрос, нужны нескольким звеньям сразу — классический пример это пользователь, выясненный аутентификацией.

Свой echo кастомный middleware проходит ту же анатомию, что loggerTimer из урока про цепочки: внешняя функция принимает следующий обработчик и возвращает замыкание, замыкание исполняется на каждый запрос, вызов следующего звена — next(c). Напомним одной строкой и миф из того же урока: метода c.Next() в Echo не существует — это API Gin и Fiber, и в рецептах про echo он всегда признак скопированного кода. План урока такой: сначала фабрика с конфигом — почему без неё не обойтись и где в ней жить состоянию; затем Bearer-аутентификация с отказом 401, передача пользователя через c.Set/c.Get, сквозная валидация, снятая с домашнего задания, и скипер по стандартам каталога.

### Фабрика с конфигом: три ступени звена

Голую функцию func(next echo.HandlerFunc) echo.HandlerFunc вы уже писали: loggerTimer в уроке про цепочки был именно ей. Для звена с настройками и состоянием оформляйте фабрику — функцию, которая принимает конфиг и возвращает echo.MiddlewareFunc, ровно как штатные фабрики каталога: Logger() и LoggerWithConfig(config) из урока про встроенные слои. Структура повторяет XConfig-паттерн: своя структура конфига, резолв дефолтов, возврат слоя.

Смотрите внимательно: у фабрики три ступени, и живут они по-разному. Первая ступень — тело самой фабрики: она исполняется один раз, в момент e.Use(metricsTimer(...)), потому что вызываете её вы сами. Здесь резолвятся дефолты, проверяется конфиг и объявляется состояние. Вторая ступень — возвращённый слой func(next echo.HandlerFunc) echo.HandlerFunc: его на каждый запрос вызывает applyMiddleware из echo.go, оборачивая найденный обработчик, — там нельзя заводить переменные, которыми хочет жить слой между запросами. Третья ступень — замыкание func(c echo.Context) error: само звено, тело которого исполняется один раз на запрос. Это важный контраст с Gin, где цепочка склеивается в момент регистрации маршрута: в Echo обёртка строится на каждый запрос — цена копеечная, пара замыканий, но правило размещения состояния другое. Свежая деталь из исходников v4: часть фабрик каталога на невалидном конфиге паникует прямо на старте — KeyAuth без Validator падает с текстом echo: key-auth middleware requires a validator function. Берите приём на вооружение: ошибка конфигурации должна стрелять при подключении, а не на первом запросе.

### metricsTimer: метрики со счётчиками в фабрике

```go
// метрики: состояние объявлено в теле фабрики —
// она исполняется один раз при e.Use
type MetricsConfig struct {
        Prefix string // метка в строке вывода
}

func metricsTimer(cfg MetricsConfig) echo.MiddlewareFunc {
        // ступень 1: один раз при подключении — здесь живёт память слоя
        var (
                mu      sync.Mutex // запросы обслуживаются конкурентно
                total   int64
                errored int64
                elapsed time.Duration
        )
        if cfg.Prefix == "" {
                cfg.Prefix = "metrics" // дефолт резолвим на старте
        }

        // ступень 2: слой — applyMiddleware зовёт его на каждый запрос
        return func(next echo.HandlerFunc) echo.HandlerFunc {
                // ступень 3: само звено — один раз на запрос
                return func(c echo.Context) error {
                        start := time.Now() // фаза запроса

                        err := next(c) // дальше по цепочке — к обработчику

                        // фаза ответа: статус и время уже известны
                        latency := time.Since(start)
                        mu.Lock()
                        total++
                        elapsed += latency
                        if err != nil {
                                errored++
                        }
                        mu.Unlock()
                        fmt.Printf("%s: total=%d err=%d avg=%s last=%s\n",
                                cfg.Prefix, total, errored,
                                elapsed/time.Duration(total), latency)

                        if err != nil {
                                c.Error(err) // глобальный обработчик оформит ответ
                        }
                        return err
                }
        }
}

// подключение в main():
e.Use(metricsTimer(MetricsConfig{Prefix: "api"}))
```

Три ступени помечены комментариями — сверьтесь, где объявлено состояние: в теле фабрики, а не в слое. mu защищает счётчики: сервер обслуживает соединения конкурентно, у каждого соединения своя горутина, и две одновременные записи в int64 без мьютекса — гонка данных (проверяется go run -race). avg считается от накопленной суммы — средняя задержка за жизнь процесса. c.Error(err) до печати — канон loggerTimer из урока 06: обработчик ошибок фиксирует ответ, и строка метрик видит честный статус. Про песочницу: как и весь курс Echo, этот урок не исполняется браузерным движком сайта — runner-ов здесь нет и не будет, код запускается локально: go mod init, go get github.com/labstack/echo/v4, затем go run и curl.

### Латентность и статус: что видно после next(c)

Метрики живут по обе стороны next(c), и у каждой стороны свои возможности. До next(c) доступна фаза запроса: метод, путь, заголовки — можно поставить метку start := time.Now(). После next(c) наступает фаза ответа: время считается time.Since(start), ошибка внутренних слоёв лежит в err, а c.Response().Status содержит статус ответа — но только если ответ к этому моменту оформлен. Вот тонкость, на которой ломаются самодельные метрики. Когда нижнее звено, например аутентификация, возвращает echo.NewHTTPError и не вызывает next(c), ответа ещё нет: файл response.go при сбросе контекста ставит Status в http.StatusOK, и до оформления ответа там будет дефолтный 200. Оформлением занимается глобальный HTTPErrorHandler — строка if err := h(c); err != nil { e.HTTPErrorHandler(err, c) } из ServeHTTP, — но она срабатывает ПОСЛЕ выхода из вашего слоя. Поэтому loggerTimer из урока про цепочки и штатный Logger зовут c.Error(err) до печати: этот вызов тут же проводит ошибку через обработчик, ответ фиксируется, и статус в c.Response().Status становится настоящим — 401, 400, 500. Пропустите c.Error — и метрика молча запишет отказ аутентификации как двухсотый ответ.

### Слой без c.Error: какой статус напечатается

```go
// метрики-слой, но c.Error(err) кто-то удалил:
func metricsTimer(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
                start := time.Now()

                err := next(c) // bearerAuth ниже вернёт 401-ошибку

                fmt.Printf("%s %s -> %d %s\n",
                        c.Request().Method, c.Path(),
                        c.Response().Status, time.Since(start))
                return err
        }
}

// e.Use(metricsTimer)
// api := e.Group("/api", bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}))
// curl -i localhost:1323/api/profile   (без заголовка Authorization)
```

**Что выведет программа?**

- ✅ Строка GET /api/profile -> 200 ...: статус в момент печати ещё дефолтный, клиент при этом получит 401
- Строка GET /api/profile -> 401 ...: bearerAuth вернул ошибку с кодом 401
- Строка не напечатается вовсе: код после next(c) пропускается, когда внутренний слой вернул ошибку
- 401 и в строке, и клиенту, причём строка напечатается дважды: один раз слой, один раз ServeHTTP

err := next(c) вернул 401-ошибку, но никто не позвал c.Error: ответ не оформлен, Committed равен false, а c.Response().Status держит дефолт http.StatusOK, который response.go ставит при сбросе контекста. На печати — 200, хотя клиенту ServeHTTP после выхода из цепочки оформит 401 через HTTPErrorHandler. Код после next(c) при ошибке исполняется как ни в чём не бывало — раскручивание цепочки это обычный возврат из функции. Лечение: c.Error(err) до печати, как в loggerTimer и штатном Logger.

### echo auth middleware: Bearer-токен из заголовка Authorization

Запрос «echo auth middleware» в поиске — это почти всегда Bearer-схема: заголовок Authorization вида Bearer <token>, стандарт RFC 6750, так ходят токены OAuth 2.0 и JWT. Соберём заглушку своими руками — а заменой настоящей проверке она станет после того, как сравнение токена подменится походом в базу сессий. Механика фазы запроса: достать заголовок через c.Request().Header.Get(echo.HeaderAuthorization) — Echo не нужна своя обёртка, заголовок живёт в обычном *http.Request; проверить префикс strings.HasPrefix(auth, "Bearer ") и срезать его strings.TrimPrefix-ом; сравнить токен с ожидаемым. Отказ — return echo.NewHTTPError(http.StatusUnauthorized, ...): ошибка без вызова next(c), клиент получает 401 с телом {"message":...} от глобального обработчика. Подробная анатомия HTTPError — Message как interface{}, поле Internal, собственный ErrorHandler с единым форматом — тема десятого урока, там и продолжим. Две честные оговорки наперёд. Первая: RFC 6750 советует при отказе ставить заголовок WWW-Authenticate: Bearer — штатный KeyAuth этого не делает, а ваша фабрика может, это одна строка перед return. Вторая: у каталога есть готовый middleware.KeyAuth с AuthScheme "Bearer" по умолчанию — своё звено оправдано, когда проверка ходит в вашу базу или кладёт в контекст больше одного значения.

### bearerAuth: фабрика аутентификации

```go
// ключи контекста — константы, а не строковые литералы
const ctxUserKey = "auth.user"

// конфиг фабрики: по образцу XConfig из каталога
type AuthConfig struct {
        Token    string // «пароль» заглушки
        Username string // кем считаем владельца токена
}

func bearerAuth(cfg AuthConfig) echo.MiddlewareFunc {
        // ступень 1: конфиг проверяется один раз при подключении
        return func(next echo.HandlerFunc) echo.HandlerFunc {
                // ступень 2: слой — здесь состояния быть не должно
                return func(c echo.Context) error {
                        // фаза запроса: разбор заголовка Authorization
                        auth := c.Request().Header.Get(echo.HeaderAuthorization)
                        if !strings.HasPrefix(auth, "Bearer ") {
                                return echo.NewHTTPError(
                                        http.StatusUnauthorized,
                                        "нужен bearer-токен в заголовке Authorization",
                                )
                        }
                        token := strings.TrimPrefix(auth, "Bearer ")
                        if token != cfg.Token {
                                return echo.NewHTTPError(
                                        http.StatusUnauthorized,
                                        "неизвестный токен",
                                )
                        }

                        // успех: данные для звеньев ниже — и дальше по цепочке
                        c.Set(ctxUserKey, cfg.Username)
                        return next(c)
                }
        }
}

// подключение: защищённая группа из урока про цепочки
api := e.Group("/api", bearerAuth(AuthConfig{
        Token:    "dev-token-42",
        Username: "alice",
}))
```

Префикс проверяется с хвостовым пробелом — «Bearer » именно так: TrimPrefix срежет ровно схему, и токен останется без ведущего пробела. Имя заголовка в HTTP регистронезависимо: Header.Get приводит его к канонической форме, поэтому Authorization, AUTHORIZATION и authorization читаются одинаково — а вот само значение схемы сверяется строкой в точности. Отказ построен на echo.NewHTTPError без next(c): это прерывание цепочки из урока 06, обработчик не увидит запрос. При успехе в контекст кладётся имя пользователя под константой ctxUserKey — сразу после этого return next(c): success-ветка фабрики всегда заканчивается проходом дальше, иначе звено останется тупиком.

### Проверка: где объявлять состояние звена

**Вопрос:** Вы хотите, чтобы middleware считал запросы на протяжении всей жизни процесса. Где объявить счётчик?

- ✅ В теле фабрики, до return func(next ...): фабрика исполняется один раз при e.Use, счётчик живёт между запросами
- В слое, внутри func(next echo.HandlerFunc) echo.HandlerFunc: он тоже вызывается один раз при регистрации
- В замыкании func(c echo.Context) error: локальная переменная замыкания сохранится между вызовами
- Нигде: счётчики в middleware невозможны, нужна внешняя глобальная переменная пакета

applyMiddleware из echo.go оборачивает обработчик на каждый запрос, поэтому вторая ступень — func(next) — вызывается заново на каждый запрос: объявленный там счётчик обнулится немедленно. Замыкание третьей ступени и вовсе исполняется один раз на запрос. Тело фабрики — единственное место, которое исполняется один раз при e.Use(metricsTimer(...)); переменные, захваченные замыканиями глубже, живут столько, сколько живёт процесс. Голая функция без фабрики не держит состояния вовсе — её тело и есть вторая ступень.

### c.Set и c.Get: константы ключей и свежие нюансы

Пользователь выяснен — как о нём узнают звенья ниже? Канал тот же, что в уроке про цепочки: c.Set(key, val) и c.Get(key) any поверх ленивой карты Map в контексте запроса, создание при первом c.Set. Там же разобрана и ok-форма чтения — v, ok := c.Get(key).(string), — повторять её не будем, но закрепим дисциплину, которая в файле с тремя и более звеньями становится обязательной. Ключи — только константы: const ctxUserKey = "auth.user" объявлен рядом с bearerAuth, и опечатка теперь ловится компилятором, а не молчаливой пустой строкой в обработчике. Префикс auth. — не магия, а способ видеть в автодополнении все ключи одного звена; validateCreate ниже добавит свой ctxTodoInputKey.

Два нюанса из актуальных исходников v4.15, которых не было в прошлом разговоре. Первый: доступ к store обёрнут в sync.RWMutex — c.Set берёт блокировку на запись, c.Get на чтение, так что чтение пользователя из параллельных запросов безопасно само по себе. Второй: store — поле контекста, а контексты Echo переиспользует через пул; Reset обнуляет store в nil, поэтому данные живут ровно один запрос и не перетекают в следующий. Читают положенное три потребителя: обработчик, сквозная валидация ниже по цепочке и — если вы помните урок про встроенные слои — CustomTagFunc штатного Logger, умеющая докинуть имя пользователя в строку лога.

### Проверка: штатный KeyAuth на запросе без заголовка

**Вопрос:** Замените своё звено на штатное: e.Use(middleware.KeyAuth(validator)) с настройками по умолчанию. Прилетает запрос вовсе без заголовка Authorization. Что получит клиент?

- 401 Unauthorized: нет токена — нет доступа
- ✅ 400 Bad Request: ключ не удалось даже извлечь — это ошибка запроса, а не отказа в доступе; 401 достаётся невалидному ключу
- 500 Internal Server Error: Validator не вызывался, ошибка считается серверной
- 404 Not Found: KeyAuth обрывает цепочку без ответа

Доккомментарий key_auth.go различает два случая: «For missing key, it sends 400 - Bad Request response», «For invalid key, it sends 401 - Unauthorized response». Заголовка нет — извлечение упало на errHeaderExtractorValueMissing, ошибка оборачивается в ErrKeyAuthMissing и уходит как 400 с телом вида {"message":"missing key in the request header"}; токен есть, но не прошёл Validator — 401 с Message "Unauthorized". Дефолтный AuthScheme — "Bearer", KeyLookup — "header:Authorization". Проектируя своё звено, решите заранее, куда относить отсутствие токена: ваша заглушка отвечает 401 на оба случая, и это тоже легитимный выбор.

### Сквозная валидация: конвейер до обработчика

В домашнем задании урока про цепочки был каркас: middleware, который прогоняет структуру через c.Validate и на ошибку возвращает 400, не доходя до обработчика. Развернём его в законченный сквозной конвейер привязки и проверки — тот самый bind → normalize → validate, который собирался в уроке про привязку и валидацию, переезжает из каждого обработчика в одно звено. Идея: звено привязывает тело запроса в TodoInput, нормализует и проверяет, кладёт готовую структуру в контекст и лишь потом зовёт next(c); обработчик достаёт результат через c.Get и занимается единственным своим делом — хранилищем и ответом. Три обработчика создания сущностей — и три копии конвейера превращаются в один e.POST(path, handler, validateCreate).

У дизайна есть цена, и о ней честно. Звено, которое делает c.Bind, обязано знать структуру — значит, оно прикрепляется к конкретным маршрутам (route-level, самый близкий к обработчику уровень из таблицы урока 06), а не вешается глобально. Маршрут перестаёт нести знание о теле запроса — это плюс к единообразию и минус к локальности: читая main, вы видите validateCreate в списке middleware, а не в теле обработчика. И помните правило урока 04: c.Bind читает тело для POST/PUT/PATCH, а для GET/DELETE — query; сквозное звено валидации создания живёт только на создающих маршрутах, оставляя query-разбор обработчикам чтения.

### validateCreate: bind и validate в звене

```go
const ctxTodoInputKey = "validate.todoInput"

// сквозная валидация: подключается на создающих маршрутах
func validateCreate(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
                var in TodoInput // структура с validate-тегами из урока 05

                // POST: тело по Content-Type; битый JSON — 400
                if err := c.Bind(&in); err != nil {
                        return echo.NewHTTPError(
                                http.StatusBadRequest,
                                "тело должно быть корректным JSON",
                        )
                }
                in.Normalize() // TrimSpace заголовка до проверки правил

                // правила из e.Validator: customValidator уже упаковал
                // ValidationErrors в 400 с картой поле -> правило
                if err := c.Validate(&in); err != nil {
                        return err
                }

                // результат конвейера — звеньям ниже через контекст
                c.Set(ctxTodoInputKey, in)
                return next(c)
        }
}

func createTodo(c echo.Context) error {
        // ok-форма: звено выше могло не отработать
        in, ok := c.Get(ctxTodoInputKey).(TodoInput)
        if !ok {
                return echo.NewHTTPError(
                        http.StatusInternalServerError,
                        "validateCreate не положил TodoInput в контекст",
                )
        }

        // единственная забота обработчика — хранилище и ответ
        t := Todo{ID: nextID, Title: in.Title, Priority: in.Priority}
        nextID++
        todos = append(todos, t)

        return c.JSON(http.StatusCreated, t)
}

// в main(): e.POST("/todos", createTodo, validateCreate)
```

Ошибку c.Validate возвращаем без обёртки: customValidator из урока про привязку уже упаковал ValidationErrors в echo.NewHTTPError с картой деталей, и дефолтный обработчик доставит её клиенту как 400 {"message":{"Title":"required"}}. Ошибка c.Bind остаётся нашей — фиксированное сообщение, как в уроке 04. createTodo читает контекст ok-формой и отвечает 201: три строки bind-normalize-validate исчезли из тела, правила живут на struct-тегах, а обработчик не может получить невалидную структуру физически — до него она не доедет. Напоминание из урока 05: e.Validator должен быть назначен до регистрации маршрутов, иначе первый же c.Validate вернёт ErrValidatorNotRegistered и уедет в 500.

### Валидный токен, невалидное тело

```go
// main():
e.Use(metricsTimer(MetricsConfig{})) // с c.Error(err) до печати

api := e.Group("/api", bearerAuth(AuthConfig{
        Token:    "dev-token-42",
        Username: "alice",
}))
api.POST("/todos", createTodo, validateCreate)

// curl -i -H "Authorization: Bearer dev-token-42"
//      -H "Content-Type: application/json"
//      -d '{"title":"   "}' localhost:1323/api/todos
```

**Что выведет программа?**

- ✅ 400 с {"message":{"Title":"required"}}, строка метрик со статусом 400, createTodo не исполнялся — задача не вставилась
- 201 Created: пробелы проходят required, Normalize ничего не меняет
- 400, но строка метрик со статусом 200: ответ оформится после выхода metricsTimer
- 500 Internal Server Error: ValidationErrors — не HTTPError, обработчик ошибок её не понимает

Токен верный — bearerAuth пропускает дальше. Тело из одних пробелов проходит c.Bind, Normalize триммит заголовок в пустую строку, и required проваливается уже на проверке — та самая ловушка из урока про валидацию. customValidator упаковал нарушения в HTTPError 400, validateCreate вернул её, не позвав next(c) — обработчик и вставка в срез не исполнялись. metricsTimer — самый внешний слой: его c.Error(err) оформил ответ до печати, поэтому в строке метрик честный 400 и errored стал больше. Контраст с прошлым predict-ом — там без c.Error печатался дефолтный 200.

### Skipper в своём звене: паттерн каталога

У каждого штатного конфига из урока про встроенные слои первое поле — Skipper: func(c echo.Context) bool, true — слой молча делает return next(c). Дефолт DefaultSkipper всегда возвращает false. Ваше звено обязано уметь то же самое: слой, который нельзя выключить, быстро начинают обходить стороной — health-чеки не должны требовать токен, метрики не должны попадать в свои же счётчики, публичные маршруты не должны знать про аутентификацию.

Интеграция в три действия. Первое: поле Skipper middleware.Skipper в своей структуре конфига — тип переиспользуем из пакета middleware, свой не заводим. Второе: резолв nil-а в теле фабрики — если скипер не задан, ставим middleware.DefaultSkipper; это та же ступень «один раз при подключении», что и для дефолтов конфига. Третье: первая строка замыкания — if cfg.Skipper(c) { return next(c) }: проверка дешёвая, выполняется на каждый запрос, и при true звено исчезает из цепочки, не потратив ни одной операции на разбор заголовков. Для сравнения путей берите c.Path() — шаблон маршрута вида /api/public/*, тот же, что у тега ${route} в штатном Logger, — либо c.Request().URL.Path с реальным путём запроса; разница проявляется на path-параметрах, где шаблон стабильнее.

### bearerAuthWithConfig: дефолты, паника и скипер

```go
// конфиг дорос до скипера — тип из пакета middleware
type AuthConfig struct {
        Token    string
        Username string
        Skipper  middleware.Skipper // func(c echo.Context) bool
}

func bearerAuthWithConfig(cfg AuthConfig) echo.MiddlewareFunc {
        // ступень 1: резолв дефолтов — один раз при подключении
        if cfg.Skipper == nil {
                cfg.Skipper = middleware.DefaultSkipper // всегда false
        }
        if cfg.Token == "" {
                // ошибка конфигурации должна стрелять на старте,
                // как у штатного KeyAuth без Validator
                panic("bearer-auth: пустой токен в конфиге")
        }

        return func(next echo.HandlerFunc) echo.HandlerFunc {
                return func(c echo.Context) error {
                        // первая строка замыкания: путь пропущен —
                        // слой не работает вовсе
                        if cfg.Skipper(c) {
                                return next(c)
                        }
                        // ...далее тело bearerAuth без изменений:
                        // разбор Authorization, проверка токена,
                        // c.Set и next(c)
                        return next(c)
                }
        }
}

// подключение: health и публичные пути мимо аутентификации
e.Use(bearerAuthWithConfig(AuthConfig{
        Token: "dev-token-42",
        Skipper: func(c echo.Context) bool {
                p := c.Path() // шаблон маршрута, как ${route} у Logger
                return p == "/healthz" || strings.HasPrefix(p, "/api/public")
        },
}))
```

Это та же фабрика bearerAuth, доведённая до стандарта каталога: Skipper-поле, nil-резолв, паника на бессмысленной конфигурации — пустой токен означал бы 401 на каждый запрос до первого curl. Скипер зовётся на каждый запрос из замыкания — держите его линейным сравнением строк, без походов в базу. Глобальное подключение со скипером публичных путей — альтернатива группе из урока 06: один слой закрывает всё API, а исключения перечисляет в одном месте, вместо того чтобы разрастаться списком групп.

### Проверка: что делает слой при Skipper true

**Вопрос:** В AuthConfig задан скипер, возвращающий true для /healthz. Запрос пришёл на GET /healthz. Что происходит?

- ✅ Замыкание исполняется, но вместо всей работы делает return next(c): звено ниже получает управление, разбор заголовков не выполняется
- Слой не вызывается вовсе: Echo видит скипер при регистрации и выкидывает звено из цепочки
- Слой вернёт 200 без вызова next(c): пропущенный запрос не должен доходить до обработчика
- Паника: скипер обязан возвращать false хотя бы для одного маршрута

Скипер — не механизм регистрации, а условие внутри звена: цепочка собирается одинаково для всех маршрутов, и на /healthz замыкание исполняется, но первая же строка делает return next(c) без всякой работы. Замыкания при этом всё равно построены — applyMiddleware обёртывает обработчик на каждый запрос независимо от скипера. DefaultSkipper, который Echo ставит при nil-е, всегда возвращает false — то есть «никогда не пропускать».

### Скелеты звеньев урока: две фазы next(c)

| Звено | До next(c) | После next(c) | Что кладёт в контекст |
| --- | --- | --- | --- |
| metricsTimer | start := time.Now() | latency := time.Since(start); счётчики под мьютексом; c.Error(err) при ошибке; печать строки | ничего |
| bearerAuth | разбор Authorization, сравнение токена; отказ — 401 без next(c) | ничего — success-ветка заканчивается next(c) | ctxUserKey → имя пользователя |
| validateCreate | c.Bind, Normalize(), c.Validate; отказ — 400 без next(c) | ничего | ctxTodoInputKey → TodoInput |
| штатный Logger (07) | метка времени, буфер из sync.Pool, Skipper | строка лога с тегами ${...}; c.Error(err) при ошибке | нет, но видит чужие: ${id}, ${custom} |

> **Сравнение токенов: == против constant-time.** Для учебной заглушки token != cfg.Token — нормально. В бою сравнение строк оператором == уязвимо к timing-атакам: сравнение останавливается на первом несовпадающем байте, и по времени ответа можно вычислять корректный префикс токена посимвольно. Исходники Echo прямо предупреждают об этом в комментариях к KeyAuth и предлагают crypto/subtle: subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 — одинаковое время независимо от степени совпадения. Второе правило гигиены — не логировать Authorization: не подключайте к нему тег header:Authorization штатного Logger и не печатайте заголовок в своих звеньях; токен в логе — это скомпрометированная сессия.

> **Когда не писать своё звено.** Прежде чем заводить фабрику, загляните в каталог из урока 07: middleware.KeyAuth умеет Bearer из коробки — AuthScheme "Bearer" по умолчанию, ваш Validator ходит хоть в базу, хоть в сервис сессий; BasicAuth закрывает простые случаи; RateLimiter берёт ваш Store. JWT — отдельный модуль labstack/echo-jwt, и в курсе ему посвящён собственный урок. Своё звено оправдано, когда форматы действительно ваши: внутренние метки тениантов, подпись запроса, лимиты из собственного хранилища, комбинация проверки с метрикой. Правило то же, что у обработчиков: стандартная задача — штатный слой, доменная — фабрика.

### Соберите bearerAuth заново

Заполните пропуски: типы MiddlewareFunc, скипер, константа заголовка, статус отказа и кладка в контекст.

```go
func bearerAuth(cfg AuthConfig) echo.MiddlewareFunc {
        return func(next echo.___) echo.___ {
                return func(c echo.Context) error {
                        if cfg.___(c) {
                                return next(c) // путь пропущен: слой не работает
                        }
                        auth := c.Request().Header.Get(echo.___)
                        if !strings.HasPrefix(auth, "Bearer ") {
                                return echo.NewHTTPError(http.___, "нужен bearer-токен")
                        }
                        if strings.TrimPrefix(auth, "Bearer ") != cfg.Token {
                                return echo.NewHTTPError(http.StatusUnauthorized, "неизвестный токен")
                        }
                        c.___(ctxUserKey, cfg.Username) // данные для звеньев ниже
                        return next(c)
                }
        }
}
```

Подсказка: Слой принимает и возвращает echo.HandlerFunc; скипер хранится в конфиге и зовётся первой строкой; константа заголовка — HeaderAuthorization; отказ — 401; данные кладутся методом c.Set.

Правильные ответы в пропусках: `HandlerFunc`, `HandlerFunc`, `Skipper`, `HeaderAuthorization`, `StatusUnauthorized`, `Set`

### Соберите main() с тремя уровнями подключения

Расставьте строки в правильном порядке: фабрика метрик — глобально и снаружи, аутентификация — на группе, валидация — на маршруте. Помните правило урока 06: тот, кто должен видеть всё, добавляется первым.

Правильный порядок строк:

1. `func main() {`
2. `  e := echo.New()`
3. `  e.Validator = &customValidator{validator: validator.New()}`
4. `  e.Use(metricsTimer(MetricsConfig{Prefix: "api"}))`
5. `  api := e.Group("/api", bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}))`
6. `  api.GET("/profile", profile)`
7. `  e.POST("/todos", createTodo, validateCreate)`
8. `  e.Logger.Fatal(e.Start(":1323"))`
9. `}`

### Что дальше: логи и ошибки

Мы собрали три своих звена и ни разу не вышли за пределы модели «до next(c) — после»: фабрика с конфигом и состоянием на первой ступени, Bearer-аутентификация с отказом 401 и кладкой в контекст, сквозная валидация, снявшая конвейер с обработчиков. Порядок сборки из order-задания — репетиция реального main: сначала то, что видит всё, потом то, что отклоняет, потом то, что готовит данные. Единственный шаг от заглушек к бою — подмена сравнения токена походом в базу и fmt.Printf на настоящий логгер: куда именно вести строки метрик и как переходить на структурные логи со slog — тема следующего, девятого урока про логирование.

Вторая линия на вырост — ошибки. Все отказы урока построены на echo.NewHTTPError, но у HTTPError есть поле Internal для исходной причины, Message принимает не только строки, а собственный ErrorHandler собирает единый формат для всего API — разбор ошибок целиком занимает десятый урок. Пока же проверьте себя карточками, пройдите fill и order ещё раз с закрытыми глазами и перенесите звенья в свой учебный сервер из прошлых уроков: метрики, аутентификация и валидация собираются в один файл без единой правки обработчиков. Навигация по курсу — на [странице курса Echo](/courses/echo/), анатомия цепочки — в [уроке про middleware и цепочки](/courses/echo/lessons/06-middleware/), каталог штатных слоёв — в [уроке про встроенные middleware](/courses/echo/lessons/07-vstroennye-middleware/).

### Карточки: кастомные middleware

- **echo.MiddlewareFunc** — type MiddlewareFunc func(next HandlerFunc) HandlerFunc в echo.go. Свой слой: func(next echo.HandlerFunc) echo.HandlerFunc с возвратом замыкания func(c echo.Context) error. Следующий слой вызывается как next(c) — метода c.Next в Echo нет. applyMiddleware строит обёртку на каждый запрос.
- **Три ступени фабрики** — Тело фабрики — один раз при e.Use: дефолты, проверки конфига, состояние. Слой func(next) — вызывается applyMiddleware-ом на каждый запрос. Замыкание func(c echo.Context) error — само звено, один раз на запрос. Состояние между запросами живёт только в теле фабрики; часть фабрик v4 паникует на невалидном конфиге (KeyAuth без Validator).
- **metricsTimer** — start := time.Now() до next(c), err := next(c), latency := time.Since(start) после. Счётчики в теле фабрики под sync.Mutex — соединения обслуживаются конкурентно. c.Error(err) до печати статуса: иначе c.Response().Status останется дефолтным 200 — ответ оформит ServeHTTP после выхода из цепочки.
- **bearerAuth (echo auth middleware)** — Authorization: Bearer <token>; HasPrefix/TrimPrefix с хвостовым пробелом. Отказ — echo.NewHTTPError(http.StatusUnauthorized, ...) без next(c): 401 и {"message":...}. Успех — c.Set(ctxUserKey, user) и next(c). Штатный KeyAuth: AuthScheme "Bearer", но missing-ключ даёт 400, invalid — 401; сравнение в бою — subtle.ConstantTimeCompare.
- **c.Set / c.Get в v4.15** — Ленивая карта Map в контексте; в актуальных исходниках доступ под sync.RWMutex, Reset обнуляет store — данные живут один запрос. Ключи — в константах вроде ctxUserKey. c.Get возвращает any: чтение ok-формой v, ok := c.Get(key).(string). Потребители — обработчик, validateCreate, CustomTagFunc штатного Logger.
- **validateCreate и Skipper** — Сквозная валидация: c.Bind → Normalize() → c.Validate до обработчика; отказ 400 (ошибка Validate уходит как есть — customValidator уже обернул), валидный TodoInput через c.Set. Скипер: поле Skipper middleware.Skipper в конфиге, nil-резолв в middleware.DefaultSkipper, первая строка замыкания if cfg.Skipper(c) { return next(c) }; сравнение по c.Path().

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

- Свой middleware — func(next echo.HandlerFunc) echo.HandlerFunc с возвратом замыкания func(c echo.Context) error; следующий слой зовётся next(c), никакого c.Next в Echo нет.
- Фабрика с конфигом — три ступени: тело фабрики исполняется один раз при e.Use (дефолты, проверки, состояние), слой func(next) строится applyMiddleware-ом на каждый запрос, замыкание func(c) — само звено.
- Состояние между запросами живёт только в теле фабрики; счётчики, объявленные в слое func(next), создаются заново на каждый запрос, — loggerTimer из урока 06 не пострадал: у него состояния не было.
- Метрики-таймер: start до next(c), latency := time.Since(start) после; конкурентные запросы — мьютекс вокруг счётчиков; c.Error(err) до печати статуса, иначе увидите дефолтный 200 вместо честного 401.
- bearerAuth: Bearer-токен из заголовка Authorization, отказ — echo.NewHTTPError(401) без next(c), успех — c.Set(ctxUserKey, user) и next(c); штатный KeyAuth умеет Bearer из коробки, но missing-ключ отвечает 400, а invalid — 401.
- Сквозная валидация: c.Bind → Normalize() → c.Validate в звене до обработчика, отказ 400, готовый TodoInput через c.Set; в обработчике остаётся только хранилище и ответ.
- Скипер по паттерну каталога: поле Skipper в конфиге, nil-резолв в middleware.DefaultSkipper, первая строка замыкания if cfg.Skipper(c) { return next(c) }; сравнение путей по c.Path().

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

Дорастите звенья до боевых. Первое: добавьте в MetricsConfig поле SlowThreshold time.Duration и печатайте строку только для запросов медленнее порога — скипером исключите /healthz из счётчиков вовсе. Второе: напишите фабрику tenantAuth: идентификатор тенианта читается из заголовка X-Tenant-ID, проверка — по карте известных тениантов, при успехе имя кладётся в контекст константой, при отказе — 401 с заголовком WWW-Authenticate: Bearer. Третье: замените сравнение токена на subtle.ConstantTimeCompare и убедитесь curl-ом, что поведение не изменилось: запрос без заголовка и с неверным токеном даёт две строки метрик со статусом 401 (c.Error до печати!), запрос с правильным токеном и телом {"title":"купить хлеб","priority":3} — 201 и задачу в списке.

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

### Где объявлять состояние middleware, чтобы оно жило между запросами?

В теле фабрики — функции, которая возвращает echo.MiddlewareFunc. В Echo applyMiddleware из echo.go оборачивает обработчик на каждый запрос, поэтому переменные, объявленные в слое func(next echo.HandlerFunc) echo.HandlerFunc, создаются заново на каждый запрос и обнуляются. Тело фабрики исполняется один раз при e.Use(metricsTimer(...)), и захваченные его переменными замыкания живут столько, сколько живёт процесс. Голая функция без фабрики состояния между запросами не держит вовсе.

### Как передать данные из middleware в обработчик?

Через c.Set и c.Get: аутентификация кладёт пользователя c.Set(ctxUserKey, user), обработчик читает ok-формой v, ok := c.Get(ctxUserKey).(string). Ключи объявляйте константами рядом со звеном — опечатка в строковом литерале ловится только в рантайме. Хранилище — ленивая карта Map, в актуальных v4 доступ под RWMutex, а Reset при переиспользовании контекста обнуляет её: данные живут ровно один запрос.

### Нужно ли вызывать c.Error(err) в каждом своём middleware?

Нет: без него ответ всё равно оформится — ServeHTTP после выхода из цепочки передаст ошибку глобальному HTTPErrorHandler. c.Error нужен там, где дальше по цепочке кто-то печатает статус или пишет заголовки до выхода: штатный Logger и наш metricsTimer зовут его до печати, чтобы в строке был настоящий 401, а не дефолтный 200. Если ваш слой ничего не печатает, достаточно сохранить err и вернуть его наверх.
