---
title: "Работа с JSON"
description: "Урок fiber json: c.JSON и Content-Type, теги json и omitempty, коды ответов Status и SendStatus, Bind — замена BodyParser, структуры запросов и массивы."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/09-json/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Работа с JSON

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

Тема «fiber json» — про байты, которые ходят между клиентом и вашим API. Восемь уроков мы отвечали литералами fiber.Map и не трогали входящие тела; сегодня вскрываем механику обеих сторон: как c.JSON сериализует ответ и ставит Content-Type, что делают теги json и omitempty, из чего состоят коды ответов Status и SendStatus — и как Fiber v3 разбирает входящий JSON методом Bind, который в комментариях исходников назван прямой заменой BodyParser из второго поколения.

### fiber json: байты в обе стороны

Каждый хендлер этого курса заканчивался вызовом c.JSON — пора вскрыть json-слой Fiber построчно. JSON здесь работает в два направления. Исходящее: хендлер отдаёт значение Go, фреймворк превращает его в байты и подписывает заголовком Content-Type: application/json; charset=utf-8. Входящее: клиент присылает тело, и его надо разобрать обратно в структуру Go — с проверкой, что Content-Type и содержимое совпали.

Обещания мы уже раздавали: в первом уроке среди «что есть в коробке» был пункт «JSON: c.JSON для ответов, BodyParser для разбора тела запроса», а таблица четвёртого урока откладывала разбор тел со словами «BodyParser — в уроке про JSON». Платим по счетам, но с поправкой на версию: в v3 BodyParser сменился методом Bind — слово из tutorials второго поколения мы встретим и аккуратно переведём. Сверять всё будем с исходниками тега v3.5.0: res.go для ответов, ctx.go и bind.go для разбора, constants.go для кодов.

### c.JSON под микроскопом: три шага на wire

```go
package main

import (
        "log"

        "github.com/gofiber/fiber/v3"
)

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

        app.Get("/healthz", func(c fiber.Ctx) error {
                // сериализация, тело и Content-Type — одним вызовом
                return c.JSON(fiber.Map{"ok": true})
        })

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

Сигнатура из res.go: func (r *DefaultRes) JSON(data any, ctype ...string) error. Внутри три шага. Первый — app.config.JSONEncoder(data): кодировщик берётся из конфига приложения, а app.go назначает json.Marshal, если поле не задано. Второй — response.SetBodyRaw(raw): готовые байты ложатся в тело без повторного копирования. Третий — Content-Type: если второй аргумент не передан, ставится константа MIMEApplicationJSONCharsetUTF8, за которой стоит строка application/json; charset=utf-8; передадите ctype — уедет ваша строка. curl -i покажет оба следа сразу: заголовок и тело {"ok":true}.

### fiber.Map против структур: форма ответа

fiber.Map объявлен в app.go одной строкой: type Map map[string]any. Это карта «что угодно» — и в этом сила, и слабость. Сила: {"ok": true} для health-check или {"error": "неверный API-ключ"} для стража из седьмого урока пишутся мгновенно, без отдельного типа. Слабость проявляется, когда ответ — это данные: имена ключей живут в строках, опечатку «tiel» компилятор не заметит, форма ответа нигде не задокументирована, а каждое значение — any, то есть без защиты типов.

Есть и приятный бонус у карт, о котором стоит знать: encoding/json сортирует ключи карты по алфавиту, поэтому fiber.Map даёт байтово предсказуемый вывод — полезно для хешей и диффов. Но порядок этот — алфавитный, а не смысловой. Структура работает наоборот: порядок полей в JSON повторяет порядок объявления, поля и типы фиксирует компилятор, а форму видно прямо в коде. Практическое правило: Map — для мелких ответов без формы данных; всё, что называется существительным (задача, профиль, заказ), — структура с тегами json.

### Task: первая структура курса Fiber

```go
// Task — форма данных API: ключи и порядок задаёт тег json
type Task struct {
        ID        int    `json:"id"`
        Title     string `json:"title"`
        Done      bool   `json:"done"`
        Priority  string `json:"priority,omitempty"`
        Estimate  int    `json:"estimate,omitempty"`
        CreatedAt string `json:"created_at,omitempty"`
}

