---
title: "JSON API: парсим и отдаём JSON"
description: "JSON API на chi: json.NewEncoder и NewDecoder в хендлерах, Content-Type до WriteHeader, MaxBytesReader, единый 400-формат ошибок. Примеры на каркасе /notes."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/11-json-api/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# JSON API: парсим и отдаём JSON

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

JSON API на go с chi собирается из двух функций стандартной библиотеки: json.NewEncoder отдаёт объекты клиенту, json.NewDecoder разбирает тела запросов. Разберём порядок заголовков и WriteHeader, лимит MaxBytesReader, единый формат 400-ошибок и спор «стриминг против Marshal» — на живом каркасе /notes.

Ситуация знакомая: REST-каркас из [урока про маршруты и методы](/courses/chi/lessons/03-marshruty-metody/) отвечает на POST /notes строкой «заметка создана», а фронтенд-разработчик рядом делает JSON.parse этой строки и получает SyntaxError. До сих пор клиентами сервиса были curl и Postman, которые едят любой текст; настоящий клиент ожидает JSON и в ответе, и в запросе. Хорошая новость в том, что json api в go не требует ни одного пакета поверх стандартной библиотеки: json.NewEncoder пишет объекты прямо в ResponseWriter, json.NewDecoder читает тела запросов, а chi не вмешивается ни во что из этого — хендлеры остаются чистым net/http. Но между «вызвать функцию» и «работающий JSON API» лежат четыре ловушки: порядок заголовков и WriteHeader, обработка битых тел, лимит размера и выбор между Encoder и Marshal. Каждую разберём на одном и том же хендлере заметок.

### Отдаём JSON в chi: json.NewEncoder и Content-Type

Ответ JSON-ом — три строки, и порядок в них жёсткий. Сначала w.Header().Set("Content-Type", "application/json") — заголовки можно менять только до первой записи в w. Потом w.WriteHeader(http.StatusCreated) — отправка статус-строки и заголовков клиенту; с этого момента заголовки заморожены, а повторный WriteHeader приводит к строке «superfluous WriteHeader» в логе сервера. Наконец json.NewEncoder(w).Encode(note) — энкодер получает ResponseWriter как io.Writer и пишет сериализованный объект прямо в сеть, без полной копии в памяти. Статус 201 Created — не украшение: он означает «ресурс создан», и клиентские библиотеки, генераторы типов и мониторинг читают его как сигнал. Пропустите WriteHeader — Go отправит 200 по умолчанию, и REST-контракт «создано» потеряется. Пропустите Set Content-Type — самая дорогая из ошибок урока, о ней отдельная врезка ниже. Сам Encode, кроме объекта, дописывает в конец один символ переноса строки — деталь, которая всплывёт в тестах.

### Хендлер создания: 201 и JSON в ответ

```go
type noteRequest struct {
        Title string `json:"title"`
        Body  string `json:"body"`
}

type noteResponse struct {
        ID    int    `json:"id"`
        Title string `json:"title"`
}

func createNote(w http.ResponseWriter, r *http.Request) {
        var req noteRequest
        err := json.NewDecoder(r.Body).Decode(&req)
        if err != nil {
                writeErr(w, http.StatusBadRequest, "битый JSON")
                return
        }

        n := saveNote(req.Title, req.Body)

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(n)
}
```

Хендлер читает тело через Decoder, создаёт заметку в хранилище и отвечает тройкой «заголовок, статус, тело». Структура noteResponse с тегами json:"id" — поля наружу идут в snake_case, а не в Go-вом Title. saveNote здесь заглушка хранилища: в настоящем сервисе это вызов слоя репозитория. Ответ пишется одним вызовом Encode: объект, перенос строки — готово.

> **Забытый Content-Type.** Хендлер отвечает валидным JSON, а фронтенд падает с Unexpected token — виной отсутствие заголовка. Без явного Content-Type сервер снифит первые 512 байт тела и для JSON честно угадывает text/plain; charset=utf-8. Дальше расхождение: браузер при навигации не понимает, что это данные, и предлагает скачать файл; fetch() в приложении вызывает res.json(), тот сверяет MIME и бросает ошибку до парсинга. Баг всплывает не на бэкенде, а «где-то во фронте», и стоит полдня перекрёстных разборов. Привычка одна: Content-Type ставится первой строкой ответа, до WriteHeader — и закрепляется тестом.

### Проверка: порядок записи ответа

**Вопрос:** Хендлер сначала вызвал json.NewEncoder(w).Encode(note), а уже потом w.Header().Set("Content-Type", "application/json") и w.WriteHeader(201). Что получит клиент?

- 201 Created и application/json: заголовки применяются до конца хендлера
- ✅ 200 OK и text/plain; charset=utf-8: первая запись уже отправила статус, поздние Set и WriteHeader игнорируются
- 500 Internal Server Error: chi запрещает менять заголовки после тела
- 201 Created, но с пустым телом

Первый Write внутри Encode отправляет клиенту статус 200 и заголовки — сниффер угадывает text/plain. Header().Set после этого меняет карту, которую уже никто не отправит, а второй WriteHeader даёт строку superfluous WriteHeader в логе. Заголовки замораживаются первой записью, а не концом хендлера.

### Парсим тело запроса: json.NewDecoder

Входящий JSON читает json.NewDecoder(r.Body): декодер наворачивается на поток тела и раскладывает значения в структуру через Decode(&req). Три факта, которые экономят часы. Первый: Decode читает ровно одно JSON-значение — если клиент прислал два объекта подряд, второй останется в теле непрочитанным; для API это нормально, но знать стоит. Второй: пустое тело возвращает io.EOF — ту же ошибку, что и битый JSON, и обе ветки ведут в один ответ 400. Третий: Decode не закрывает тело запроса, и закрывать его в хендлере не нужно — документация net/http прямо снимает эту заботу с хендлера: Server закроет Body сам. Привычка из клиентского кода, где resp.Body закрывает вызывающий, на серверной стороне не работает. Ошибка Decode — почти всегда вина клиента, и код ответа соответствующий: 400 Bad Request, ровно как на битом id из пути в уроке про параметры.

### Единый формат ошибок: writeErr

```go
func writeErr(w http.ResponseWriter, status int, msg string) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(status)
        json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
```

Единый формат ошибок — {"error": "..."} — договор с клиентом: фронтенд пишет один обработчик на все ответы со статусом не 2xx и читает поле error. writeErr сам ставит Content-Type и статус, поэтому вызов в хендлере — одна строка и return. Дальше по курсу этот помощник вырастет в централизованный обработчик ошибок на весь роутер — здесь важен сам контракт.

### MaxBytesReader: лимит тела запроса

Content-Length в заголовке запроса обещает размер, но обещание не гарантия: при chunked-кодировке заголовка нет вообще, а прокси и шлюзы умеют менять тело. Хендлер, который доверяет Decode без лимита, предлагает злоумышленнику отправить гигабайт JSON и съесть столько же памяти. Стандартное лекарство — http.MaxBytesReader(w, r.Body, 1<<20): обёртка над телом с потолком в 1 048 576 байт; запись 1<<20 — идиоматичное «один мегабайт», а число выбирается по самому большому легитимному запросу вашего API. При превышении Read возвращает ошибку «http: request body too large», а с Go 1.19 она имеет тип *http.MaxBytesError и ловится через errors.As. Тонкость из исходников: сервер при превышении помечает соединение на закрытие (Connection: close) и рвёт его после вашего ответа — статус при этом выбираете вы, автоматического 413 у сервера нет.

### readJSON: лимит, декод, различение ошибок

```go
func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
        r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

        err := json.NewDecoder(r.Body).Decode(dst)
        if err != nil {
                var maxErr *http.MaxBytesError
                if errors.As(err, &maxErr) {
                        writeErr(w, http.StatusRequestEntityTooLarge, "тело больше лимита")
                } else {
                        writeErr(w, http.StatusBadRequest, "битый JSON")
                }
                return err
        }

        return nil
}

// в хендлере:
var req noteRequest
if err := readJSON(w, r, &req); err != nil {
        return // ответ уже отправлен
}
```

Обёртка readJSON собирает всё вместе: лимит, декод и различение причин ошибки. errors.As проверяет, что перед нами именно *http.MaxBytesError, — тогда 413 Request Entity Too Large; иначе битый JSON и 400. После любой ошибки ответ уже написан, поэтому хендлер просто делает return. Проверьте обе ветки на живом сервисе: curl -i с телом в два мегабайта и с обрезанным JSON — запускать нужно локальным Go, песочница сайта сеть не видит.

### Ситуация, код ответа и тело

| Ситуация | Код ответа | Тело ответа |
| --- | --- | --- |
| Заметка создана | 201 Created | {"id":7,"title":"..."} |
| Битый JSON или пустое тело | 400 Bad Request | {"error":"битый JSON"} |
| Тело больше лимита MaxBytesReader | 413 Request Entity Too Large | {"error":"тело больше лимита"} |
| JSON валиден, но это массив вместо объекта | 400 Bad Request | {"error":"битый JSON"} |
| Обязательное поле title пустое | 400 Bad Request | {"error":"title обязателен"} |

### Проверка: превышение лимита

**Вопрос:** В readJSON лимит http.MaxBytesReader(w, r.Body, 1<<20). Клиент отправил POST с телом 2 МБ, Decode вернул ошибку. Что вернёт клиенту readJSON?

- 400 Bad Request: любая ошибка чтения трактуется как битый JSON
- ✅ 413 Request Entity Too Large: errors.As распознал *http.MaxBytesError
- 500 Internal Server Error: превышение лимита — вина сервера
- 200 OK с усечённым телом: Decode прочитает сколько сможет

MaxBytesReader при превышении возвращает ошибку «http: request body too large», а с Go 1.19 — значение типа *http.MaxBytesError, которое errors.As отличает от синтаксической ошибки JSON. Статус выбирает хендлер: автоматического 413 у сервера нет — он лишь закроет соединение после ответа.

### Что увидит клиент?

```go
func weird(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"a": "1"})
        json.NewEncoder(w).Encode(map[string]string{"b": "2"})
}
```

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

- {"a":"1"} — второй Encode перезаписал тело
- ✅ {"a":"1"} и {"b":"2"} подряд в одном ответе — два объекта через перенос строки
- 500 Internal Server Error: двойная запись в ответ запрещена
- {"a":"1","b":"2"} — объекты сольются в один

Encode пишет в w немедленно и ничего не знает о соседних вызовах: тело — два JSON-объекта подряд, разделённые переносами. Как один документ это невалидно: строгий парсер прочитает первый объект и остановится — ровно так же Decode читает одно значение, а остаток тела игнорирует.

> **Encode дописывает перенос строки.** json.Encoder.Encode после объекта дописывает \n — задокументированное поведение энкодера. В тесте сравнение тела со строкой {"id":7} упадёт: в ответе {"id":7}\n. Лечится strings.TrimSpace(rec.Body.String()) перед сравнением или повторным Unmarshal в структуру. И помните про predict выше: второй Encode дописывает второй объект — для единого ответа вызывайте его один раз.

### json.Marshal против json.NewEncoder: когда важна память

