JSON: синтаксис формата и Content-Type application/json в HTTP

JSON: синтаксис формата и Content-Type application/json в HTTP Полезное

JSON (JavaScript Object Notation) — это текстовый формат записи структурированных данных: объекты из пар «ключ: значение», массивы, строки, числа и литералы true, false, null. Синтаксис взят из литералов JavaScript, но сам формат от языка не зависит: его читают и пишут Python, Java, C#, PHP, Go и почти любые другие языки. Сейчас он описан двумя согласованными стандартами — RFC 8259 и ECMA-404.

Чтобы не путать, сразу разведу три понятия, которые в статьях о JSON часто склеивают:

  • JSON-текст — сама строка вида {"points": 1250};
  • объект в памяти программы — словарь Python или объект JavaScript, который получается после разбора этой строки;
  • Content-Type: application/json — HTTP-заголовок, которым отправитель сообщает получателю, что тело сообщения нужно читать как JSON.

Дальше по порядку: синтаксис, разбор в Python и JavaScript, заголовки Content-Type и Accept на рабочем сервере и частые ошибки.

Синтаксис JSON: из чего состоит документ

Пример — ответ сервера бонусной программы магазина по карте покупателя:

{
  "name": "Анна",
  "points": 1250,
  "vip": false,
  "city": null,
  "last_orders": [101, 205, 318],
  "card": {"number": "7001-2233", "level": "silver"}
}

Правила, по которым строится любой JSON-документ:

  • объект заключен в фигурные скобки, пары разделяются запятыми, ключ отделяется от значения двоеточием;
  • ключ — всегда строка в двойных кавычках;
  • массив — упорядоченный список значений в квадратных скобках, значения могут быть разных типов;
  • после последнего элемента запятая не ставится;
  • комментариев в JSON нет;
  • пробелы и переносы строк между элементами допустимы и ни на что не влияют.

Значение может быть одного из шести типов:

Тип JSON Пример Python после json.loads JavaScript после JSON.parse
объект {"a": 1} dict Object
массив [1, "два"] list Array
строка "Анна" str string
число 1250, -3.5, 1e3 int или float number
логический true, false True, False true, false
пусто null None null

