DarkRiDDeR9 мин

PHP. Как отличить битый JSON от корректного null в ответе API

PHPJSONИнтеграции

В обработчике ответа часто встречается одна строка: if (!$data) { throw new Exception("bad response"); }. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное null, число 0 или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет JSON_THROW_ON_ERROR, поэтому после json_decode() нужно явно проверить состояние декодера.

Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем json_last_error(). Только если там JSON_ERROR_NONE, проверяем тип и обязательные поля ответа. Цена ошибки — показать пользователю пустой результат там, где партнёр вернул повреждённый или чужой формат.

Почему null не доказывает ошибку

По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, false, true и null. PHP отражает это напрямую: json_decode("null") возвращает null, но null возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.

То же происходит с пустыми коллекциями. После json_decode("[]", true) получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же if (!$data) назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.

Схема диагностики JSON: сырой ответ сначала проходит json_decode и json_last_error, затем проверку типа и обязательных полей контракта
Ошибка декодирования и нарушение контракта — разные события. У них разные владельцы и разные действия.

Короткая таблица, которую стоит держать рядом с кодом

Сырой ответРезультат json_decode(..., true)json_last_errorЧто это значит для клиента
{"order_id":"A-17"}ассоциативный массивJSON_ERROR_NONEПроверить поле order_id и принять ответ
[]пустой массивJSON_ERROR_NONEКорректный JSON; допустимость зависит от операции
nullnullJSON_ERROR_NONEКорректный JSON, но не тот тип, который ждёт данный endpoint
false или 0false или 0JSON_ERROR_NONEКорректный JSON; проверка на «ложь» здесь ошибочна
<html>503</html>обычно nullJSON_ERROR_SYNTAXНеверный формат ответа; сохранить безопасный диагностический контекст

Пример для PHP 7.1

Функция ниже принимает уже полученное тело только после проверки HTTP-статуса. Она не пытается угадать, где возникли данные: транспорт и HTTP должны быть разобраны раньше. Здесь контракт намеренно маленький: мы ждём объект с непустым строковым order_id. В другом API это может быть список, поле accepted или код задачи — меняется проверка контракта, но не порядок диагностики.

<?php

function logPayloadProblem(array $record)
{
    error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
}

function rejectPayloadContract($reason, $requestId, $body)
{
    logPayloadProblem(array(
        'kind' => 'contract_error',
        'reason' => $reason,
        'request_id' => $requestId,
        'body_bytes' => strlen($body),
        'body_sha256' => hash('sha256', $body),
    ));

    throw new UnexpectedValueException($reason);
}

function decodeCreatedOrder($body, $requestId)
{
    if ($body === '') {
        rejectPayloadContract('Partner returned an empty body', $requestId, $body);
    }

    $data = json_decode($body, true);
    $jsonError = json_last_error();

    if ($jsonError !== JSON_ERROR_NONE) {
        logPayloadProblem(array(
            'kind' => 'json_decode_error',
            'request_id' => $requestId,
            'json_error' => $jsonError,
            'body_bytes' => strlen($body),
            'body_sha256' => hash('sha256', $body),
        ));

        throw new UnexpectedValueException('Partner response is not valid JSON');
    }

    if (!is_array($data)) {
        rejectPayloadContract(
            'Partner returned valid JSON, but not an object',
            $requestId,
            $body
        );
    }

    if (
        !array_key_exists('order_id', $data)
        || !is_string($data['order_id'])
        || $data['order_id'] === ''
    ) {
        rejectPayloadContract(
            'Partner JSON has no non-empty order_id',
            $requestId,
            $body
        );
    }

    return $data;
}

Значение json_last_error() читается сразу после json_decode(). Это состояние относится к последней операции JSON, поэтому его легко затереть следующим json_encode() или повторным разбором. В примере в журнал попадают код ошибки, размер тела и хеш. Хеш позволяет сравнить два ответа, не печатая сам ответ в общий журнал. Если отладка требует фрагмент тела, её лучше проводить в ограниченной тестовой среде с маскированием данных.

Не путать формат с договором

Предположим, партнёр ответил []. С точки зрения JSON всё в порядке. Для запроса «найди заказы за час» это может быть нормальный нулевой результат. Для запроса «создай заказ» пустой массив не годится, потому что договор ожидает идентификатор. Это уже не ошибка декодера и не повод говорить, что «API вернул битый JSON». Это нарушение контракта полезной нагрузки.

Такая формулировка помогает и при разговоре с партнёром. Вместо расплывчатого «не распарсили ответ» можно передать факт: HTTP-статус был 200, JSON синтаксически корректен, но поле order_id отсутствует или имеет другой тип. Это сообщение можно проверить на их стороне и закрепить в документации API.

Проверка на четырёх маленьких ответах

Тест не обязан ходить в сеть. Достаточно передать функции строки и сравнить исключение или результат. Важно держать рядом успешный пустой сценарий только для той операции, где пустота допустима: иначе тест сам начнёт размывать договор.

  1. Передать {"order_id":"A-17"} и проверить, что функция вернула массив с идентификатором.
  2. Передать <html>maintenance</html>; ожидается ветка json_decode_error с кодом JSON_ERROR_SYNTAX.
  3. Передать null; json_last_error() должен показать успех разбора, а функция должна отклонить неподходящий тип.
  4. Передать []; разбор успешен, но контракт создания заказа должен отклонить отсутствие order_id.
  5. Отдельно проверить поиск или список, где [] является валидным результатом, чтобы не переносить правила одной операции на другую.

Версия PHP и ограничения

В PHP 7.3 появился флаг JSON_THROW_ON_ERROR. В этом примере я намеренно остаюсь на PHP 7.1, поэтому проверка json_last_error() — нормальный механизм для выбранной версии, а не обходной путь. Если проект уже обновлён, исключения могут сделать код компактнее, но проверка формы ответа всё равно остаётся.

Декодер ожидает строку в UTF-8. Ошибка JSON_ERROR_UTF8 говорит о проблеме кодировки, но не объясняет, на каком именно участке она появилась. Для такого случая нужны метрики и безопасный способ сравнить исходные ответы, а не принудительное перекодирование всей строки без понимания источника. RFC 8259 рекомендует уникальные имена в объекте; при повторе разные реализации могут вести себя по-разному. Если поле критично, договор API должен фиксировать его единственность.

Что оставить после исправления

После этой доработки в клиенте остаются два разных события: json_decode_error для невалидного формата и contract_error для валидного, но неожиданного объекта. У них разные причины, разная срочность и разные адресаты. А условие if (!$data) исчезает: оно не способно сказать, что именно произошло.

Проверяемые источники

  • PHP manual: json_decode — что возвращает декодер, требование UTF-8 и изменение PHP 7.3
  • PHP manual: json_last_error — коды ошибок последней операции JSON
  • RFC 8259: JSON — JSON допускает не только объект и массив, но и null, false, true, число и строку