// GET /api/tasks — список задач
api.Get("/tasks", func(c fiber.Ctx) error {
        tasks := []Task{
                {ID: 1, Title: "починить шлюз"},
                {ID: 2, Title: "обновить сертификат", Done: true, Priority: "high"},
        }
        // данные завёрнуты в items, как во всех уроках курса
        return c.JSON(fiber.Map{"items": tasks})
})
```

Теги json — это договор не с Fiber, а с encoding/json, который тот вызывает по умолчанию. Имя ключа берётся из тега, а не из поля: title, а не Title. Стиль ключей выбираете вы: created_at — snake_case, написанный руками, автоматического переименования нет. Estimate — целое число часов, добавлено нарочно: на нём мы поймаем ошибку типов, когда клиент пришлёт строку. Приоритет и дата помечены omitempty — что делает этот флаг и сколько он стоит, — следующий блок. Вывод эндпоинта: {"items":[{"id":1,"title":"починить шлюз","done":false},...]} — done печатается честно, ведь без omitempty false остаётся false.

### Теги json: omitempty и цена тишины

omitempty — самое экономное слово в словаре тегов: если значение пустое по правилам encoding/json, поле не сериализуется вовсе. Пустое — это ноль для чисел, false для булевых, пустая строка, nil для указателей, интерфейсов, срезов и карт, а также любой срез или карта с len == 0. Черновик без приоритета не тащит ключ priority в каждом элементе списка, ответ дышит свободнее, а снапшоты ответов в тестах перестают трястись от пустых полей.

У экономии есть цена, и о ней принято забывать. Поле, которого нет, неотличимо для клиента от поля, о котором он не знает: старая версия мобильного приложения, проверяющая наличие ключа, поведёт себя иначе, чем с "priority": "". Форма ответа становится зависимой от данных — ровно до момента, когда кто-то начнёт строить на ней интерфейс. Поэтому omitempty уместен на необязательных полях и вреден на смысловых: done с omitempty превращал бы «задача не сделана» в «задача без статуса».

Два соседних приёма из того же словаря. Запятая с флагом без имени — json:",omitempty" — допустима: имя берётся из поля, но ключ станет Priority в PascalCase, так что имя в теге лучше писать всегда. А тег json:"-" прячет поле целиком — и при сериализации, и при разборе: так наружу не уезжают пароли и внутренние метки. Всё это — семантика encoding/json, общая для всего Go-мира: у gin из параллельного курса теги работают точно так же, отличие фреймворков — в методах, а не в тегах.

### Проверьте себя: omitempty в деле

**Вопрос:** Хендлер возвращает Task{ID: 3, Title: "позвонить", Done: false} — Priority, Estimate и CreatedAt помечены omitempty, Done без флагов. Какой JSON уедет?

- ✅ {"id":3,"title":"позвонить","done":false}
- {"id":3,"title":"позвонить"}
- {"id":3,"title":"позвонить","done":false,"priority":"","estimate":0,"created_at":""}
- {"ID":3,"Title":"позвонить","Done":false}

Пустые значения опущены: у строк пусто — это "", у int — 0, поэтому priority, estimate и created_at не сериализовались. Done объявлен без omitempty — false печатается честно, и клиент видит статус задачи. Имена ключей дают теги, а не поля Go: вариант с ID и Title получился бы вовсе без тегов. Это базовая механика encoding/json — omitempty опускает нулевые значения, остальное печатает всегда.

### Fiber против gin: честное сравнение JSON-методов

В пятом уроке курса gin разобрано целое семейство ответов: IndentedJSON с отступами для отладки, PureJSON без HTML-экранирования, SecureJSON с префиксом против дед hijacking и AsciiJSON с \u-эскейпами. Сверим с тегом v3.5.0: в Fiber v3 ни одного из этих методов нет — c.JSON ровно один, никаких IndentedJSON или PureJSON на Ctx не существует (подробное сравнение — в [пятом уроке курса gin](/courses/gin/lessons/05-json-otvety/)).

Честная картина по обе стороны. Экранирование одинаковое: json.Marshal превращает <, > и & в \u003c, \u003e и \u0026 — и gin, и Fiber в базовом методе этого не отключают; переключателя уровня PureJSON (SetEscapeHTML(false)) в комплекте Fiber нет. Красивая печать решается иначе — подменой кодировщика: fiber.Config{JSONEncoder: json.MarshalIndent-обёртка} — поле типа utils.JSONMarshal с дефолтом json.Marshal, и все c.JSON начнут отвечать с отступами. Разумно держать такой конфиг у /debug-эндпоинтов или включать по переменной окружения: на каждый ответ процессора тратится, а тело растёт. Взамен v3 даёт то, чего нет у gin: соседей c.JSON тем же паттерном — JSONP с поддержкой callback, MsgPack и CBOR — бинарные форматы с собственными методами и кодировщиками из конфига. Content-Type application/json; charset=utf-8 — общий знаменатель обоих фреймворков; там, где gin экономил байты PureJSON, Fiber предлагает посчитать, нужны ли вам точные байты вообще.

### Что уедет в теле ответа?

```go
type Task struct {
        ID       int    `json:"id"`
        Title    string `json:"title"`
        Priority string `json:"priority,omitempty"`
        Note     string `json:"note,omitempty"`
}

// GET /api/tasks/3 возвращает напрямую структуру:
return c.JSON(Task{ID: 3, Title: "позвонить <клиенту>", Note: ""})
```

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

- ✅ {"id":3,"title":"позвонить \u003cклиенту\u003e"}
- {"id":3,"title":"позвонить <клиенту>","note":""}
- {"ID":3,"Title":"позвонить <клиенту>"}
- {"id":3,"title":"позвонить <клиенту>","priority":""}

Три эффекта разом. Угловые скобки экранированы: json.Marshal пишет < и > как \u003c и \u003e, и Fiber в этом не отступает от дефолта — переключателя уровня PureJSON у него нет; любой уважающий себя декодер вернёт исходную строку. Note с пустой строкой выпал по omitempty. Priority не показался, хотя бы потому, что его нулевое значение тоже пусто, — а имена ключей взяты из тегов, не из полей Go.

### Коды ответов: Status, SendStatus и статусные фразы

JSON-ответ — это заголовки, код и тело. Разберём последние два инструмента до единой строки исходников. c.Status(code) в ctx.go объявлен как func (c *DefaultCtx) Status(status int) Ctx: ставит код, тело не трогает и возвращает Ctx — отсюда цепочка c.Status(fiber.StatusCreated).JSON(...), которую мы используем со времён восьмого урока. Это инструмент «код плюс тело одним выражением».

c.SendStatus(status) в res.go умнее по телу. Сначала он ставит код, затем спрашивает statusDisallowsBody: для 1xx, 204 No Content, 205 Reset Content и 304 Not Modified тело запрещено самим RFC — ResetBody сбрасывает его дочиста. Если статус тело разрешает, а тело пусто, SendStatus кладёт статусную фразу utils.StatusMessage(status): «Not Found», «Created», «Unprocessable Entity». Если тело уже записано — не трогает ни кодовую фразу, ни содержимое. Отсюда правило выбора: есть что сериализовать — c.Status(...).JSON(...); отвечаете только кодом — c.SendStatus(...), и пусть фраза и запреты тела решаются за вас.

### Коды ответов JSON-API и константы Fiber

| Код | Константа | Когда ставить |
| --- | --- | --- |
| 200 | fiber.StatusOK | успешный GET: данные в теле, ничего создавать не пришлось |
| 201 | fiber.StatusCreated | POST создал ресурс: вернуть созданное с присвоенным ID |
| 204 | fiber.StatusNoContent | DELETE или обновление без тела в ответе; тело запрещено RFC |
| 400 | fiber.StatusBadRequest | запрос сломан: тип поля не тот, JSON не парсится, поле пусто |
| 422 | fiber.StatusUnprocessableEntity | Content-Type не из списка Bind — тело нечем разобрать |
| 500 | fiber.StatusInternalServerError | ошибка на нашей стороне; дефолтный код необработанных ошибок |

### POST с 201 и DELETE с 204

```go
// POST /api/tasks — создать: код 201 и созданная задача в теле
api.Post("/tasks", func(c fiber.Ctx) error {
        var in Task
        // разбор тела — тема второй половины урока
        if err := c.Bind().Body(&in); err != nil {
                return err
        }
        in.ID = nextID()
        in.CreatedAt = time.Now().Format(time.RFC3339)
        tasks = append(tasks, in)
        // цепочка: сначала код, потом JSON
        return c.Status(fiber.StatusCreated).JSON(in)
})