Ограничения, о которые спотыкаются чаще всего. Строки только в двойных кавычках, спецсимволы экранируются обратной косой чертой (\", \n, \u0410). Числа только десятичные: без ведущих нулей (007 недопустимо), без шестнадцатеричной записи, без NaN и Infinity. Типа «дата» нет — даты передают строкой, обычно в формате ISO 8601 ("2026-09-23T10:00:00Z").

Повторяющиеся ключи в одном объекте стандарт не запрещает, но поведение в этом случае не определено — разные библиотеки поступают по-разному. Отправлять такие документы не стоит.

Разбор и сериализация: JSON.parse и json.loads

Перевод JSON-текста в объекты программы называют разбором (parsing, десериализация), обратный путь — сериализацией. Минимальный полный пример на Python (проверено на Python 3.14):

import json

raw = '{"name": "Анна", "points": 1250, "vip": false, "city": null, "last": [101, 205]}'

data = json.loads(raw)          # JSON-текст -> объекты Python
print(type(data).__name__, data["points"] + 50, data["vip"], data["city"])

data["points"] = 1300
text = json.dumps(data, ensure_ascii=False)   # объекты Python -> JSON-текст
print(text)
print(json.dumps({"name": "Анна"}))           # по умолчанию не-ASCII экранируется

Вывод:

dict 1300 False None
{"name": "Анна", "points": 1300, "vip": false, "city": null, "last": [101, 205]}
{"name": "\u0410\u043d\u043d\u0430"}

Разбор по строкам. json.loads вернул обычный словарь, поэтому с points можно сразу считать. false и null превратились в False и None. При обратном переводе json.dumps без параметров экранирует кириллицу в \uXXXX — это валидный JSON, но читать его неудобно; ensure_ascii=False оставляет символы как есть. Для файлов есть пара json.load / json.dump, они работают с открытым файлом, а не со строкой.

То же самое в JavaScript (проверено в Node.js 24):

const raw = '{"name": "Анна", "points": 1250, "vip": false, "city": null}';
const user = JSON.parse(raw);            // JSON-текст -> объект JS
console.log(user.points + 50, user.vip, user.city);

user.points = 1300;
console.log(JSON.stringify(user));       // объект JS -> JSON-текст
1300 false null
{"name":"Анна","points":1300,"vip":false,"city":null}

Никогда не разбирайте JSON через eval() или аналоги: если строка пришла извне, это выполнение чужого кода. JSON.parse и json.loads только читают данные.

Content-Type: application/json в HTTP

Content-Type — заголовок HTTP-сообщения, который указывает медиатип (MIME-тип) тела. Медиатип состоит из типа и подтипа: text/html, image/png, application/json. Для JSON зарегистрирован именно application/json; text/plain и application/javascript для него неверны.

Важно направление заголовка:

Заголовок Кто ставит Что сообщает
Content-Type в запросе клиент «тело моего запроса — JSON»
Content-Type в ответе сервер «тело ответа — JSON»
Accept в запросе клиент «я умею читать такие форматы ответа»

Если у GET-запроса нет тела, то и Content-Type в нем не нужен. Accept: application/json — это просьба, а не гарантия: сервер может ответить другим форматом или ошибкой 406 Not Acceptable, поэтому клиенту стоит проверять тип ответа.

Кодировка: по RFC 8259 JSON, передаваемый между системами, должен быть в UTF-8. Параметр charset для application/json не определен; запись application/json; charset=utf-8 встречается часто и на разбор не влияет, поэтому сервер должен ее принимать.

Рабочий пример: сервер, который проверяет тип контента

Учебный сервер на стандартной библиотеке Python: отдает JSON на GET и принимает JSON на POST. Проверки в POST идут в порядке «тип -> размер -> синтаксис -> смысл», и на каждую есть свой код ответа.

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

MAX_BODY = 64 * 1024  # лимит тела запроса, байт


class Handler(BaseHTTPRequestHandler):
    def send_json(self, status, payload):
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        self.send_json(200, {"name": "Анна", "points": 1250})

    def do_POST(self):
        ctype = self.headers.get("Content-Type", "")
        if ctype.split(";")[0].strip().lower() != "application/json":
            return self.send_json(415, {"error": "ожидается application/json"})
        length = self.headers.get("Content-Length", "")
        if not length.isdigit():
            return self.send_json(411, {"error": "нужен Content-Length"})
        length = int(length)
        if length > MAX_BODY:
            return self.send_json(413, {"error": "слишком большое тело"})
        try:
            data = json.loads(self.rfile.read(length))
        except (json.JSONDecodeError, UnicodeDecodeError, RecursionError):
            return self.send_json(400, {"error": "некорректный JSON"})
        points = data.get("points") if isinstance(data, dict) else None
        if type(points) is not int:  # bool - подкласс int, его отсекаем
            return self.send_json(422, {"error": "нужно поле points (целое)"})
        self.send_json(200, {"ok": True, "points": points})


HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

Запускаем python3 server.py и во втором терминале шлем запросы curl. Флаг -d без явного заголовка отправляет тело как application/x-www-form-urlencoded, поэтому третий запрос сервер отклоняет:

curl -i http://127.0.0.1:8000/
curl -X POST http://127.0.0.1:8000/ -H "Content-Type: application/json" -d '{"points": 1300}'
curl -X POST http://127.0.0.1:8000/ -d '{"points": 1300}'
curl -X POST http://127.0.0.1:8000/ -H "Content-Type: application/json" -d "{'points': 1300}"
curl -X POST http://127.0.0.1:8000/ -H "Content-Type: application/json" -d '{"points": "1300"}'

Фактический результат (код ответа дописан в скобках, строка Date опущена):

HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.14.7
Content-Type: application/json
X-Content-Type-Options: nosniff
Content-Length: 36

{"name": "Анна", "points": 1250}
{"ok": true, "points": 1300} [200]
{"error": "ожидается application/json"} [415]
{"error": "некорректный JSON"} [400]
{"error": "нужно поле points (целое)"} [422]

Что здесь важно. 415 Unsupported Media Type — ответ на неверный Content-Type, 400 — на синтаксически битый JSON, 422 — на правильный JSON с неподходящими данными. Разделять эти случаи полезно: клиент сразу видит, что чинить. RecursionError в списке исключений не лишний: тело из десятков тысяч открывающих скобок [[[[... укладывается в лимит 64 КБ, но разбор падает на глубине вложенности, и без этой строки обработчик рвет соединение без ответа (проверено на Python 3.14). Заголовок X-Content-Type-Options: nosniff запрещает браузеру «угадывать» тип по содержимому и толковать ответ иначе, чем объявлено.

Граница примера: это учебный сервер. http.server не предназначен для production — там нужны фреймворк или сервер приложений, аутентификация, HTTPS, журналирование и проверка схемы данных (например, по JSON Schema), а не одного поля.

Клиент: отправить JSON и проверить ответ

const res = await fetch("http://127.0.0.1:8000/", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Accept": "application/json" },
  body: JSON.stringify({ points: 1300 }),
});
const type = res.headers.get("content-type") ?? "";
if (!res.ok || !type.startsWith("application/json")) {
  throw new Error(`Ожидали JSON, получили ${res.status} ${type}`);
}
const data = await res.json();   // разбор тела ответа как JSON
console.log(res.status, type, data);
200 application/json { ok: true, points: 1300 }

Проверка типа добавлена не случайно: res.json() пытается разобрать тело независимо от заголовка. Если прокси или сервер вернул HTML-страницу ошибки, без проверки читатель получит невнятный SyntaxError вместо понятного сообщения о статусе.

Частые ошибки при работе с JSON

Одиночные кавычки — самая частая ошибка у тех, кто пишет JSON руками или склеивает строку из словаря Python через str():

import json

bad = "{'name': 'Анна', 'points': 1250,}"
try:
    json.loads(bad)
except json.JSONDecodeError as e:
    print("Ошибка:", e)

good = '{"name": "Анна", "points": 1250}'
print(json.loads(good))
Ошибка: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
{'name': 'Анна', 'points': 1250}

Исправление — двойные кавычки и никакой запятой в конце. Строку JSON правильнее получать через json.dumps, а не собирать вручную. Лишняя запятая при верных кавычках в Python 3.14 дает отдельное сообщение Illegal trailing comma before end of object; в старых версиях текст ошибки другой. JavaScript на одиночные кавычки отвечает так (Node.js 24): SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2). Текст ошибки зависит от движка, поэтому в коде ловят тип исключения, а не сообщение.

Остальные типовые ловушки удобнее свести в таблицу. Все результаты получены прогоном на Python 3.14 и Node.js 24:

Ситуация Что происходит Как делать
Большое целое в JS: JSON.parse('{"id": 9007199254740993}') получается 9007199254740992 — точность потеряна, число больше 2^53 крупные идентификаторы передавать строкой
json.dumps(float("nan")) в Python выводит NaN — это не валидный JSON json.dumps(..., allow_nan=False) бросит ValueError
JSON.stringify({f: undefined, n: NaN}) поле f пропадает, NaN становится null не полагаться на такие значения в API
Дата в Python: json.dumps({"t": date(...)}) TypeError: Object of type date is not JSON serializable переводить в строку ISO 8601 (d.isoformat())
Повторный ключ {"id": 1, "id": 2} Python оставляет последнее значение: {'id': 2} не отправлять дубли, валидировать вход

Выводы

  • JSON — текстовый формат из шести типов значений; ключи и строки только в двойных кавычках, без комментариев, лишних запятых, NaN и дат как отдельного типа.
  • JSON-текст, объект в памяти и заголовок Content-Type: application/json — три разные вещи: строку разбирают JSON.parse или json.loads, заголовок лишь сообщает, как читать тело.
  • В запросе с JSON-телом клиент ставит Content-Type: application/json, желаемый формат ответа указывает в Accept; сервер помечает свой ответ тем же Content-Type.
  • Сервер, принимающий JSON, проверяет тип, размер, синтаксис и смысл данных и отвечает разными кодами: 415, 413, 400, 422.
  • Самые дорогие ошибки — потеря точности больших чисел в JavaScript и разбор недоверенных данных через eval.

Где применяется / связь с практикой

Освойте тему на практике

JSON — основной формат большинства современных веб-API: мобильные приложения, фронтенд на React или Vue, микросервисы и интеграции с внешними системами обмениваются именно им. Как только API становится больше двух-трех методов, вопросы из этой статьи превращаются в проектные решения: какие коды ответа возвращать, как описать схему данных, как версионировать контракт и документировать его в OpenAPI. Этим занимается курс «Проектирование API».

Освойте тему на практике

Если хочется сначала посмотреть на формат занятий, на открытых уроках Otus регулярно разбирают REST, HTTP и работу с данными в живом эфире.

FAQ

Нужно ли писать charset=utf-8 в Content-Type для JSON?
Не обязательно: для application/json параметр charset не определен, а сам JSON между системами передается в UTF-8. Если клиент его прислал, сервер должен спокойно принять такой заголовок.

Чем JSON отличается от JSON5 и JSONC?
Это надстройки для конфигурационных файлов, где разрешены комментарии, а в JSON5 еще и одиночные кавычки и лишние запятые. Стандартные JSON.parse и json.loads такие файлы не читают, в HTTP-API их не используют.

Что такое JSONP и нужен ли он сейчас?
JSONP — старый прием обхода политики одного источника: данные заворачивались в вызов функции и загружались тегом <script>, то есть исполнялись как код. Сейчас для запросов к другому домену используют CORS, а JSONP считается небезопасным наследием.

Как быть, если сервер отдает JSON с Content-Type: text/html?
Это ошибка конфигурации сервера: клиенты и браузеры будут обрабатывать ответ как HTML. Правильно исправить заголовок на сервере; на клиенте проверять тип ответа до вызова res.json().

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)