---
title: "Свои middleware"
description: "Пишем кастомный middleware fiber: фабрика-замыкание, аутентификация по X-API-Key с JSON-ошибкой 401, таймер запроса вокруг c.Next() и контекст c.Locals."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/07-svoi-middleware/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Свои middleware

*Курс «Fiber: экспресс-подобный веб-фреймворк на Fasthttp», урок 7 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fiber/lessons/07-svoi-middleware/*

Тема «кастомный middleware fiber» — это тот же Handler из шестого урока, только написанный под вашу задачу: аутентификация, таймер, передача данных между слоями. Разберём две формы собственного слоя (обычная функция и фабрика-замыкание, как у штатных пакетов), построим аутентификацию-заглушку с проверкой заголовка X-API-Key и JSON-ошибкой 401, замерим длительность запроса в error-стиле и передадим пользователя в хендлер через контекст запроса c.Locals. Все факты сверены с исходниками тега v3.5.0.

### кастомный middleware fiber: три задачи, которые остаются вам

В [шестом уроке](/courses/fiber/lessons/06-middleware/) мы разобрали механику до последней строчки: Handler = func(Ctx) error, c.Next() и луковица, прерывание цепочки ошибкой, канонический порядок requestid → logger → recover. Встроенных пакетов хватает для наблюдения и защиты, но третий класс задач они не закрывают — бизнес-логика доступа к вашим данным. Логгер не знает, что ключ «s3cret» открывает заметки, а таймер не понимает, кому отдавать вычисленный пользователь. Это остаётся вам, и хорошая новость в том, что нового синтаксиса нет: свой middleware — это обычная функция или фабрика, возвращающая fiber.Handler.

Разберём три классические задачи, которые встречаются почти в каждом сервисе. Первая — аутентификация-заглушка: слой проверяет заголовок X-API-Key и не пускает чужих дальше, отвечая 401 с JSON-ошибкой. Вторая — таймер: замерить, сколько запрос провёл внутри цепочки, не потеряв ошибку. Третья — контекст запроса: передать вычисленное аутентификацией значение в хендлер через c.Locals. Сквозной пример курса — API заметок — продолжается: к концу урока всё API уедет под группу /api со стражем на входе. Все факты сверены с исходниками тега v3.5.0: app.go, router.go, req.go и каталог middleware.

### Две формы своего middleware

```go
// Форма 1: обычная функция. Тип совпадает с fiber.Handler,
// поэтому её можно вешать на Use и маршруты напрямую.
func timerPlain(c fiber.Ctx) error {
        // фаза запроса: код до c.Next()
        start := time.Now()

        // эстафета внутренним слоям вплоть до хендлера
        err := c.Next()

        // фаза ответа: сюда попадаем, когда всё внутри закончилось
        fmt.Printf("%s %s -> %v\n", c.Method(), c.Path(), time.Since(start))
        return err
}

// Форма 2: фабрика. Конфигурация приходит аргументом,
// наружу возвращается готовый fiber.Handler.
func pathPrinter(prefix string) fiber.Handler {
        // замыкание видит prefix при каждом вызове слоя
        return func(c fiber.Ctx) error {
                fmt.Printf("%s: запрос %s %s\n", prefix, c.Method(), c.Path())
                return c.Next()
        }
}

// Подключение обеих форм выглядит одинаково
app.Use(timerPlain)
app.Use(pathPrinter("api"))
```

Фабрика — не особый синтаксис Fiber, а обычный Go: функция, возвращающая функцию того же типа Handler. Fiber не различает формы — на вход Use и маршрутов принимается любой хендлер, разница только в удобстве. Форма 1 хороша для слоёв без настройки, форма 2 — когда конфиг нужен: он захватывается замыканием и живёт столько, сколько живёт слой. Обратите внимание на возврат ошибки в обеих формах: err := c.Next(), затем return err — этот рисунок назовём error-стилем и разберём отдельно ниже.

### Фабрика-замыкание: конфиг на входе, слой на выходе

Зачем вообще вторая форма? Слою часто нужен конфиг: ожидаемый ключ, имя заголовка, порог срабатывания, адрес получателя лога. Обычная функция может взять это только из глобальных переменных — и вот у вас уже два сервиса дерутся за одну переменную. Фабрика решает проблему чисто: requireAPIKey("s3cret") и requireAPIKey("other") вернут два независимых слоя, каждый со своим ключом внутри замыкания. Конфиг живёт ровно столько, сколько нужен, не торчит в пакете и не тянется через параметры каждого вызова.

Второй аргумент за фабрику — единообразие со штатными пакетами. Вы уже видели этот рисунок в шестом уроке: logger.New(logger.Config{...}) принимает конфиг и возвращает fiber.Handler, то же делает recover.New(), requestid.New() и любое стороннее middleware Fiber. Промышленная версия чуть развитее: конфиг оформляется структурой Config с полем ConfigDefault и вариадиком New(config ...Config), поэтому незаполненные поля добираются из дефолта. Мы приходим к тому же интерфейсу, только с простым аргументом-строкой. Последнее правило гигиены: фабрику зовут один раз при регистрации, а не в хендлере на каждый запрос. Слой, созданный в хендлере, работает, но бессмысленно тратит память на замыкания и прячет конфигурацию там, где её не найти при чтении кода приложения.

### Проверьте себя

**Вопрос:** Чем фабрика requireAPIKey(key) fiber.Handler лучше функции-слоя, читающей ключ из глобальной переменной пакета?

- ✅ Замыкание захватывает свой ключ: два слоя с разными ключами независимы, а конфиг не торчит в глобальных переменных
- Fiber требует, чтобы middleware обязательно создавались фабрикой: обычную функцию Use не примет
- Фабрика вызывается на каждый запрос и потому быстрее читает переменные
- Глобальные переменные в Go запрещены компилятором

Use принимает любую функцию с сигнатурой func(Ctx) error — форма 1 из кода выше легальна, и никакой обязательности фабрики в Fiber нет. Фабрика выигрывает в другом: ключ изолирован внутри замыкания, слои с разными конфигами не пересекаются, регистрация читается одной строкой requireAPIKey("s3cret"). На каждый запрос фабрика не вызывается — слой создаётся один раз при регистрации. А глобальные переменные в Go разрешены, просто это плохой способ держать конфиг middleware.

### Аутентификация-заглушка: X-API-Key и JSON-ошибка 401

```go
// requireAPIKey возвращает слой-страж: без верного ключа путь закрыт
func requireAPIKey(key string) fiber.Handler {
        return func(c fiber.Ctx) error {
                // c.Get читает заголовок запроса без учёта регистра
                if c.Get("X-API-Key") != key {
                        // статус 401 и JSON-ошибка пишутся здесь, без ErrorHandler
                        return c.Status(fiber.StatusUnauthorized).JSON(
                                fiber.Map{"error": "неверный API-ключ"},
                        )
                }
                // ключ подошёл — эстафета дальше по цепочке
                return c.Next()
        }
}

// Подключение аргументом маршрута: защита только этого пути
app.Get("/api/notes", requireAPIKey("s3cret"), listNotes)
```

Три детали. Первая: c.Get возвращает значение заголовка запроса; в req.go подчёркнуто, что имена полей сравниваются без учёта регистра — X-API-Key, x-api-key и X-Api-Key для Fiber одно и то же. Вторая: fiber.StatusUnauthorized — константа для 401; c.Status возвращает сам контекст, поэтому к нему прицепляется c.JSON, и ответ уходит с Content-Type application/json. Третья: ответ записан самим слоем, return завершает функцию без c.Next() — хендлер listNotes не исполнится вовсе. Это прерывание цепочки из шестого урока, только с самодельным ответом вместо fiber.NewError.

### fiber auth middleware: почему 401 вышел JSON, а не text/plain

В шестом уроке прерывание делалось через fiber.NewError(fiber.StatusUnauthorized, ...): ошибка улетала в ErrorHandler приложения, и DefaultErrorHandler отвечал телом text/plain; charset=utf-8. Здесь мы выбрали другой путь — слой сам записал ответ через c.Status(...).JSON(...) и вернул результат как ошибку (а c.JSON возвращает error, которым обычно является nil). Для клиента разница материальная: JSON-тело удобно парсить фронтенду, text/plain — нет. Способов получить JSON-ошибку ровно два: писать ответ в самом middleware, как сейчас, или заменить ErrorHandler приложения — fiber.New(fiber.Config{ErrorHandler: ...}) — тогда и fiber.NewError начнёт отвечать JSON-ом по всему приложению. Первый способ дешевле для одного слоя, второй даёт единый формат ошибок сразу везде; к ErrorHandler мы вернёмся в уроке про обработку ошибок.

Раз строим аутентификацию, честно скажем: в комплекте Fiber есть и готовый fiber auth middleware — пакет github.com/gofiber/fiber/v3/middleware/keyauth. Его конфиг — зеркальное отражение того, что мы построили руками. Extractor достаёт ключ из запроса (дефолт — extractors.FromAuthHeader("Bearer"), для нашего случая подошёл бы extractors.FromHeader("X-API-Key")); Validator сверяет извлечённый ключ; SuccessHandler пускает дальше — дефолтно это return c.Next(); ErrorHandler отвечает при провале — дефолтно 401 и текст «missing or invalid API Key». Наша заглушка — не кустарщина, а упрощённая копия промышленного шаблона, и после этого урока чтение исходников keyauth не вызовет вопросов.

### Клиент ошибся ключом: что в ответе?

**Вопрос:** Слой requireAPIKey("s3cret") подключен к /api/notes. Клиент отправил заголовок X-API-Key со значением "wrong". Что получит клиент?

- ✅ Статус 401 и тело {"error":"неверный API-ключ"} с Content-Type application/json
- Статус 401 и тело «неверный API-ключ» в text/plain; charset=utf-8
- Статус 500 и текст ошибки в теле: слой вернул ошибку
- Статус 200 и пустое тело: слой записал ответ и вернул nil

Слой сам вызвал c.Status(fiber.StatusUnauthorized).JSON(...) — это ответ 401 с JSON-телом, а вовсе не ошибка для ErrorHandler. Второй вариант получился бы при fiber.NewError без собственного ErrorHandler — разбор в шестом уроке. Пятисотки нет: ответ записан и возвращён как результат c.JSON. Пустой 200 вышел бы, если бы слой вернул nil, не записав ничего, — тот самый тихий баг из шестого урока.

### Таймер- middleware: замер до и после c.Next()

```go
// timer возвращает слой, замеряющий длительность обработки запроса
func timer() fiber.Handler {
        return func(c fiber.Ctx) error {
                // фаза запроса: старт до всех внутренних слоёв
                start := time.Now()

                // эстафета: ошибка сохраняется, а не отбрасывается
                err := c.Next()

                // фаза ответа: замер выполняется и для ошибочных ответов
                fmt.Printf("%s %s -> %v\n", c.Method(), c.Path(), time.Since(start))

                // error-стиль: отдаём ошибку наверх без изменений
                return err
        }
}

// Подключение на всё приложение
app.Use(timer())
```

Луковица из шестого урока в чистом виде: код до c.Next() исполняется на пути запроса, код после — на пути ответа. Внутри штатного пакета responsetime ровно этот рисунок: start := time.Now(), err := c.Next(), затем c.Set(cfg.Header, time.Since(start).String()) и return err — он ставит заголовок X-Response-Time с длительностью каждого запроса (константа fiber.HeaderXResponseTime). Наш таймер — его урезанная версия с печатью в консоль вместо заголовка.

### error-стиль: return err после замера

Самая частая ошибка при написании таймера — записать return c.Next() одной строкой и поставить печать «после»: return покидает функцию немедленно, и до Printf выполнение не доходит никогда, замер молча исчезает. Правильный рисунок — три шага: err := c.Next(), замер, return err. Вторая граблина — return nil после замера при непустой ошибке: если внутренние слои вернули ошибку, а таймер её отбросил, ErrorHandler никогда её не увидит, клиент получит пустой 200 (ответ ведь никто не записал), а вышестоящий логгер не покажет ${error}. Ошибка в луковице всплывает наверх через каждый слой: ваш слой обязан либо обработать её и записать ответ, либо передать как есть. «Перехватил и забыл» — не приём, а бага.

Третье наблюдение тоньше: печать стоит после c.Next(), а не после проверки err != nil, поэтому длительность видна и для ошибочных ответов — в том числе для 401 из прошлого раздела. Слой-таймер честно меряет всё, что происходило внутри, независимо от исхода. Если хотите отдавать замер клиенту, добавьте c.Set("X-Response-Time", time.Since(start).String()) до return err — заголовок уедет с ответом; полный вариант с конфигурируемым именем заголовка — это responsetime.New() из комплекта, о котором сказано выше. И последнее: замер продолжит работать и на паникующих запросах, но только если recover-слой стоит ВНУТРИ таймера — об этом месте в порядке регистрации отдельно поговорим при сборке каркаса.

### Где пропала строка замера?

```go
func timer(c fiber.Ctx) error {
        start := time.Now()
        err := c.Next()
        if err != nil {
                // «заботливо» гасим ошибку
                return nil
        }
        fmt.Printf("%s %s -> %v\n", c.Method(), c.Path(), time.Since(start))
        return err
}

app.Use(timer)
app.Get("/boom", func(c fiber.Ctx) error {
        return fiber.NewError(fiber.StatusInternalServerError, "boom")
})
```

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

- ✅ Клиент получит пустой 200, строка замера не напечатается вовсе
- Клиент получит 500 «boom», в консоли будет строка замера
- Клиент получит 500 «boom», строки замера не будет
- Клиент получит 200, в консоли будет строка замера

Хендлер вернул fiber.NewError — ошибку 500. Таймер сохранил её в err, но ветка if err != nil вернула nil: во-первых, печать осталась за границей if и не выполнилась; во-вторых, ошибка стёрта, ErrorHandler её не получил, а ответ никто не записал — клиенту уходит пустой 200. Двойной ущерб одной строкой: исчез и замер, и ошибка. Правильно — замерять до проверки и возвращать err как есть.

### Контекст запроса: c.Locals как конверт между слоями

Слои часто обязаны передавать друг другу данные: аутентификация вычислила пользователя — хендлеру нужен логин; таймер посчитал длительность — логгеру пригодилось бы число. Заводить под это глобальную карту — гонки и утечки между запросами. В Fiber для этого служит c.Locals. Сигнатура из v3: func (c fiber.Ctx) Locals(key any, value ...any) any — метод-двуликость: один аргумент означает чтение, два — запись. Докомментарий к интерфейсу формулирует назначение: makes it possible to pass any values under keys scoped to the request — значения живут ровно один запрос; документация добавляет, что они удаляются после завершения запроса, а под капотом стоят UserValue и SetUserValue fasthttp.

Чтение возвращает any — без магии: доставайте type assertion в ok-форме: user, ok := c.Locals("user").(string). Если ключа нет, вернётся nil, ok будет false, а user — пустая строка; прямое приведение c.Locals("user").(string) без ok на отсутствующем ключе паникует. Для типизированных чтений есть дженерик-версия fiber.Locals[string](c, "user") — она вернёт zero value типа, если ключа нет или тип не совпал. Ключом может быть любое значение; документация советует заводить непроэкспортируемый тип для ключа, чтобы разные пакеты не столкнулись строками. Ещё две детали из исходников: значение, реализующее io.Closer, Fiber закроет при удалении; а StoreInContext(c, key, value) пишет и в locals, и в context.Context — правда, второе только при включённом конфиге PassLocalsToContext (по умолчанию false).

### Страж запоминает пользователя, хендлер читает

```go
// Слой-страж: после успешной проверки пишет в контекст запроса
func requireAPIKey(key string) fiber.Handler {
        return func(c fiber.Ctx) error {
                if c.Get("X-API-Key") != key {
                        return c.Status(fiber.StatusUnauthorized).JSON(
                                fiber.Map{"error": "неверный API-ключ"},
                        )
                }
                // запись в locals: два аргумента
                c.Locals("user", "api-client")
                return c.Next()
        }
}

// Хендлер читает то, что оставил слой: один аргумент + type assertion
app.Get("/api/me", func(c fiber.Ctx) error {
        user, ok := c.Locals("user").(string)
        if !ok {
                return c.Status(fiber.StatusUnauthorized).JSON(
                        fiber.Map{"error": "нет пользователя в контексте"},
                )
        }
        return c.JSON(fiber.Map{"user": user})
})
```

Ключом здесь служит строка «user» — так короче для урока; в пакете с несколькими слоями возьмите непроэкспортируемый тип из совета документации. ok-форма обязательна по двум причинам: ключ мог не быть записан (этот маршрут вызван мимо стража), а чтение возвращает any. Если ok false, хендлер честно отвечает 401 — без паники и без мусорных данных. На живом сервере под «user» лежала бы запись из БД, а не строка-константа; механика от этого не меняется.

### Чтение несуществующего ключа

**Вопрос:** Ни один слой не записывал ничего под ключом «ghost». Что произойдёт при вызове c.Locals("ghost")?

- ✅ Вернётся nil (пустой any): type assertion в ok-форме даст false и не запаникует
- Вернётся пустая строка: any внутри — это string по умолчанию
- Паника: обращение к отсутствующему ключу запрещено
- Вернётся ошибка error, которую нужно обработать

Под капотом — UserValue fasthttp, который для незнакомых ключей возвращает nil. Поэтому ok-форма безопасна: user, ok := c.Locals("ghost").(string) даст "" и false. Дженерик fiber.Locals[string](c, "ghost") тоже не паникует — вернёт zero value типа. Паникует только прямое приведение без ok: c.Locals("ghost").(string) — попытка достать string из nil.

### Групповая привязка: app.Group("/api", mw...)

Вешать стража на каждый маршрут по отдельности — работа для робота, и она же порождает главный источник дыр: один новый маршрут забыли прикрыть. app.Group собирает маршруты с общим префиксом и вешает на них middleware одним движением. Сигнатура из app.go: func (app *App) Group(prefix string, handlers ...any) Router, а докомментарий звучит так: Group is used for Routes with common prefix to define a new sub-router with optional middleware. Возвращается Router — под-роутер с привычным набором методов: api.Get, api.Post, вложенные api.Group("/v1"). Слои из Group получают все маршруты, зарегистрированные на группе и на вложенных группах, — и только они: статика из [урока 5](/courses/fiber/lessons/05-staticheskie-fajly/) и корневые пути вроде health-check остаются без аутентификации, что обычно и требуется.

Две грабли из исходников. Первая: handlers — вариадик any, и незнакомый тип паникует в collectHandlers с контекстом «group»: сообщение вида group: invalid handler #0 (...тип...) — родственник «use: invalid handler» из шестого урока. Вторая — порядок регистрации: Group регистрирует свои слои в момент вызова, поэтому маршруты, объявленные РАНЬШЕ вызова app.Group, слоёв не получают. Это та же стековая механика, что у Use: слой, записанный в стек позже живого маршрута, не оборачивает его. Привычка, которая спасает: сначала все Use и Group, потом маршруты групп, Listen — последней строкой.

### Каркас сервиса: встроенные слои плюс своя группа

```go
package main

import (
        "fmt"
        "log"
        "time"

        "github.com/gofiber/fiber/v3"
        "github.com/gofiber/fiber/v3/middleware/logger"
        "github.com/gofiber/fiber/v3/middleware/recover"
        "github.com/gofiber/fiber/v3/middleware/requestid"
)

// Таймер: замер вокруг всей внутренней цепочки
func timer() fiber.Handler {
        return func(c fiber.Ctx) error {
                start := time.Now()
                err := c.Next()
                fmt.Printf("%s %s -> %v\n", c.Method(), c.Path(), time.Since(start))
                return err
        }
}

// Страж: проверка X-API-Key, JSON-ошибка и запись в locals
func requireAPIKey(key string) fiber.Handler {
        return func(c fiber.Ctx) error {
                if c.Get("X-API-Key") != key {
                        return c.Status(fiber.StatusUnauthorized).JSON(
                                fiber.Map{"error": "неверный API-ключ"},
                        )
                }
                c.Locals("user", "api-client")
                return c.Next()
        }
}

func main() {
        app := fiber.New()

        // 1-2: встроенные слои из шестого урока
        app.Use(requestid.New())
        app.Use(logger.New(logger.Config{
                Format: "${time} ${requestid} ${status} ${latency} ${method} ${path} ${error}\n",
        }))

        // 3: таймер — СНАРУЖИ recover, чтобы замерять и паникующие запросы
        app.Use(timer())
        app.Use(recover.New())

        // 4: защищённая группа — страж видят все маршруты /api
        api := app.Group("/api", requireAPIKey("s3cret"))
        api.Get("/notes", func(c fiber.Ctx) error {
                return c.JSON(fiber.Map{"items": []string{"первая", "вторая"}})
        })
        api.Get("/me", func(c fiber.Ctx) error {
                user, ok := c.Locals("user").(string)
                if !ok {
                        return c.Status(fiber.StatusUnauthorized).JSON(
                                fiber.Map{"error": "нет пользователя в контексте"},
                        )
                }
                return c.JSON(fiber.Map{"user": user})
        })

        // 5: health-check без ключа — маршрут вне группы
        app.Get("/healthz", func(c fiber.Ctx) error {
                return c.JSON(fiber.Map{"ok": true})
        })

        log.Fatal(app.Listen(":3000"))
}
```

Порядок нарочно отличается от канонического каркаса шестого урока в одном месте: таймер встал МЕЖДУ logger и recover. Причина — луковица. Паника в хендлере разматывает стек до ближайшего defer с recover(); если recover стоит внутри таймера, паника пролетает сквозь кадр таймера, и строка замера не печатается. Когда таймер снаружи, recover первым превращает панику в ошибку, c.Next() таймера возвращается штатно с этой ошибкой, замер печатается, а логгер выше пишет ${error}. Страж на группе /api защищает оба маршрута и пишет пользователя в locals; /healthz открыт, потому что зарегистрирован вне группы. Лог на запрос с неверным ключом покажет статус 401 при пустом ${error}: страж вернул nil, записав ответ сам.

### Маршрут раньше группы: кто ответит?

```go
// Маршрут объявлен ДО группы со стражем
app.Get("/api/notes", func(c fiber.Ctx) error {
        return c.JSON(fiber.Map{"items": []string{"первая"}})
})

// Группа и страж объявлены ПОСЛЕ
api := app.Group("/api", requireAPIKey("s3cret"))
api.Get("/users", listUsers)
```

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

- ✅ JSON со списком заметок и статус 200: страж не вызывался — маршрут объявлен раньше слоя
- 401 и JSON-ошибка: слои группы распространяются и на ранее объявленные маршруты
- 404: группа перекрыла существующий маршрут
- 500: Fiber обнаружит двойную регистрацию пути /api/notes

Стек роутера живёт в порядке регистрации: /api/notes записан раньше use-слоя группы, поэтому хендлер выполняется первым и, не вызывая c.Next(), завершает обработку — слой, стоящий в стеке позже, не получает управления. Маршрут /api/users объявлен на группе после её создания, поэтому страж его защищает. Никакой ошибки двойной регистрации нет: Fiber спокойно хранит оба маршрута. Вот почему правило «сначала Group, потом маршруты» — не вкус, а безопасность.

### Куда вешать свой слой: четыре варианта

| Способ | Пример | Кто получит слой |
| --- | --- | --- |
| Use на всё приложение | app.Use(timer()) | Все запросы, зарегистрированные после него |
| Группа с префиксом | app.Group("/api", requireAPIKey(k)) | Все маршруты группы и вложенных групп |
| Аргументы маршрута | app.Get("/notes", mw, handler) | Только этот маршрут: слой в route.Handlers |
| Вложенная группа | v1 := api.Group("/v1", mw) | Маршруты /api/v1: слои родителя плюс свои |

### Соберите защищённую группу

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

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

1. `app := fiber.New()`
2. `app.Use(requestid.New())`
3. `app.Use(logger.New())`
4. `app.Use(recover.New())`
5. `api := app.Group("/api", requireAPIKey("s3cret"))`
6. `api.Get("/notes", listNotes)`
7. `log.Fatal(app.Listen(":3000"))`

### Допишите фабрику стража

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

```go
func requireAPIKey(key string) fiber.Handler {
        return func(c fiber.Ctx) error {
                // заголовок запроса; регистр имени не важен
                if c.Get("___") != key {
                        // свой JSON-ответ со статусом 401
                        return c.Status(fiber.___).JSON(
                                fiber.Map{"error": "неверный API-ключ"},
                        )
                }
                // запоминаем пользователя в контексте запроса
                c.___("user", "api-client")
                // эстафета дальше по цепочке
                return c.___()
        }
}
```

Подсказка: Заголовок X-API-Key; статус fiber.StatusUnauthorized; запись — c.Locals(ключ, значение); эстафета — return c.Next().

Правильные ответы в пропусках: `X-API-Key`, `StatusUnauthorized`, `Locals`, `Next`

> **Три способа потерять ошибку или замер.** return c.Next() одной строкой — код после него не исполняется никогда: return покидает функцию сразу. return nil после c.Next() при непустой ошибке — ErrorHandler не узнает о проблеме, клиент получит пустой 200, а вышестоящий логгер не покажет ${error}. Ветвление if err != nil { return nil } выглядит «заботливо» и делает ровно это — маскирует сбой. Правило error-стиля: замер и побочные действия — до проверки ошибки, возврат — err без изменений.

> **Ключи Locals: свой тип вместо строк.** Документация c.Locals советует заводить непроэкспортируемый тип для ключей: type ctxKey int, затем const userKey ctxKey = iota. Два пакета со строковым ключом «user» случайно затрут значения друг друга; с разными типами-ключами столкновение невозможно в принципе. Для чтения удобно fiber.Locals[string](c, userKey) — дженерик принимает любой тип ключа и возвращает сразу string, без ручного приведения.

### Словарь урока

- **фабрика-замыкание** — Функция, принимающая конфиг и возвращающая fiber.Handler: замыкание захватывает настройки, слой создаётся один раз при регистрации. Так устроены logger.New, recover.New и keyauth.New.
- **X-API-Key** — Заголовок аутентификации-заглушки; читается c.Get без учёта регистра. При несовпадении слой сам пишет 401 с JSON-телом и завершает обработку без c.Next().
- **c.Locals** — Хранилище на время одного запроса: c.Locals(key, value) — запись, c.Locals(key) — чтение any с type assertion. Значения удаляются после завершения запроса; ключи лучше делать своим типом.
- **fiber.Locals[string]** — Дженерик-обёртка над c.Locals: типизированные чтение и запись; при отсутствии ключа — zero value вместо nil, без паники.
- **таймер и error-стиль** — start := time.Now() до c.Next(); err := c.Next(); замер; return err — ошибка прокидывается наверх, замер видит и ошибочные ответы. return c.Next() убивает замер, return nil теряет ошибку.
- **app.Group("/api", mw)** — Sub-router с общим префиксом и слоями: func Group(prefix string, handlers ...any) Router. Слои получают маршруты группы и вложенных; чужой тип — паника «group: invalid handler».

### Запуск, проверка и что дальше

Соберите main.go из каркаса выше в один файл — timer и requireAPIKey лежат рядом с main, импорты уже перечислены — и прогоните сценарий curl-ом. Первый запрос: curl -H "X-API-Key: s3cret" /api/notes — в ответе JSON со списком, в консоли строка таймера с длительностью, в логе статус 200. Второй: тот же путь без заголовка — клиент получает 401 и {"error":"неверный API-ключ"}, лог пишет 401 при пустом ${error}: страж записал ответ сам и вернул nil. Третий: curl -i /healthz — 200 без всякого ключа, маршрут вне группы; заодно в заголовках видно X-Request-ID из шестого урока. Четвёртый, для любопытных: добавьте в таймер c.Set("X-Response-Time", time.Since(start).String()) и найдите этот заголовок в curl -i — у вас получился мини-клон responsetime.

Дальше по плану — восьмой урок: CORS и безопасность заголовков, как разрешить браузеру чужого origin дёргать ваше API и какие заголовки за это отвечают; таймер и страж из этого урока будут помогать его разбору. Освежить механику c.Next(), луковицы и ErrorHandler — в [шестом уроке](/courses/fiber/lessons/06-middleware/), параметры запроса — в четвёртом, полный план — на странице [курса Fiber](/courses/fiber/). В браузерной песочнице сайта сетевые приложения не запускаются — runner-ов в уроках Fiber нет, проверяйте примеры в терминале с локальным Go.

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

- Свой middleware — обычная функция func(Ctx) error или фабрика, возвращающая fiber.Handler: конфиг захватывается замыканием, слой создаётся один раз при регистрации, как у logger.New и recover.New.
- Аутентификация-заглушка: c.Get("X-API-Key") читает заголовок без учёта регистра; при несовпадении слой сам пишет c.Status(fiber.StatusUnauthorized).JSON(...) и завершает обработку без c.Next().
- fiber.NewError отвечает text/plain через DefaultErrorHandler; для JSON-ошибок либо пишите ответ в middleware, либо ставьте свой ErrorHandler в fiber.New(fiber.Config{ErrorHandler: ...}).
- Таймер в error-стиле: start := time.Now() до c.Next(), err := c.Next(), замер, return err — замер видит и ошибочные ответы; return c.Next() убивает печать, return nil теряет ошибку.
- c.Locals(key, value) пишет, c.Locals(key) читает any с type assertion в ok-форме; значения живут один запрос; есть дженерик fiber.Locals[V] и StoreInContext для context.Context.
- app.Group("/api", mw) возвращает Router и вешает слои на все маршруты группы и вложенных групп; незнакомый тип паникует «group: invalid handler».
- Порядок регистрации решает: маршрут, объявленный раньше Group или Use, слоёв не получает; таймер ставят снаружи recover, чтобы замерять и паникующие запросы.

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

Возьмите каркас из урока и доработайте его: вынесите ключ стража в переменную окружения и прокомментируйте, что изменилось в вызове фабрики; добавьте в таймер заголовок X-Response-Time и проверьте его через curl -i; заведите вторую группу /admin со вторым стражем и другим ключом, убедитесь, что замыкания не пересекаются; добавьте маршрут /api/secret ДО объявления группы и объясните результат запроса без ключа; наконец подключите готовый keyauth с extractors.FromHeader("X-API-Key") и собственным Validator и перечислите, какие части нашей заглушки он повторяет.

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

### Когда нужна фабрика middleware, а когда достаточно обычной функции?

Обычной функции хватает, если слою не нужен конфиг — как у таймера без настроек. Как только появляется параметр (ключ, порог, адрес лога), берите фабрику: замыкание изолирует конфиг, можно создать несколько независимых слоёв с разными настройками, и регистрация читается одной строкой requireAPIKey("s3cret"). Именно так устроены штатные logger.New, recover.New и keyauth.New.

### Как сделать, чтобы fiber.NewError отвечал JSON, а не text/plain?

Заменить ErrorHandler приложения: fiber.New(fiber.Config{ErrorHandler: func(c fiber.Ctx, err error) error { ... }}) — внутри достаньте код через errors.As в *fiber.Error и верните c.Status(code).JSON(...). Альтернатива для одного слоя — записать ответ прямо в middleware через c.Status(...).JSON(...) и вернуть результат, не трогая глобальный обработчик.

### Почему таймер ставят раньше recover, а не после?

Из-за луковицы. Если recover стоит внутри таймера, паника разматывает стек сквозь кадр таймера — код после его c.Next() не исполняется, строка замера теряется. Когда таймер снаружи (зарегистрирован раньше), recover первым перехватывает панику и превращает её в ошибку, c.Next() таймера возвращается штатно с этой ошибкой: замер печатается, ошибка прокидывается в ErrorHandler как обычно.