// DELETE /api/tasks — тела в ответе нет по RFC
api.Delete("/tasks", func(c fiber.Ctx) error {
        tasks = nil
        // SendStatus: код 204 и гарантированно пустое тело
        return c.SendStatus(fiber.StatusNoContent)
})
```

Здесь важны два кода. 201 Created — «ресурс создан, вот он»: клиенту не нужен второй GET, чтобы узнать ID и дату. 204 через SendStatus: статус запрещает тело, statusDisallowsBody вызывает ResetBody — и строка «No Content» в теле не уедет, хотя при 404 на пустом хендлере SendStatus положил бы в тело фразу Not Found. Вся семантика — RFC 9110, вшитая в res.go; константы StatusCreated и StatusNoContent живут в constants.go рядом с числами и ссылками на RFC.

### Проверьте себя: что в теле у SendStatus

**Вопрос:** Хендлер завершается return c.SendStatus(fiber.StatusNotFound), и тело ответа пусто. Что получит клиент?

- ✅ 404 и тело Not Found — статусную фразу
- 404 и пустое тело — SendStatus тело не пишет
- 500 и тело Not Found — SendStatus поднимает код
- 404 и JSON {"error": "Not Found"}

res.go: код ставится первым; 404 тело разрешает — в отличие от 204 и 304, где работает ResetBody; тело пусто, поэтому SendStatus записывает utils.StatusMessage(status) — фразу Not Found. Никакого JSON в этом теле нет — это просто строка, и парсить её как JSON клиент не должен. SendStatus поднимает код до 500 — выдумка: он только подтверждает ваш код.

### BodyParser уходит: c.Bind() в v3

Ищете разбор JSON в Fiber — поисковик неизбежно приводит на слово BodyParser: так метод назывался в v2, и tutorials второго поколения построены на c.BodyParser(&in). Сверим с тегом v3.5.0: метода BodyParser на Ctx больше нет. На его месте — c.Bind(), и комментарий в ctx.go прямо называет замену: Replacement of: BodyParser, ParamsParser, GetReqHeaders, GetRespHeaders, AllParams, QueryParser, ReqHeaderParser. Один Bind собрал в себе весь разбор запроса.

Внутри — семейство методов. Тело: Body (диспетчер по Content-Type), JSON, XML, Form, MsgPack, CBOR. Прочие источники: Query — строка запроса, URI — параметры маршрута из третьего урока, Header и Cookie — заголовки и печенье, RespHeader — ответные заголовки. Все вместе сразу — All, свои биндеры — Custom. Практика перевода v2-примера в v3 механическая: c.BodyParser(&in) превращается в c.Bind().Body(&in), а поведение по Content-Type сохраняется. Поэтому запрос «bodyparser fiber» правильный, а ответ — Bind: tutorials не переведены, исходники — да.

### Bind: источники данных и выбор парсера

| Метод | Источник | Как выбирается |
| --- | --- | --- |
| Body() | тело запроса | диспетчер по Content-Type: JSON, XML, формы, MsgPack, CBOR |
| JSON() | тело запроса | всегда JSON-декодер, заголовок Content-Type игнорирует |
| XML(), Form() | тело запроса | XML-документ; формы urlencoded и multipart с файлами |
| Query(), URI() | строка запроса и путь | параметры URL и :id из маршрута — источники четвёртого и третьего уроков |
| Header(), Cookie() | заголовки и cookie | разбор по тегам структуры, включая X-API-Key стража |
| All() | всё сразу | порядок: URI, тело, query, заголовки, cookie — настраивается тегом binding_source |

### c.Bind().Body: диспетчер по Content-Type

```go
// POST /api/tasks: Content-Type решает, чем парсить тело
api.Post("/tasks", func(c fiber.Ctx) error {
        var in Task
        // application/json → JSON-декодер, формы → form-биндер
        if err := c.Bind().Body(&in); err != nil {
                // чужой Content-Type и битый JSON приходят сюда
                return c.Status(fiber.StatusBadRequest).JSON(
                        fiber.Map{"error": "запрос не разобран"},
                )
        }
        if in.Title == "" {
                return c.Status(fiber.StatusBadRequest).JSON(
                        fiber.Map{"error": "поле title обязательно"},
                )
        }
        in.ID = nextID()
        tasks = append(tasks, in)
        return c.Status(fiber.StatusCreated).JSON(in)
})
```

Механика из helpers.go: bindMediaType приводит заголовок к нижнему регистру и отбрасывает параметры после точки с запятой, поэтому «application/json; charset=utf-8» распознаётся как application/json; вендорные формы вида application/vnd.api+json сводятся к базовому типу. Дальше switch из bind.go: MIMEApplicationJSON уходит в Bind().JSON, формы — application/x-www-form-urlencoded и multipart/form-data — в Bind().Form, там же XML, MsgPack и CBOR. Ничего не подошло и кастомный биндер не нашёлся — возвращается ErrUnprocessableEntity, это *Error с кодом 422 из constants.go. Разница с Bind().JSON принципиальна: тот декодирует тело при любом заголовке и падает только на содержимом; декодер берётся из конфига — bind.go передаёт app.Config().JSONDecoder, дефолт которого json.Unmarshal назначается в app.go, так что подменить разбор JSON в проекте можно так же, как и кодировщик.

### JSON с чужим Content-Type

```go
api.Post("/tasks", func(c fiber.Ctx) error {
        var in Task
        if err := c.Bind().Body(&in); err != nil {
                return err // ошибку отдаём как есть
        }
        return c.Status(fiber.StatusCreated).JSON(in)
})

