Содержание урока · 14 разделов
- fiber json: байты в обе стороны
- fiber.Map против структур: форма ответа
- Теги json: omitempty и цена тишины
- Fiber против gin: честное сравнение JSON-методов
- Коды ответов: Status, SendStatus и статусные фразы
- Коды ответов JSON-API и константы Fiber
- BodyParser уходит: c.Bind() в v3
- Bind: источники данных и выбор парсера
- Ошибки биндинга: BindError, UnmarshalTypeError и 400
- JSON-массив: батч-импорт и тело побольше
- Запуск, проверка curl-ом и что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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
GOpackage 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 уедет?
Fiber против gin: честное сравнение JSON-методов#
В пятом уроке курса gin разобрано целое семейство ответов: IndentedJSON с отступами для отладки, PureJSON без HTML-экранирования, SecureJSON с префиксом против дед hijacking и AsciiJSON с \u-эскейпами. Сверим с тегом v3.5.0: в Fiber v3 ни одного из этих методов нет — c.JSON ровно один, никаких IndentedJSON или PureJSON на Ctx не существует (подробное сравнение — в пятом уроке курса gin).
Честная картина по обе стороны. Экранирование одинаковое: 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 предлагает посчитать, нужны ли вам точные байты вообще.
Что уедет в теле ответа?
Прочитайте код и выберите, что он выведет:
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: ""})Коды ответов: 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), и тело ответа пусто. Что получит клиент?
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
Прочитайте код и выберите, что он выведет:
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": "позвонить клиенту"}Ошибки биндинга: 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 и без своих ответов. Клиент прислал строку в числовом поле. Что увидит клиент?
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
GOpackage 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-телом по порядку: от команды в терминале до последнего заголовка ответа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Допишите хендлер создания задачи
Вставьте пропуски: метод разбора, код для битого запроса, цепочка статуса и код создания ресурса.
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 покажет код и заголовки сразу — та же дисциплина наблюдения, что и в предыдущем уроке, только теперь мы смотрим ещё и на тело.
Словарь урока
Запуск, проверка 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 обязателен» перестанет быть ручной проверкой. Обновить слои и стража — в восьмом уроке, фабрику слоёв и c.Locals — в седьмом, полный план — на странице курса Fiber. В браузерной песочнице сайта сетевые приложения не запускаются — runner-ов в уроках Fiber нет, проверяйте примеры в терминале с локальным Go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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, а в проде красивая печать — лишние байты и процессорное время на каждый ответ.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT