---
title: "Собственный middleware: таймер, лог, авторизация"
description: "Собственный middleware в chi: таймер-лог с RequestID, авторизация по X-API-Key с 401 и 403, пользователь в контексте и тест своего слоя httptest."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/12-own-middleware/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# Собственный middleware: таймер, лог, авторизация

*Курс «Chi: роутер для Go HTTP-сервисов», урок 12 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/12-own-middleware/*

Собственный middleware go-сервиса — это та же func(http.Handler) http.Handler из урока про цепочку, только написанная под вашу боль: таймер с RequestID, авторизация по X-API-Key, пользователь в контексте. Напишем два боевых слоя, повесим их на группу и прогоним тестом httptest без сети.

Сценарий из жизни сервиса: проверка ключа скопирована из хендлера в хендлер — двенадцать раз, с той же валидацией, — и на ревью кто-то находит в одиннадцатой копии потерянный return после writeErr(w, http.StatusUnauthorized, ...). Клиент получает 401, а код ниже всё равно выполняется: заметка создаётся, платёж списывается. Компилятор такое не ловит, тесты — почти: в остальных одиннадцати копиях всё правильно. Собственный middleware в chi лечит болезнь целиком: проверка пишется один раз как func(http.Handler) http.Handler и вешается r.Use или на группу маршрутов. В этом уроке соберём два боевых слоя — apilogger с RequestID из контекста и авторизацию APIKeyMiddleware с пользователем в контексте, — закрепим правило ранних ответов и прогоним слой тестом httptest, которому не нужны ни порт, ни сеть.

### Сигнатура func(http.Handler) http.Handler: свой слой

Сигнатура собственного middleware совпадает с сигнатурой из [урока про Use и цепочку](/courses/chi/lessons/05-middleware/) — func(http.Handler) http.Handler, без единого chi-типа. Внутри — замыкание, которое ловит next, и три места для кода: до next.ServeHTTP, сам вызов и после него. Первый слой урока — apilogger, таймер-лог из того урока во взрослом виде. Две добавки. middleware.NewWrapResponseWriter(w, r.ProtoMajor) возвращает обёртку, которая запоминает статус ответа: без неё лог не может честно показать, чем закончился запрос, — базовый ResponseWriter своего статуса не отдаёт. А middleware.GetReqID(r.Context()) достаёт идентификатор, который встроенный RequestID положил в контекст запроса: каждая строка лога сшивается с этим id, и по нему же её находят соседние сервисы. Слои из стандартного пакета и ваши собственные живут в одной цепочке на равных — различие только в авторе.

### apilogger: таймер с RequestID и честным статусом

```go
func apilogger(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                start := time.Now()
                ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)

                defer func() {
                        log.Printf("[%s] %s %s: %d (%v)",
                                middleware.GetReqID(r.Context()),
                                r.Method, r.URL.Path,
                                ww.Status(), time.Since(start))
                }()

                next.ServeHTTP(ww, r)
        })
}
```

WrapResponseWriter подменяет w в эстафете: next.ServeHTTP(ww, r) — весь стек ниже пишет в обёртку, а defer после возврата читает ww.Status() и BytesWritten(). Строка лога собирается после завершения всей цепочки, поэтому показывает финальный статус — включая ранние 401 и 403 из авторизации ниже по стеку. Для запуска нужен локальный Go: песочница сайта TCP-сервер не поднимает.

### APIKeyMiddleware: 401 без ключа, 403 с чужим

Авторизация читает один заголовок и делает два отказа. r.Header.Get("X-API-Key") возвращает пустую строку, если заголовка нет, — клиент не представился: 401 Unauthorized и return. Заголовок есть, но ключа нет в map[string]user, — клиент представился, но доступа нет: 403 Forbidden. Разница не эстетическая: клиентские библиотеки на 401 повторяют запрос с новым токеном, на 403 прекращают попытки — перепутанные коды порождают бесконечные ретраи. Каждому валидному ключу соответствует user с именем и ролями, и middleware — последнее место, где уместно решать, кто стучится. Дальше пользователь отправляется вниз по цепочке: context.WithValue(r.Context(), userKey, u) создаёт новый контекст, r.WithContext(ctx) приклеивает его к запросу, и next.ServeHTTP(w, r.WithContext(ctx)) передаёт эстафету уже с пользователем. Ключ контекста — приватный тип, а не строка: вся механика разобрана в [уроке про context.Context](/courses/chi/lessons/10-context/).

### apiKeyAuth: три ветки и контекст

```go
type ctxKey string

const userKey ctxKey = "user"

type user struct {
        Name  string
        Roles []string
}

func apiKeyAuth(keys map[string]user) func(http.Handler) http.Handler {
        return func(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        key := r.Header.Get("X-API-Key")
                        if key == "" {
                                writeErr(w, http.StatusUnauthorized, "нет заголовка X-API-Key")
                                return
                        }

                        u, ok := keys[key]
                        if !ok {
                                writeErr(w, http.StatusForbidden, "ключ неизвестен")
                                return
                        }

                        ctx := context.WithValue(r.Context(), userKey, u)
                        next.ServeHTTP(w, r.WithContext(ctx))
                })
        }
}
```

Три ветки на три исхода: нет заголовка — 401, неизвестный ключ — 403, успех — контекст с пользователем и next. writeErr из прошлого урока держит формат ошибок единым. Для параноидальных сценариев сравнение секретов делают subtle.ConstantTimeCompare, как в chi-вом middleware.BasicAuth, — для внутреннего API хватает map-lookup. В хендлере пользователь достаётся type assertion с ok-формой: u, ok := r.Context().Value(userKey).(user).

> **Одиннадцатая копия авторизации.** Скопированная в каждый хендлер проверка умирает медленно: первая копия правильная, вторая тоже, десятая — ещё. Одиннадцатая получает опечатку — return после 401 потерян. Клиент видит 401, а код ниже выполняется: заметка создаётся, деньги списываются. Логи показывают 401, и все уверены, что запрос «отклонён». Такого класса баги не ловит ни компилятор, ни большинство тестов — они ловятся только структурой: одна функция, одна точка правки, r.Use на группу. Копипаста авторизации на ревью — повод для рефакторинга, а не мелочь.

### Проверка: 401 или 403

**Вопрос:** Клиент прислал заголовок X-API-Key, но такого ключа нет в map. Какой статус вернёт apiKeyAuth и почему?

- 401 Unauthorized: любая ошибка аутентификации — это 401
- ✅ 403 Forbidden: клиент представился, но доступа нет
- 400 Bad Request: заголовок есть, значение неверное
- 404 Not Found: так скрывают факт существования эндпоинта

401 означает «не представился» и предлагает попытку заново — клиентские библиотеки на неё повторяют запрос с новым токеном. 403 означает «представился, но решено отказать» — повторять бесполезно. Отсутствующий заголовок — 401, неизвестный ключ — 403; перепутанные коды дают бесконечные ретраи у клиента.

### r.Use или группа: где вешать авторизацию

r.Use(apiKeyAuth(keys)) на корневом роутере закрывает весь сервис — включая /health, который мониторинг должен опрашивать без ключей. Практичная раскладка: публичные маршруты живут на корне, защищённые — в ветке. r.Route("/api", ...) из [урока про Route и Group](/courses/chi/lessons/07-groups-routes/) даёт подроутер со своим стеком: r.Use(apiKeyAuth) внутри видит только API-запросы, а корневой Logger остаётся общим; Group работает так же, но без префикса. Два напоминания из механики цепочки. Use вызывается до первого маршрута — поздний Use приводит к панике на старте. И порядок слоёв равен пути запроса: apilogger регистрируют раньше auth, чтобы лог видел и отказы. Бонус правильного порядка — таймер считает и время авторизации: слои выше всегда платят за нижние.

### Встроенный middleware chi и момент для своего

| Встроенный middleware | Покрывает | Когда пишут свой |
| --- | --- | --- |
| middleware.Logger | строку в консоль на каждый запрос | нужны структурные поля: request_id, user, статус в JSON — свой логгер или zap |
| middleware.Recoverer | 500 и стек при панике | нужен единый формат ошибки и метрика инцидентов |
| middleware.BasicAuth | Basic-схему и map паролей | API-ключи, роли, хранилище пользователей — всё мимо его контракта |
| middleware.Throttle | глобальный лимит соединений | лимит на пользователя или ключ — считать нужно в своём слое |
| middleware.RequestID | id запроса и GetReqID | трейсинг по traceparent (W3C) — идентификатор берут из заголовков клиента |

### Ошибки в middleware: ранний ответ без next

Правило одного предложения: если дальше идти нельзя — middleware отвечает сам и не вызывает next. Клиент получает ответ, хендлер не тратит ни такта, а слои выше досчитывают своё «после» в defer: apilogger напечатает строку и для отказанного запроса. Обратная ловушка — вызвать next после собственного ответа: статус уже отправлен, и тело хендлера допишется к нему вторым куском. Ещё один способ выстрелить в ногу — вызвать next дважды: двойная строка лога, двойной счёт метрики, а в худшем случае двойная запись в ответ. Страховка от человеческого фактора — ранний return в каждой ветке отказа по образцу writeErr: ответил и вернулся. И про данные: в контекст их кладут строго до next — после собственного ответа они никому не нужны, а до вызова они уже видны всем нижним слоям.

### Проверка: потерянный return

**Вопрос:** В копии проверки потерялся return: writeErr отправил 401, но выполнение продолжилось — хендлер создал заметку и что-то записал в ответ. Что получит клиент?

- 401 и пустое тело: после WriteHeader запись в ответ невозможна
- ✅ 401 как статус, но тело хендлера допишется к ответу, а заметка останется созданной
- 500 Internal Server Error: второй WriteHeader в одном ответе запрещён
- 201 Created: последний WriteHeader выигрывает

WriteHeader отправляет статус один раз и навсегда; повторный вызов даёт superfluous WriteHeader в лог, а записи в тело продолжают дописываться. Клиент видит 401 с чужим телом, при этом бизнес-операция выполнена — рассинхрон хуже честной ошибки. Одна проверка в middleware исключает весь класс таких опечаток.

> **Authorization против X-API-Key.** X-API-Key — простой заголовок без схемы: одна строка, никаких правил, идеален для машинных клиентов и MVP. Authorization — стандарт: RFC 6750 задаёт схему Bearer, инструменты и прокси его понимают, а путь к JWT и OAuth открывается без смены заголовка. Моё правило с границей: внутренние интеграции и старт проекта — X-API-Key; пользователи в браузере или план на федерацию логинов — Authorization: Bearer с первого дня. Смена заголовка позже — это сломанные клиенты разом, дешевле выбрать его честно сразу.

### Что напечатает apilogger?

```go
r := chi.NewRouter()
r.Use(apilogger)
r.Use(apiKeyAuth(keys))
r.Post("/notes", createNote)

// запрос: POST /notes с заголовком X-API-Key: чужой-ключ
```

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

- Ничего: хендлер не выполнился, логировать нечего
- ✅ [id] POST /notes: 403 (…мс) — строка с финальным статусом и длительностью
- [id] POST /notes: 200 (…мс) — авторизация не меняет статус
- [id] POST /notes: 500 (…мс) — отказ внутри цепочки считается сбоем

apilogger оборачивает ответ и печатает строку в defer после возврата всей цепочки. apiKeyAuth ответил 403 сам и не вызывал next, но «после» у внешних слоёв всё равно срабатывает: обёртка поймала статус, таймер посчитал время. До createNote дело не дошло — а строка в логе есть.

### Тест своего middleware: httptest без сети

Middleware тестируется проще хендлеров: не нужны ни база, ни порт. Собирается минимальный роутер chi.NewRouter с одним слоем и хендлером-заглушкой; httptest.NewRequest создаёт запрос с нужным заголовком, httptest.NewRecorder — подставной ResponseWriter, который запоминает статус и тело. Вызов r.ServeHTTP(rec, req) прогоняет всю цепочку синхронно: rec.Code — итоговый статус, rec.Body — ответ. Три кейса закрывают контракт: нет заголовка — 401, чужой ключ — 403, правильный ключ — 201 и пользователь в контексте хендлера. Библиотека [testify](/courses/testify/) сокращает assert-обвязку, а системному тестированию хендлеров chi посвящён отдельный урок ближе к финалу курса. Честная оговорка урока: go test, как и сам сервер, запускается локальным Go — песочница сайта компилирует синтаксис, но сети у неё нет.

### Тест ветки 403

```go
func TestAPIKeyAuth_WrongKey(t *testing.T) {
        r := chi.NewRouter()
        r.Use(apiKeyAuth(map[string]user{"secret-1": {Name: "frontend"}}))
        r.Post("/notes", func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusCreated)
        })

        req := httptest.NewRequest("POST", "/notes", nil)
        req.Header.Set("X-API-Key", "wrong")
        rec := httptest.NewRecorder()
        r.ServeHTTP(rec, req)

        if rec.Code != http.StatusForbidden {
                t.Fatalf("хотели 403, пришло %d", rec.Code)
        }
}
```

Тест чужого ключа: роутер, слой, заглушка-хендлер, запрос с неверным X-API-Key. rec.Code равен 403 — слой отработал, до хендлера дело не дошло. Соседние проверки на 401 без заголовка и на 201 с правильным ключом делают контракт исчерпанным: три ветки middleware — три теста, и все без сети.

### Чек-лист собственного middleware

- Одна ответственность на слой: таймер меряет, авторизация пускает, логгер пишет.
- Либо next.ServeHTTP, либо собственный ответ — и return в каждой ветке отказа.
- Данные вниз — context.WithValue с приватным типом ключа, строго до вызова next.
- Код после next — в defer: он сработает и на панике нижних слоёв.
- Статус ловите через middleware.NewWrapResponseWriter — иначе у лога нет честного кода ответа.
- Тест httptest без сети: recorder, запрос с заголовком и три кейса на три ветки.

### Соберите apiKeyAuth по строкам

Восстановите сигнатуру, чтение заголовка, код отказа и передачу эстафеты с контекстом.

```go
func apiKeyAuth(keys map[string]user) func(http.Handler) http.___ {
        return func(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        key := r.___.Get("X-API-Key")
                        if key == "" {
                                w.WriteHeader(http.___)
                                return
                        }

                        ctx := context.WithValue(r.Context(), userKey, keys[key])
                        next.___(w, r.WithContext(ctx))
                })
        }
}
```

Подсказка: Сигнатура заканчивается http.Handler, заголовок читает r.Header.Get, отказ без ключа — 401 StatusUnauthorized, а эстафету передаёт next.ServeHTTP.

Правильные ответы в пропусках: `Handler`, `Header`, `StatusUnauthorized`, `ServeHTTP`

### Соберите поток авторизации

Расставьте строки middleware в правильном порядке: от сигнатуры до передачи эстафеты с контекстом.

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

1. `func apiKeyAuth(next http.Handler) http.Handler {`
2. `return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {`
3. `key := r.Header.Get("X-API-Key")`
4. `if key == "" { http.Error(w, "нет ключа", http.StatusUnauthorized); return }`
5. `ctx := context.WithValue(r.Context(), userKey, user)`
6. `next.ServeHTTP(w, r.WithContext(ctx))`

### Карточки: свои слои в цепочке

- **func(http.Handler) http.Handler** — Сигнатура любого middleware chi — встроенного и вашего: замыкание ловит next, код живёт до next.ServeHTTP, после него или вместо него. chi-типов внутри нет.
- **middleware.NewWrapResponseWriter** — Обёртка ResponseWriter, запоминающая статус и число байт: ww.Status() в defer даёт логу честный финальный код, включая ранние ответы нижних слоёв.
- **middleware.GetReqID(r.Context())** — Достаёт идентификатор запроса, положенный в контекст встроенным RequestID. Строка лога с этим id сшивается с журналами соседних сервисов.
- **401 против 403** — 401 — не представился (нет заголовка): просит попытку заново. 403 — представился, но доступа нет: повторы бесполезны. Перепутанные коды дают клиенту бесконечные ретраи.
- **r.WithContext(ctx)** — Возвращает копию запроса с новым контекстом. Пара к нему — context.WithValue с приватным типом-ключом: так пользователь попадает из middleware в хендлер.
- **Ранний ответ в middleware** — Ошибка — собственный ответ и return, next не вызывается. Слои выше досчитывают «после» в defer; вызов next после своего ответа дописывает тело хендлера к чужому статусу.

Слои собраны: apilogger с RequestID и честным статусом, apiKeyAuth с парой 401/403 и пользователем в контексте, тест без сети. По той же выкройке пишется всё остальное — замер для метрик, лимит на ключ, свой recover с JSON-ошибкой из таблицы выше. Дальше по курсу стоят предохранители chi — таймауты и троттлинг, — и обе темы опираются на механику этого урока: слой, который умеет ответить сам и пропустить дальше, уже у вас есть. Напоследок поменяйте местами r.Use(apilogger) и r.Use(apiKeyAuth) и посмотрите на лог отказанных запросов: разница между двумя строками регистрации покажет, кто кого оборачивает.

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

- Собственный middleware — та же func(http.Handler) http.Handler: chi-типов нет, слой встаёт в цепочку как встроенный.
- Статус ответа ловит middleware.NewWrapResponseWriter, идентификатор запроса — middleware.GetReqID(r.Context()).
- Нет заголовка X-API-Key — 401, ключ неизвестен — 403: первый просит представиться, второй отказывает окончательно.
- Успех — context.WithValue с приватным ключом-типом и next.ServeHTTP(w, r.WithContext(ctx)); хендлер достаёт пользователя из контекста.
- Ошибка в middleware — собственный ответ и return; вызов next после своего ответа дописывает тело хендлера к чужому статусу.
- r.Use(auth) закрывает весь сервис; группа из Route/Group ограничивает слой своей веткой — /health остаётся публичным.
- Тест слоя — httptest без сети: recorder вместо ResponseWriter и по тесту на каждую ветку.

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

Напишите apiKeyAuth с map[string]user и повесьте его на группу /api, оставив /health публичным. Проверьте curl-ом три случая: без заголовка — 401, с чужим ключом — 403, с валидным — 201 на POST /notes и имя пользователя в строке лога apilogger.

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

### Чем собственный middleware отличается от встроенного?

Только автором: сигнатура, механизм цепочки и способ регистрации идентичны. Встроенные слои chi — те же func(http.Handler) http.Handler в отдельном пакете, и ваш apiKeyAuth взаимозаменяем с ними, комбинируется в любом порядке. Разница в правах: встроенные могут трогать приватные поля пакета, ваши — только публичный API.

### Почему у 401 нет заголовка WWW-Authenticate?

По RFC 7235 ответ 401 сопровождается WWW-Authenticate со схемой — для Basic и Bearer это работает. API-ключи лежат вне стандартных схем аутентификации, и на практике заголовок часто опускают: клиент всё равно не знает, что с ним делать. Перейдёте на Authorization: Bearer — заголовок появится сам собой.

### Куда класть роли и права — в middleware или хендлер?

Граница простая: факт «кто это» решает middleware — он кладёт user с ролями в контекст. Решение «может ли он это» — хендлер или отдельный слой-владелец ресурса: проверка «заметка принадлежит этому пользователю» живёт там, где про заметки всё знают. Авторизация не зависит от бизнеса, а бизнес — от способа аутентификации.