// Запрос:
// POST /api/tasks
// Content-Type: text/plain
//
// {"title": "позвонить клиенту"}
```

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

- ✅ 422 Unprocessable Entity: парсер под text/plain не нашёлся
- 201 Created: JSON распарсится, заголовок не важен
- 400 Bad Request: тело — битый JSON
- 200 OK: ошибка разбора молча игнорируется

Диспетчер в Bind().Body свитчит по Content-Type: text/plain в списке нет, кастомные биндеры тоже мимо — возвращается ErrUnprocessableEntity, *Error с кодом 422 (StatusUnprocessableEntity). Вернули её как есть — ErrorHandler поставит 422 и статусную фразу Unprocessable Entity. Это клиентская ошибка с ясной причиной: пока Content-Type не станет JSON, тело никто не будет угадывать. Bind().JSON() повёл бы себя иначе — декодировал бы тело без оглядки на заголовок.

### Ошибки биндинга: BindError, UnmarshalTypeError и 400

У разбора два режима, и дефолт — ручной. bindPool создаёт Bind с shouldSkipErrHandling: true: ошибка разбора возвращается как *BindError, статус ответа НЕ ставится. BindError — структура из bind.go с тремя полями: Err — исходная ошибка, Source — источник из констант BindSource* (body, query, uri, header, cookie), Field — имя поля, вычисленное по-умному: для JSON-ошибок *json.UnmarshalTypeError поле вытаскивается из самой ошибки, и строка «json: cannot unmarshal string into Go struct field Task.estimate of type int» превращается в estimate. Достать всё это — errors.As(err, &be).

Второй режим включает c.Bind().WithAutoHandling(): returnErr в bind.go ставит b.ctx.Status(StatusBadRequest) и возвращает NewError(StatusBadRequest, "Bad request: "+err.Error()) — готовый *Error с кодом 400; return err вы всё равно пишете сами. И третья правда, о которой tutorials молчат: в ручном режиме вернули BindError как есть — для дефолтного ErrorHandler это не *Error, поэтому код станет 500, а телом уедет err.Error() в text/plain. Клиент виноват, а получает Internal Server Error. Синтаксическая ошибка (битый JSON, *json.SyntaxError) и несовпадение типов (*json.UnmarshalTypeError) — обе проходят одной дорогой биндинга; различие — в сообщении и поле.

### Свои JSON-ошибки на 400: errors.As из bind.go

```go
// POST /api/tasks — ручной режим: ошибки биндинга получают форму API
api.Post("/tasks", func(c fiber.Ctx) error {
        var in Task
        err := c.Bind().Body(&in)
        if err != nil {
                var be *fiber.BindError
                // errors.As достаёт источник и поле из ошибки биндинга
                if errors.As(err, &be) {
                        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
                                "error":  "запрос не разобран",
                                "source": be.Source, // body
                                "field":  be.Field,  // estimate — из UnmarshalTypeError
                        })
                }
                return c.Status(fiber.StatusBadRequest).JSON(
                        fiber.Map{"error": "запрос не разобран"},
                )
        }
        if in.Title == "" {
                return c.Status(fiber.StatusBadRequest).JSON(
                        fiber.Map{"error": "поле title обязательно"},
                )
        }
        in.ID = nextID()
        tasks = append(tasks, in)
        return c.Status(fiber.StatusCreated).JSON(in)
})
```

Приём «страж пишет JSON» из седьмого урока сменил профессию: теперь единую форму {error, source, field} получают ошибки разбора. Клиенту не нужно гадать, JSON ли пришёл или текст ошибки — всё парсится одним кодом. errors.As вынимает *BindError: Source различит body, uri и query — полезно, когда к эндпоинту сходятся и путь, и тело; Field передаёт estimate прямо из UnmarshalTypeError. Альтернатива на стиле — bind := c.Bind().WithAutoHandling(): статус 400 и сообщение Bad request: ... проставятся сами, но уйдут как text/plain от ErrorHandler, а не вашим JSON. Два режима — две цены: меньше кода или полный контроль.

### Проверьте себя: цена ручного режима

**Вопрос:** Bind в дефолтном ручном режиме. Хендлер сделал err := c.Bind().Body(&in) и вернул err как есть — без WithAutoHandling и без своих ответов. Клиент прислал строку в числовом поле. Что увидит клиент?

- ✅ 500 и текст ошибки биндинга в text/plain
- 400 и JSON {"error": "Bad request: ..."}
- 400 и пустое тело — статус поставит Bind
- 201 — ошибка разбора игнорируется

shouldSkipErrHandling: true — Bind статус не ставит. *BindError не является *Error, поэтому ErrorHandler берёт дефолтный код 500 и пишет в тело err.Error() — наружу уедет строка вида bind from body: ..., хотя виноват клиент. Виновнику положено 400: либо WithAutoHandling(), который поставит статус и вернёт NewError с Bad request: ..., либо errors.As + c.Status(400).JSON из предыдущего блока.

### JSON-массив: батч-импорт и тело побольше

Массив — тоже JSON-значение, и разбор идёт тем же методом: c.Bind().Body(&newTasks), где newTasks []Task, съест тело [{"title":"..."},{"title":"..."}] целиком. Практический сценарий — импорт: POST /api/tasks:import принимает список и добавляет всё сразу; ответ разумно вернуть в той же форме — {"items": [...]}, чтобы клиент получил обновлённый список одним вызовом. Разница nil и пустого среза видна и тут: тело [] даёт срез длины ноль, отсутствие ключа в объекте — нулевое значение поля, nil; с omitempty оба исчезнут из ответа, без него уедут как null и [] соответственно.

Два ограничения стоит знать заранее. Тело ограничено BodyLimit из fiber.Config — по умолчанию DefaultBodyLimit, четыре мебибайта; импорт миллионного массива режьте на страницы. А качество каждого элемента массива — на совести той же цепочки разбора: после декодирования bind.go вызывает validateStruct, который спрашивает config.StructValidator из fiber.Config. По умолчанию валидатор не задан — проверка молча пропускается, и задача «каждая задача имеет непустой title» остаётся вашей. Провода для валидации уже проложены, и это аккуратный мост к десятому уроку.

### Каркас: структуры, коды, биндинг в одном main.go

```go
package main

