JSONPath: синтаксис, примеры и как искать путь к значению

Ответ API приходит на триста строк, и в нём нужно найти одно поле: цену, идентификатор, статус заказа. Листать глазами JSON, где половина строк это вложенные объекты и массивы, неудобно и ненадёжно: легко пропустить второе вхождение того же ключа или запутаться, на каком уровне лежит нужное поле. Для таких задач придумали JSONPath, язык, в котором маршрут к значению записывается одной строкой. Разберём, как он устроен, где его границы и как применить его к реальному ответу API.
Что такое JSONPath и зачем он нужен
JSONPath описывает не данные, а маршрут к ним. Путь начинается с корня документа, который обозначается символом $ (как «корень» в дереве), а дальше идут шаги: имя ключа, индекс элемента массива, иногда условие. Идея старая и очевидная: если в XML путь к узлу записывается как /order/items/item[3]/price, то и в JSON должен быть способ сказать «возьми третий элемент массива items и покажи его поле price».
- одинаково работает с объектами, массивами и вложенными структурами любой глубины;
- путь читается почти как фраза: $.orders[0].price это «в корне, массив orders, нулевой элемент, поле price»;
- ответ приходит списком, а не одним значением, потому что один и тот же ключ может встречаться в разных ветках документа;
- выражения понимают библиотеки на разных языках: Python, JavaScript, Java, Go, PHP;
- JSONPath отвечает на вопрос «где лежит значение», а не «что с ним делать»: это не язык программирования и не замена самому JSON.
Сразу обозначу границу, потому что вокруг JSONPath много путаницы. Это не единственный язык запросов к JSON и не один-единственный стандарт. Есть jq с собственной логикой фильтров, есть JMESPath с другим синтаксисом, есть SQL/JSON Path из стандарта SQL. Имена похожие, грамматика разная, поэтому в одном проекте их лучше не мешать.
Стоит сказать и про JQL, язык запросов к Jira, потому что его часто приводят как пример JSONPath. Выглядит он совсем иначе: project = PROJ AND status = Open, то есть ближе к SQL, где условие пишется словами, а не символами. У JMESPath синтаксис ближе к JSONPath, но фильтры записываются иначе, с обратными кавычками вокруг значения: items[?price > `10`]. Все три языка решают одну задачу, но выражение из одного в другой без проверки переносить нельзя.
Базовый синтаксис: корень, точка и квадратные скобки
Полезных конструкций в базовой части немного, и их достаточно для большинства задач. Полный список выглядит так.
- $ — корень документа, точка отсчёта для любого пути;
- .key — обращение к полю объекта: $.user.name;
- ["key with spaces"] — то же самое для ключей, которые нельзя записать точкой: $["order id"];
- [0] — элемент массива по индексу, нумерация с нуля: $.orders[0].price это первый заказ;
- [*] — все элементы массива: $.orders[*].price;
- .* — все значения объекта на текущем уровне: $.user.*;
- ..key — рекурсивный спуск, подробнее о нём ниже.
Две формы записи одного и того же ключа равнозначны: $.user.name и $['user']['name'] указывают на одно поле. Разница только в читаемости и в том, какие символы приходится экранировать. Если ключ содержит пробел, дефис или точку, точка уже не работает, и остаётся только форма в квадратных скобках.
Смешение двух синтаксисов объясняет часть ошибок. В JavaScript к полю объекта обращаются через точку, а к элементу массива через индекс в квадратных скобках, и никакого префикса не нужно: obj.user и arr[0]. JSONPath добавляет к этому префикс $ в начале и превращает обращение к ключу в отдельную операцию. Отсюда простое правило: путь, который начинается с $, это JSONPath, а выражение без такого префикса это обычный код, который нужно писать на языке программирования.
Рекурсивный спуск и поиск по значению
Двойная точка экономит больше всего времени. Запись $.name означает «поле name на верхнем уровне», а $..name означает «поле name на любой глубине, где оно встречается». Именно рекурсивный спуск превращает JSONPath из точной адресации в удобный инструмент поиска по незнакомой структуре.
{
"meta": { "page": 1, "total": 137 },
"data": [
{ "id": 1024, "price": 7490.5, "seller": { "id": 55, "name": "ИП Романов" } },
{ "id": 1025, "price": 3290.0, "seller": { "id": 55, "name": "ИП Романов" } }
]
}
$.data[0].price → 7490.5 точный путь, ветка известна
$.data[*].price → 7490.5, 3290.0 все элементы массива
$..price → 7490.5, 3290.0 то же самое рекурсивно
$..id → 1024, 1025, 55, 55 все id, включая вложенные
(порядок обхода зависит от реализации)
$..name → "ИП Романов", "ИП Романов"
$.meta.total → 137
Что JSONPath не умеет: найти элемент по значению поля.
Вопрос "где price равно 3290" решается фильтром, а не базовым путём.Разница между поиском по имени и поиском по значению важна на практике. Выражение $..price находит все поля с именем price, но не отвечает на вопрос, у какого из товаров цена равна 3290. Путь по имени не смотрит на содержимое значений вообще.
Ещё один нюанс рекурсивного спуска: он обходит весь документ целиком. На маленьком ответе это незаметно, а на файле в десятки мегабайт один такой запрос способен занять ощутимое время. Если структура ответа известна, точное выражение отработает быстрее: $.data[0].price обходит одну ветку, а $..price обходит всё дерево целиком.
Фильтры и срезы: что они из себя представляют
В большинстве примеров в интернете встречаются конструкции $.items[?(@.price < 10)] (выбрать элементы массива items, у которых поле price меньше 10) и $.items[0:3] (взять первые три элемента). Эти конструкции придуманы не в оригинальном JSONPath, а в расширениях, которые разошлись по библиотекам: каждая реализация трактует их по-своему, и строго говоря, стандартизирована только базовая часть языка.
- базовый JSONPath гарантированно умеет $ , . , [] и рекурсивный спуск ..;
- фильтры ?() поддерживаются по-разному: где-то есть операторы сравнения и логические связки, где-то фильтр работает только с простым равенством;
- срезы [start:end] есть не во всех библиотеках;
- результат фильтра всегда список, даже если условию подошёл ровно один элемент;
- проверить конкретное выражение можно только в той библиотеке, в которой оно будет выполняться.
Теперь про поиск пути на Toolbox Pro, чтобы не было недоразумений. Он ищет по документу ключ или значение по вхождению подстроки и показывает путь к каждому найденному месту: $.data[0].price, $.user.address.city. Это удобно на первом шаге, когда структура ответа ещё незнакома. Но выражения с фильтрами и срезами он не вычисляет: запрос $.items[?(@.price < 10)] вернёт ноль совпадений, потому что поиск идёт по вхождению подстроки в ключи и значения, а не по вычислению выражений. Для вычисляемых запросов нужна библиотека конкретного языка.
Если фильтр нужен прямо сейчас, а библиотеки под рукой нет, его несложно написать самому, обойдя документ рекурсивно. Такой обходчик заодно объясняет, как JSONPath устроен внутри.
function findByPrice(node, limit) {
const out = [];
function walk(value) {
if (Array.isArray(value)) {
value.forEach(walk);
} else if (value && typeof value === "object") {
if (typeof value.price === "number" && value.price < limit) {
out.push(value);
}
Object.values(value).forEach(walk);
}
}
walk(node);
return out;
}
findByPrice(body, 5000);
// → [{ "id": 1025, "title": "Рюкзак", "price": 3290.0, ... }]Перед вычислением фильтра стоит убедиться, что в документе нет массивов внутри массивов: простые реализации обходят только один уровень и молча пропускают вложенные элементы. И помните, что результат фильтра всегда список, даже если подошёл один элемент.
Для проверки и просмотра самого документа помогает форматтер JSON: он показывает ошибки в синтаксисе, подсвечивает структуру и приводит ответ к читаемому виду. А если по структуре ответа нужно понять форму данных для типов, пригодится генератор TypeScript-типов.
Разбор на реальном ответе API по шагам
Возьмём типичный ответ каталога и пройдём путь от «не знаю, где цена» до готового пути, который можно скопировать и подставить в код.
- вставить ответ в поле ввода и нажать «Format», чтобы JSON развернулся в читаемый вид;
- ввести в строку поиска имя ключа, например price;
- переключатели «искать в ключах» и «искать в значениях» определяют, где идёт сравнение с подстрокой;
- получить список путей вида $.data[0].price и $.data[1].price вместе со значениями и типами;
- скопировать нужный путь кнопкой рядом с ним;
- использовать путь в Postman, в автотесте или в коде.
Ответ:
{
"meta": { "page": 1, "per_page": 20, "total": 137 },
"data": [
{ "id": 1024, "title": "Кроссовки", "price": 7490.5, "stock": 12,
"order-id": "A-77", "seller": { "id": 55, "name": "ИП Романов" } },
{ "id": 1025, "title": "Рюкзак", "price": 3290.0, "stock": 0,
"order-id": "A-78", "seller": { "id": 55, "name": "ИП Романов" } }
]
}
Запрос "price":
$.data[0].price number 7490.5
$.data[1].price number 3290
Запрос "total":
$.meta.total number 137
Запрос "Романов" (поиск по значению):
$.data[0].seller.name string ИП Романов
$.data[1].seller.name string ИП Романов
Запрос "stock":
$.data[0].stock number 12
$.data[1].stock number 0
Ключ с дефисом в пути пишется в квадратных скобках:
$["data"][0]["order-id"] string A-77Обратите внимание на последнюю строку примера. Ключ order-id содержит дефис, поэтому точечная запись $.data[0].order-id не сработает: дефис будет воспринят как минус. Правильный путь выглядит как $["data"][0]["order-id"]. Именно такие мелочи чаще всего ломают тест в CI: путь выглядит правильным, а поле не находится.
Если по одному запросу найдено несколько путей, это не ошибка, а признак того, что ключ встречается в разных ветках документа. Разбираться приходится по контексту: два пути с одинаковым окончанием могут вести к разным сущностям. Например, $.data[0].seller.id отвечает за продавца, а $.meta.owner.id за владельца каталога, и путать их опасно. В таких случаях выручает рекурсивный спуск $..id, который собирает все совпадения сразу, а уже по значениям вы отличите одно от другого.
Типичные ошибки в синтаксисе
- разные типы кавычек: внутри квадратных скобок ключ берётся в кавычки, и тип кавычек должен совпадать с типом внешней строки, иначе получается два набора кавычек подряд;
- точка вместо скобок для ключа с пробелом или дефисом: $.order id и $.order-id не работают, нужно $['order id'];
- индекс путают с ключом: $.orders[0] это первый элемент массива, $.orders["0"] то же самое, а $.orders.id не существует вовсе;
- забывают, что индексация с нуля: $.orders[1] это второй заказ, а не первый;
- смешивают JSONPath с jq: в jq фильтр пишут как .data[] | .price, а не $.data[*].price, и это два разных языка;
- ожидают от $..price одно значение, а получают список, и в коде значение приходит массивом, который надо ещё разобрать;
- пытаются записать условие в базовом синтаксисе: $.price > 1000 не найдёт ничего, потому что операций сравнения в базовой части нет.
Ещё одна ошибка, которая встречается не в синтаксисе, а в логике: путь к одному элементу массива используют там, где нужен весь массив. $.data[0].price вернёт цену только первого товара, а $.data[*].price вернёт цены всех. Для проверки «у всех товаров есть цена» нужен именно второй вариант, а для проверки «первый товар дороже тысячи» первый.
Как применить путь в Postman и в автотестах
В Postman тело ответа превращается в JavaScript-объект, поэтому в скрипте пишут прямой доступ по полям, а не JSONPath. Найденный путь отлично работает как шпаргалка: из $.data[0].price получается body.data[0].price.
const body = pm.response.json();
pm.test("первый товар дороже 1000", function () {
pm.expect(body.data[0].price).to.be.above(1000);
});
pm.test("все id числовые", function () {
const ids = body.data.map(function (item) { return item.id; });
pm.expect(ids).to.eql([1024, 1025]);
});
pm.test("есть хотя бы один товар в наличии", function () {
const inStock = body.data.filter(function (i) { return i.stock > 0; });
pm.expect(inStock.length).to.be.above(0);
});Зато в обычных автотестах JSONPath работает нативно, потому что там вы вызываете библиотеку сами.
# Python, библиотека jsonpath-ng
from jsonpath_ng.ext import parse
expr = parse("$.data[*].price")
prices = [float(m.value) for m in expr.find(body)]
assert prices == [7490.5, 3290.0]
# Java, RestAssured
response.jsonPath().getList("data.price", Float.class)
# → [7490.5, 3290.0]
# JavaScript, библиотека jsonpath-plus
const prices = JSONPath({ path: "$.data[*].price", json: body });
# → [7490.5, 3290.0]
# jq в командной строке, синтаксис свой
jq '.data[].price' response.jsonПеред тем как закрепить путь в коде, имеет смысл сравнить ответы двух версий API. Для этого подойдёт сравнение JSON, которое показывает расхождения между двумя документами. А проверка соответствия ответа заявленной схеме лежит в валидаторе JSON Schema.
Тот же путь пригодится и за пределами Postman. В консоли браузера на странице приложения данные ответа часто лежат в какой-нибудь переменной, и найденный путь подскажет, куда смотреть. В командной строке то же самое делает jq, но своим синтаксисом: jq .data[].price для массива и jq -r .meta.total для одного значения. Флаг -r убирает кавычки вокруг строк, без него текст выводится как фрагмент JSON, а не как готовое значение.
Частые вопросы
Чем JSONPath отличается от XPath?
XPath работает с XML и использует разделители и оси: /store/book[1]/title, bookstore/book/@isbn. JSONPath устроен проще, потому что структура JSON заранее известна: у объектов есть имена полей, у массивов есть индексы. Отсюда другая нотация, другая нумерация и другое поведение при отсутствии элемента: XPath вернёт пустой результат, JSONPath в разных реализациях либо пустой список, либо ошибку.
Что означают $ и ..?
Символ $ это корень документа, точка отсчёта любого пути, аналог корня дерева. Двойная точка означает рекурсивный спуск: переход на любую глубину вниз. Так, $.name ищет поле name только на верхнем уровне, а $..name находит все поля name в документе, включая те, что лежат внутри массивов и вложенных объектов.
Как выбрать все элементы массива?
Звездочка в квадратных скобках: $.data[*].price. Она означает «каждый элемент массива data», и к каждому применяется дальнейший шаг пути. Если нужен весь массив целиком, без выбора поля, пишут $.data[*]. Отдельный нюанс: в некоторых реализациях $.data[*] возвращает список списков, и лишние скобки приходится снимать в коде.
Поддерживают ли все библиотеки фильтры?
Нет, и это главная причина, по которой фильтры стоит проверять в той среде, где они будут выполняться. Базовая часть языка с $, точкой, скобками и рекурсивным спуском поддерживается везде, а вот фильтры ?(), срезы и операции над значениями появились в расширениях, и их набор отличается от библиотеки к библиотеке. Код, который работает на одной машине, спокойно падает на другой.
Можно ли найти путь к значению, не зная структуру JSON?
Да, и это самый частый сценарий. Поиск по вхождению подстроки в имена ключей и значения выдаёт все подходящие места с готовыми путями, а дальше остаётся выбрать нужный. Так находят идентификатор, цену или имя поставщика в ответе, структуру которого вы видите впервые. Важно только помнить, что выдача всегда список: если ключ встречается в трёх ветках, путей будет три, и выбирать придётся вручную.
Что делать, если путь ничего не находит?
Первым делом проверьте, что документ вообще разбирается: непарная скобка или лишняя запятая обрывают JSON целиком, и искать в нём бессмысленно. Потом попробуйте рекурсивный спуск $..имя_ключа, он находит поле на любой глубине и не зависит от того, на каком уровне вы ожидали его увидеть. И сверьте написание: поиск по подстроке регистр не учитывает, а вот пути в части библиотек к нему чувствительны, поэтому написание ключа в документе и в выражении должно совпадать.