Aller au contenu
jsonbeautifiers
Français

JSONPath, la version qui est vraiment spécifiée

JSONPath fut un billet de blog pendant dix-sept ans. La RFC 9535 dit enfin ce qu’il signifie.

Chaque affirmation de cette page est soit mesurée, soit sourcée. Quand elle n’est ni l’une ni l’autre, la page le dit.

JSONPath a commencé en 2007 comme un billet de blog de Stefan Goessner, qui esquissait un langage de requête à la XPath pour JSON en deux écrans de prose environ et une implémentation de référence en JavaScript. C’était assez bon pour que tout le monde l’implémente, et assez flou pour que tout le monde l’implémente différemment. L’opérateur de descendance .. prend-il en compte le nœud racine lui-même, ou seulement ses enfants ? [-1] est-il le dernier élément ou une erreur ? Un crochet peut-il contenir plusieurs sélecteurs ? Que fait un filtre quand la clé qu’il teste n’existe pas ? Que sélectionne une tranche de pas zéro ? Chacune de ces questions avait au moins deux réponses dans la nature, et le document d’origine n’en tranchait aucune, parce que des morceaux entiers étaient délégués à l’eval() qui traînait par là.

La RFC 9535 a réglé cela en février 2024. C’est un Proposed Standard de l’IETF, le premier niveau de maturité de la voie des normes : une spécification réelle, stable et rédigée de façon normative, et ce n’est pas un Internet Standard. Traitez-la comme n’importe quel Proposed Standard : ce contre quoi écrire du code neuf, en sachant que beaucoup de code déployé lui est antérieur.

Le document

Tout ce qui suit s’exécute sur ceci :