import (
        "errors"
        "log"
        "time"

        "github.com/gofiber/fiber/v3"
        "github.com/gofiber/fiber/v3/middleware/cors"
        "github.com/gofiber/fiber/v3/middleware/helmet"
        "github.com/gofiber/fiber/v3/middleware/logger"
)

// Task — форма данных API: ключи и порядок задаёт тег json
type Task struct {
        ID        int    `json:"id"`
        Title     string `json:"title"`
        Done      bool   `json:"done"`
        Priority  string `json:"priority,omitempty"`
        Estimate  int    `json:"estimate,omitempty"`
        CreatedAt string `json:"created_at,omitempty"`
}

// страж из седьмого урока: без X-API-Key группа закрыта
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", "spa-client")
                return c.Next()
        }
}

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

        // 1-2: слои из восьмого урока — порядок прежний
        app.Use(helmet.New())
        app.Use(cors.New(cors.Config{
                AllowOrigins:     []string{"http://localhost:3000"},
                AllowHeaders:     []string{"Content-Type", "X-API-Key"},
                AllowMethods:     []string{"GET", "POST", "PUT", "DELETE"},
                AllowCredentials: true,
                MaxAge:           3600,
        }))

        // 3: лог и защищённая группа
        app.Use(logger.New())
        api := app.Group("/api", requireAPIKey("s3cret"))

        // хранилище и счётчик — в памяти процесса
        tasks := make([]Task, 0)
        nextID := 0

        api.Get("/tasks", func(c fiber.Ctx) error {
                return c.JSON(fiber.Map{"items": tasks})
        })

        api.Post("/tasks", func(c fiber.Ctx) error {
                var in Task
                // диспетчер по Content-Type: application/json → JSON
                if err := c.Bind().Body(&in); err != nil {
                        var be *fiber.BindError
                        if errors.As(err, &be) {
                                // свои JSON-ошибки вместо 500 с text/plain
                                return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
                                        "error":  "запрос не разобран",
                                        "source": be.Source,
                                        "field":  be.Field,
                                })
                        }
                        return c.Status(fiber.StatusBadRequest).JSON(
                                fiber.Map{"error": "запрос не разобран"},
                        )
                }
                if in.Title == "" {
                        return c.Status(fiber.StatusBadRequest).JSON(
                                fiber.Map{"error": "поле title обязательно"},
                        )
                }
                nextID++
                in.ID = nextID
                in.CreatedAt = time.Now().Format(time.RFC3339)
                tasks = append(tasks, in)
                return c.Status(fiber.StatusCreated).JSON(in)
        })

        api.Delete("/tasks", func(c fiber.Ctx) error {
                tasks = nil
                return c.SendStatus(fiber.StatusNoContent)
        })

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

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

Каркас собирает урок в целое: слои восьмого урока не тронуты — шлем, CORS для SPA, лог, страж и /healthz; новое живёт внутри группы. Три эндпоинта — три кода из таблицы: 200 на список, 201 на создание с созданной задачей в теле, 204 на очистку через SendStatus. Разбор тела — ровно две ветки: ошибки биндинга с errors.As превращаются в {error, source, field} с кодом 400, дальше — своя проверка title. Если поле Title в вашем варианте помечено omitempty и без собственной проверки, пустая задача пройдёт — валидация полей и есть тема десятого урока.