Спор, который делит команды на два лагеря. Стриминговая пара NewEncoder/NewDecoder не держит ответ в памяти целиком: байты уходят в сеть по мере сериализации, и для больших выгрузок это единственный вариант без лишних копий. Против — json.Marshal и Unmarshal: []byte перед глазами, его можно обогатить перед записью, залогировать, сравнить в тесте без HTTP-слоя, положить в кэш. Моё правило: в хендлере — Encoder, потому что ответ пишется один раз и целиком; когда []byte нужен второй раз — Marshal. «Второй раз» — это ответ-конверт {"data": ..., "meta": ...} вокруг marshal-нутых данных, кэширование сериализованного результата, дублирование в лог для отладки. Граница нарушения понятна: маршихэл ради трёх строк ответа — лишняя аллокация и шум. Если теги, omitempty и вложенные структуры у вас со времён [урока про JSON продвинутого курса](/courses/go-advanced/lessons/21-json/) не всплывали — загляните: encoding/json внутри хендлера ведёт себя точно так же.

### Гигиена JSON-хендлера

- Content-Type: application/json — первой строкой ответа, до WriteHeader и до любой записи в w.
- Статус несёт смысл: 201 на создание, 400 на мусор от клиента, 413 на тело сверх лимита.
- Все ошибки — в одном формате {"error": "..."} через writeErr: клиент пишет один обработчик на всё.
- Каждый вход с телом — через readJSON с http.MaxBytesReader; лимит равен максимуму легитимного запроса.
- Структуры ответов — отдельные *Response-типы с json-тегами; структуры хранилища наружу не отдаём.
- Тесты сравнивают JSON после strings.TrimSpace или повторным Unmarshal: Encode дописывает \n.

Собранный каркас проверяется без сети: httptest.NewRequest делает *http.Request с телом из strings.NewReader, httptest.NewRecorder подставляет себя вместо ResponseWriter и запоминает всё, что в него писали. Проверки простые: rec.Code равен 400 на битом теле, Content-Type — application/json, а после Unmarshal rec.Body в структуру поле error содержит ожидаемую строку. Три кейса — битый JSON, превышение лимита, счастливый путь — закрывают обе ветки readJSON. Инструментарий assert-проверок разобран в [курсе testify](/courses/testify/), а системному тестированию хендлеров chi посвящён отдельный урок ближе к финалу курса. Прогон тестов, как и сервер, запускается локальным Go — этого достаточно, чтобы поймать все четыре ловушки урока до деплоя.

### Соберите writeErr и readJSON

Восстановите три опоры JSON-ответа: заголовок, энкодер и лимит тела.

```go
func writeErr(w http.ResponseWriter, status int, msg string) {
        w.Header().Set("___", "application/json")
        w.WriteHeader(status)
        json.___(w).Encode(map[string]string{"error": msg})
}

func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
        r.Body = http.___(w, r.Body, 1<<20)
        return json.NewDecoder(r.Body).Decode(dst)
}
```

Подсказка: Заголовок называется Content-Type, объект пишет json.NewEncoder, а лимит ставит http.MaxBytesReader.

Правильные ответы в пропусках: `Content-Type`, `NewEncoder`, `MaxBytesReader`

### Соберите хендлер по строкам

Расставьте строки хендлера создания в правильном порядке: от чтения тела до записи JSON.

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

1. `var req noteRequest`
2. `err := json.NewDecoder(r.Body).Decode(&req)`
3. `if err != nil { writeErr(w, http.StatusBadRequest, "битый JSON"); return }`
4. `w.Header().Set("Content-Type", "application/json")`
5. `w.WriteHeader(http.StatusCreated)`
6. `json.NewEncoder(w).Encode(n)`

### Карточки: JSON в хендлерах chi