{
  "store": {
    "name": "Corner Books",
    "book": [
      { "category": "reference", "author": "Nigel Rees",
        "title": "Sayings of the Century", "price": 8.95 },
      { "category": "fiction", "author": "Evelyn Waugh",
        "title": "Sword of Honour", "price": 12.99 },
      { "category": "fiction", "author": "Herman Melville",
        "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 },
      { "category": "fiction", "author": "J.R.R. Tolkien",
        "title": "The Lord of the Rings", "isbn": "0-395-19395-8" }
    ]
  }
}

Remarquez le dernier livre : pas de price. C’est dans cette absence que vit l’essentiel du comportement intéressant.

Segments et sélecteurs

Une requête est $ suivi d’une suite de segments. $ est la racine. Chaque segment applique un ou plusieurs sélecteurs à chaque nœud en main et produit une nouvelle liste de nœuds. Le résultat d’une requête est toujours une liste de nœuds, même quand elle en contient un seul ou aucun, ce qui explique qu’une bibliothèque JSONPath renvoie un tableau là où une bibliothèque JSON Pointer renvoie une valeur.

$.store.book[0].title        notation pointée, raccourci des sélecteurs de nom
$['store']['book'][0]        notation à crochets, sens identique
$["store"]["book"][0]        les guillemets doubles marchent aussi

La notation à crochets n’est pas une décoration facultative. $.first-name n’est pas un sélecteur de nom valide : une clé avec un tiret, une espace, un point ou un chiffre initial doit s’écrire $['first-name']. La forme à crochets avec apostrophes est aussi celle d’un chemin normalisé, l’identifiant unique que la RFC 9535 définit pour un seul nœud : $['store']['book'][0]['title'].

Les sélecteurs :

  • Nom : 'title' ou "title", qui sélectionne un membre d’objet. Rien sur un tableau.
  • Joker * : chaque valeur membre d’un objet, chaque élément d’un tableau. $.store.book[*] et $.store.book.* sont la même requête.
  • Index : un entier, base zéro. Les négatifs comptent depuis la fin, donc [-1] est le dernier élément. C’est désormais spécifié, pas une gentillesse propre à chaque bibliothèque.
  • Tranche début:fin:pas : semi-ouverte, fin exclue, et un pas négatif remonte. Un pas de 0 ne sélectionne rien plutôt que de lever une erreur, seul endroit où la RFC s’écarte délibérément de Python.
  • Filtre ?expr : traité plus bas.

Un segment enfant peut contenir plusieurs sélecteurs séparés par des virgules, et ils n’ont pas à être de même nature. $.store.book[0, -1] donne le premier et le dernier livre ; $.store.book[0, 2:4] mêle un index et une tranche. Les résultats reviennent dans l’ordre des sélecteurs, donc une union peut légitimement renvoyer deux fois le même nœud.

Un segment de descendance s’écrit avec deux points : $..author, $..['author'], $..*, $..[0]. Il visite le nœud d’entrée et tous ses descendants, puis applique ses sélecteurs à chacun. L’ancienne ambiguïté a disparu : $..store sur le document ci-dessus correspond bien à $.store, parce que le segment de descendance commence à la racine elle-même.

Les filtres, là où sont les questions

Dans un filtre, @ est le nœud courant testé et $ reste la racine du document entier, de sorte qu’un filtre peut comparer une valeur à quelque chose situé ailleurs dans le document.

Une requête nue employée comme expression de filtre est un test d’existence : elle est vraie quand la requête sélectionne au moins un nœud. $.store.book[?@.isbn] sélectionne les deux livres qui ont un ISBN. Les opérateurs de comparaison sont ==, !=, <, <=, >, >= ; les opérateurs logiques sont &&, || et le préfixe !, avec des parenthèses pour grouper. Les parenthèses autour de l’expression entière sont autorisées mais ne sont plus obligatoires : [?(@.price < 10)] et [?@.price < 10] sont tous deux valides et veulent dire la même chose.

Les opérandes de comparaison sont restreints. Chaque côté doit être un littéral, une requête singulière (composée uniquement de sélecteurs de nom et d’index, donc capable de sélectionner au plus un nœud) ou un appel de fonction. @.price convient. @..price et @.book[*].price non, et une implémentation devrait rejeter la requête plutôt que deviner.

Vient maintenant la règle qui fait trébucher. Une requête qui ne sélectionne rien produit la valeur spéciale Nothing, et Nothing n’est ni null, ni zéro, ni false. Il est égal à Nothing et à rien d’autre, et toute comparaison d’ordre l’impliquant est fausse. Conséquences :

$.store.book[?@.price < 10]     exclut le livre de Tolkien (pas de price)
$.store.book[?@.price >= 10]    l’exclut aussi
$.store.book[?@.price == null]  l’exclut aussi : Nothing n’est pas null
$.store.book[?!@.price]         le sélectionne, et lui seul

L’absence se teste donc en niant le test d’existence, et == null teste un membre présent qui contient null. L’image miroir est une vraie surprise : $.store.book[?@.price == @.discount] sélectionne le livre de Tolkien, parce que les deux côtés valent Nothing et que Nothing égale Nothing.

Une comparaison entre deux types différents n’est jamais une erreur. L’égalité entre types est simplement fausse, et l’ordre n’est défini qu’entre deux nombres ou deux chaînes : @.price > "10" est donc faux pour tous les livres.

Extensions de fonctions

Cinq sont définies, et elles sont typées, donc length(@.book[*]) est une erreur de type plutôt qu’une surprise à l’exécution.

Fonction Prend Rend
length() une valeur valeurs scalaires Unicode dans une chaîne, éléments dans un tableau, membres dans un objet, sinon Nothing
count() une liste de nœuds combien de nœuds elle a sélectionnés
match() une chaîne et une regex vrai si la regex correspond à la chaîne entière
search() une chaîne et une regex vrai si la regex correspond quelque part dans la chaîne
value() une liste de nœuds la valeur, si la liste contient exactement un nœud, sinon Nothing

count() existe parce qu’une requête non singulière ne peut pas être opérande de comparaison : count(@.book[?@.isbn]) == 2 est donc la façon de dire « a exactement deux livres avec un ISBN ». value() résout le même problème dans l’autre sens : $[?value(@..name) == 'Corner Books'] fonctionne parce que value() réduit une requête multi-nœuds à une valeur comparable unique, ou à Nothing si elle a trouvé plus ou moins d’un nœud.

Le dialecte d’expressions régulières est I-Regexp (RFC 9485), un sous-ensemble volontairement restreint qui se projette sur les expressions régulières XSD. Ce n’est pas PCRE. N’attendez ni assertions avant ni références arrière, et rappelez-vous que match(@.category, 'fic') est faux pour "fiction" alors que search(@.category, 'fic') est vrai.

Ce qui n’est pas dans le langage

Trois choses auxquelles on pense encore n’existent pas. Les expressions de script, la forme [(...)] du billet d’origine, ont disparu, et avec elles la dépendance à eval(). Il n’y a pas d’opérateur de parent : une requête ne descend que, donc si vous avez besoin de l’objet englobant, sélectionnez l’objet et filtrez sur l’enfant. Et la pseudo-propriété length a disparu : $.store.book[(@.length-1)] n’est pas une requête. Écrivez $.store.book[-1].

Exemples résolus

Expression Résultat
$.store.name "Corner Books"
$.store.book[*].author les quatre auteurs
$..isbn les deux chaînes ISBN
$.store.book[-1].title "The Lord of the Rings"
$.store.book[1:3].title "Sword of Honour", "Moby Dick"
$.store.book[::2].title "Sayings of the Century", "Moby Dick"
$.store.book[0,-1].title "Sayings of the Century", "The Lord of the Rings"
$.store.book[?@.price < 10].title "Sayings of the Century", "Moby Dick"
$.store.book[?!@.price].title "The Lord of the Rings"
$.store.book[?@.price > $.store.book[0].price].title "Sword of Honour", "Moby Dick"
$.store.book[?match(@.category, 'fic.*')].author Waugh, Melville, Tolkien
$.store.book[?search(@.author, 'Mel')].title "Moby Dick"
$.store.book[?length(@.title) > 16].title "Sayings of the Century", "The Lord of the Rings"

Collez le document et l’une de ces expressions dans le testeur JSONPath pour voir la liste de nœuds à côté du chemin normalisé de chaque correspondance, ce qui est le moyen le plus rapide de vérifier si votre bibliothèque s’accorde avec la RFC sur les index négatifs et les clés absentes.

Quand prendre autre chose

JSONPath sélectionne des nœuds. C’est tout son travail, et trois autres outils de requête le recoupent :

JSON Pointer (RFC 6901) adresse exactement un nœud, sans jokers, sans filtres et sans ambiguïté : /store/book/0/title, avec ~1 pour une barre oblique littérale et ~0 pour un tilde littéral. C’est ce avec quoi pointent les erreurs de JSON Schema et les opérations de JSON Patch. Si vous connaissez l’adresse, prenez un Pointer.

jq est un langage complet de traitement de flux avec son propre modèle de valeurs, son arithmétique, ses variables et son formatage de sortie. Il transforme ; JSONPath ne fait que sélectionner. Si votre expression commence à construire de nouveaux objets, c’est jq qu’il vous faut.

JMESPath se situe entre les deux : un langage de requête spécifié, plus ancien que la RFC 9535, avec des projections et sa propre bibliothèque de fonctions, et une syntaxe assez proche de JSONPath pour prêter à confusion et assez différente pour casser. Choisissez-en un par base de code.

Pour le cas quotidien, réduire un payload aux champs qui vous intéressent, l’outil de filtre le fait sans aucun langage d’expression, et le visualiseur vous montre la forme que vous interrogez. Si ce que vous voulez, c’est savoir ce qui a changé entre deux payloads, c’est diff, pas une requête.