### Соберите жизненный цикл JSON-запроса

Расставьте шаги POST-запроса с JSON-телом по порядку: от команды в терминале до последнего заголовка ответа.

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

1. `curl -X POST localhost:8080/api/tasks -H "Content-Type: application/json" -H "X-API-Key: s3cret" -d '{"title": "позвонить"}'`
2. `helmet ставит защитные заголовки и пропускает запрос дальше`
3. `cors добавляет Access-Control-Allow-Origin и тоже пропускает`
4. `страж requireAPIKey сверяет X-API-Key и пишет locals`
5. `c.Bind().Body(&in) разбирает JSON в структуру Task`
6. `c.Status(fiber.StatusCreated).JSON(in) отвечает кодом и телом`
7. `клиент читает Content-Type: application/json; charset=utf-8`

### Допишите хендлер создания задачи

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

```go
api.Post("/tasks", func(c fiber.Ctx) error {
        var in Task
        // разбор тела: JSON по Content-Type
        if err := c.___().Body(&in); err != nil {
                return c.Status(fiber.___).JSON(
                        fiber.Map{"error": "запрос не разобран"},
                )
        }
        in.ID = nextID()
        tasks = append(tasks, in)
        // код «ресурс создан» и созданная задача в ответе
        return c.___(fiber.___).JSON(in)
})
```

Подсказка: Разбор запроса в v3 — c.Bind().Body; битый запрос — StatusBadRequest; цепочка — c.Status(fiber.StatusCreated).JSON(...).

Правильные ответы в пропусках: `Bind`, `StatusBadRequest`, `Status`, `StatusCreated`

> **curl -d без заголовка шлёт форму.** curl -d '{...}' молча ставит Content-Type: application/x-www-form-urlencoded — Bind().Body уйдёт в form-биндер, JSON-ключи не лягут в структуру, и ответом станет 422, хотя JSON безупречен. Всегда добавляйте -H "Content-Type: application/json". Удобно держать тело в файле: curl -d @task.json -H "Content-Type: application/json" localhost:8080/api/tasks. А -i покажет код и заголовки сразу — та же дисциплина наблюдения, что и в предыдущем уроке, только теперь мы смотрим ещё и на тело.

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

- **c.JSON** — Метод из res.go: JSONEncoder из конфига (дефолт json.Marshal), SetBodyRaw в тело и Content-Type application/json; charset=utf-8 — константа MIMEApplicationJSONCharsetUTF8. Второй аргумент ctype перекрывает заголовок.
- **fiber.Map** — type Map map[string]any из app.go. Карта «что угодно» для мелких ответов: ключи сортируются по алфавиту, опечатки ловит только рантайм. Для данных — структуры с тегами json.
- **omitempty** — Флаг тега json: пустое значение (0, false, "", nil, len 0) не сериализуется. json:"-" прячет поле целиком. Семантика encoding/json — у Fiber и gin она общая, отличаются методы, а не теги.
- **c.Bind()** — Замена BodyParser из v2 — комментарий Replacement of в ctx.go. Body — диспетчер по Content-Type (JSON, XML, формы, MsgPack, CBOR, иначе 422), JSON — всегда JSON; Query, URI, Header, Cookie — прочие источники; All — всё сразу.
- **BindError** — Ошибка биндинга ручного режима: Err, Source (body/query/uri/header/cookie) и Field — для JSON из UnmarshalTypeError. Дефолт — статус НЕ ставится: обработайте errors.As-ом или включите WithAutoHandling() — тогда 400 и Bad request: ...
- **SendStatus** — Код плюс умное тело: для 1xx, 204, 205, 304 тело сбрасывается (statusDisallowsBody, ResetBody); иначе при пустом теле — статусная фраза utils.StatusMessage, например Not Found. c.Status лишь ставит код и возвращает Ctx для цепочки .JSON(...).

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

Соберите каркас и прогоните четыре сценария. Первый — правильное создание: curl -i -X POST localhost:8080/api/tasks -H "Content-Type: application/json" -H "X-API-Key: s3cret" -d '{"title":"позвонить","priority":"high"}' — код 201, Content-Type: application/json; charset=utf-8 и созданная задача с id и created_at; priority в теле вернётся, estimate опустится по omitempty. Второй — ошибка типов: -d '{"title":"позвонить","estimate":"пять"}' — 400 и {"error":"запрос не разобран","source":"body","field":"estimate"}: Field приехал из UnmarshalTypeError, не нами придуман. Третий — чужой Content-Type: тот же JSON без -H "Content-Type: application/json" — 422 Unprocessable Entity от ErrUnprocessableEntity. Четвёртый — очистка: curl -i -X DELETE ... -H "X-API-Key: s3cret" — 204 и пустое тело.