- **Content-Type: application/json** — Ставится до WriteHeader: заголовки замораживаются первой записью в w. Без него сервер сниффит text/plain, fetch() падает на res.json(), браузер предлагает скачать файл.
- **json.NewEncoder(w).Encode(v)** — Пишет JSON прямо в ResponseWriter стримингом, без полной копии в памяти, и дописывает \n в конце. Пара к нему — WriteHeader со статусом до вызова.
- **json.NewDecoder(r.Body).Decode(&req)** — Читает ровно одно JSON-значение из тела в структуру; пустое тело даёт io.EOF. Тело не закрывает — за серверные запросы это делает net/http сам.
- **http.MaxBytesReader(w, r.Body, 1<<20)** — Обёртка тела с лимитом байт (1<<20 — один мегабайт). При превышении — *http.MaxBytesError (Go 1.19+), которую errors.As отличает от битого JSON; сервер закроет соединение после ответа.
- **400 против 413** — 400 Bad Request — клиент прислал мусор (битый JSON, пустое обязательное поле); 413 — тело честное, но больше лимита. Оба формата — единый {"error": ...}.
- **Encoder против Marshal** — Encoder — стриминг в w для ответов, которые пишутся один раз. Marshal — когда []byte нужен до записи: конверт data/meta, кэш, лог, тест без HTTP-слоя.

Хендлеры каркаса заговорили на JSON: отдача через Encoder с правильным Content-Type и 201, приём через Decoder с 400 в едином формате и лимитом MaxBytesReader на входе. Из практики напоследок: включите лимит даже в пет-проекте — первый же кривой клиент с бесконечным телом окупит десять минут настройки. Следующий шаг — [собственный middleware](/courses/chi/lessons/12-own-middleware/): таймер-лог с RequestID и авторизация по API-ключу уходят из хендлеров в обёртку func(http.Handler) http.Handler, и writeErr остаётся у хендлеров единственной общей заботой.

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

- JSON API в chi — стандартная библиотека: json.NewEncoder пишет ответ в w, json.NewDecoder читает тело; фреймворк в это не вмешивается.
- Content-Type: application/json ставится до WriteHeader — заголовки замораживаются первой записью в w.
- Ошибку Decode не прячем: битый JSON и пустое тело — 400 в едином формате {"error": "..."}.
- http.MaxBytesReader ограничивает тело (1<<20 — мегабайт); превышение ловится errors.As по *http.MaxBytesError и отвечает 413.
- Decode не закрывает тело: за серверные запросы отвечает net/http, клиентские resp.Body закрывает вызывающий.
- Encode пишет тело сразу и дописывает \n — двойной вызов даёт два объекта подряд, тесты сравнивайте после TrimSpace.
- Marshal берите, когда []byte нужен до записи: конверт, кэш, лог, тест; в остальном — стриминг Encoder.

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

Добавьте в каркас POST /notes с парсингом через readJSON: битый JSON отвечает 400 с {"error":"битый JSON"}, тело больше 1 МБ — 413, пустой title — 400. Проверьте curl-ом все три ветки и убедитесь, что Content-Type каждого ответа — application/json.

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

### Обязательно ли закрывать r.Body после Decode?

Нет: серверные тела закрывает net/http сам — документация снимает это с хендлера, у json.Decoder метода Close нет. Закрывать нужно клиентские ответы в http-клиенте: там resp.Body и его Close — другое место кода. Лимит размера всё равно ставьте: MaxBytesReader про объём, а не про закрытие.

### Почему без Content-Type браузер предлагает скачать файл?

Не получив заголовок, сервер сниффит первые 512 байт тела, и JSON угадывается как text/plain; charset=utf-8. Браузер при навигации не знает, что это данные для скрипта, и предлагает загрузку. fetch() в приложении падает раньше: res.json() сверяет MIME и бросает ошибку, не доходя до парсинга.

### json.Marshal или json.NewEncoder — что выбрать в хендлере?

Дефолт — Encoder: пишет прямо в ResponseWriter без полной копии в памяти и добавляет завершающий перенос строки. Marshal берите, когда []byte нужен до записи: обогатить ответ конвертом, залогировать, протестировать без HTTP-слоя, положить в кэш. Граница простая: байты нужны второй раз — Marshal.