Дальше по плану — десятый урок про валидацию: провода уже видели — config.StructValidator в fiber.Config и вызов validateStruct после разбора в bind.go, который сегодня молча пропускает проверку. Подключите валидатор — и «title обязателен» перестанет быть ручной проверкой. Обновить слои и стража — в [восьмом уроке](/courses/fiber/lessons/08-cors/), фабрику слоёв и c.Locals — в [седьмом](/courses/fiber/lessons/07-svoi-middleware/), полный план — на странице [курса Fiber](/courses/fiber/). В браузерной песочнице сайта сетевые приложения не запускаются — runner-ов в уроках Fiber нет, проверяйте примеры в терминале с локальным Go.

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

- c.JSON из res.go делает три шага: JSONEncoder из конфига (дефолт json.Marshal), SetBodyRaw и Content-Type application/json; charset=utf-8 (MIMEApplicationJSONCharsetUTF8); второй аргумент перекрывает заголовок.
- fiber.Map — type Map map[string]any для мелких ответов; ключи карт сортируются по алфавиту. Данные — структуры с тегами json: имена ключей, omitempty и json:"-" задаёт encoding/json, общий и для gin.
- В Fiber v3 нет IndentedJSON, PureJSON, SecureJSON и AsciiJSON из gin: IndentedJSON заменяет подмена JSONEncoder в fiber.Config; экранирование <, >, & — общее с базовым gin. Взамен есть JSONP, MsgPack и CBOR.
- Коды ответов: c.Status(code) ставит код и возвращает Ctx для цепочки .JSON(...); c.SendStatus(code) сбрасывает тело для 1xx/204/205/304 и пишет статусную фразу utils.StatusMessage при пустом теле. Канон: 200 на список, 201 на создание, 204 на удаление.
- BodyParser из v2 заменён c.Bind(): Body — диспетчер по Content-Type (json/xml/form/msgpack/cbor, иначе ErrUnprocessableEntity 422), JSON — всегда JSON, плюс Query, URI, Header, Cookie и All (порядок URI → тело → query → заголовки → cookie).
- Ошибки биндинга в ручном режиме — *BindError (Err, Source, Field из UnmarshalTypeError) без статуса: необработанная ошибка уезжает в 500 и text/plain. WithAutoHandling ставит 400 и Bad request: ...; контролируемый путь — errors.As + c.Status(400).JSON.
- Батч-разбор массива идёт тем же Bind().Body в срез; тело ограничено BodyLimit (4 MiB по умолчанию); validateStruct после разбора спрашивает config.StructValidator и по умолчанию пропускается — мост к десятому уроку.

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

Развейте каркас: добавьте батч-импорт POST /api/tasks:import, который разбирает c.Bind().Body(&newTasks) в срез и возвращает {"items": [...]}; включите c.Bind().WithAutoHandling() в отдельном хендлере и сравните его ответ со своим JSON-вариантом из урока; подмените JSONEncoder на обёртку json.MarshalIndent и найдите отступы в ответе, затем верните дефолт; пришлите битый JSON (лишняя запятая) и убедитесь, что ошибка приходит по той же ветке errors.As с source body; наконец проверьте SendStatus(fiber.StatusResetContent) и объясните в комментарии, почему тело пусто.

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

### Почему у меня не работает c.BodyParser — он же есть во всех туториалах?

BodyParser — API второго поколения Fiber. В v3 на его месте метод c.Bind(): комментарий Replacement of: BodyParser, ParamsParser... в ctx.go называет его прямой заменой. Перевод механический: c.BodyParser(&in) → c.Bind().Body(&in), а точечные методы — c.Bind().JSON, c.Bind().Query, c.Bind().URI — покрывают бывшие ParamsParser и QueryParser.

### Чем 422 отличается от 400 при разборе JSON?

422 (StatusUnprocessableEntity, ErrUnprocessableEntity) — когда Bind().Body не нашёл парсер: Content-Type не входит в список json/xml/form/msgpack/cbor. 400 (StatusBadRequest) — когда парсер найден, но тело не разобралось: битый JSON, несовпадение типов (UnmarshalTypeError), пустые обязательные поля. Первое лечится заголовком, второе — телом запроса.

### Как получить JSON с отступами, как IndentedJSON в gin?

Отдельного метода в v3 нет — подмените кодировщик: fiber.New(fiber.Config{JSONEncoder: func(v any) ([]byte, error) { return json.MarshalIndent(v, "", "  ") }}). Все c.JSON начнут отдавать с отступами. Для локальной отладки дешевле curl | jq, а в проде красивая печать — лишние байты и процессорное время на каждый ответ.
