├── CHANGELOG.md ├── Exception ├── ExceptionInterface.php ├── InvalidArgumentException.php ├── InvalidJsonPathException.php ├── InvalidJsonStringInputException.php └── JsonCrawlerException.php ├── JsonCrawler.php ├── JsonCrawlerInterface.php ├── JsonPath.php ├── JsonPathUtils.php ├── LICENSE ├── README.md ├── Test ├── JsonPathAssertionsTrait.php ├── JsonPathContains.php ├── JsonPathCount.php ├── JsonPathEquals.php ├── JsonPathNotContains.php ├── JsonPathNotEquals.php ├── JsonPathNotSame.php └── JsonPathSame.php ├── Tokenizer ├── JsonPathToken.php ├── JsonPathTokenizer.php └── TokenType.php └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.3 5 | --- 6 | 7 | * Add the component as experimental 8 | -------------------------------------------------------------------------------- /Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Exception; 13 | 14 | /** 15 | * @author Alexandre Daubois 16 | * 17 | * @experimental 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Exception; 13 | 14 | /** 15 | * @author Alexandre Daubois 16 | * 17 | * @experimental 18 | */ 19 | class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/InvalidJsonPathException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Exception; 13 | 14 | /** 15 | * @author Alexandre Daubois 16 | * 17 | * @experimental 18 | */ 19 | class InvalidJsonPathException extends \LogicException implements ExceptionInterface 20 | { 21 | public function __construct(string $message, ?int $position = null) 22 | { 23 | parent::__construct(\sprintf('JSONPath syntax error%s: %s', $position ? ' at position '.$position : '', $message)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Exception/InvalidJsonStringInputException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Exception; 13 | 14 | /** 15 | * Thrown when a string passed as an input is not a valid JSON string, e.g. in {@see JsonCrawler}. 16 | * 17 | * @author Alexandre Daubois 18 | * 19 | * @experimental 20 | */ 21 | class InvalidJsonStringInputException extends InvalidArgumentException 22 | { 23 | public function __construct(string $message, ?\Throwable $previous = null) 24 | { 25 | parent::__construct(\sprintf('Invalid JSON input: %s.', $message), previous: $previous); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Exception/JsonCrawlerException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Exception; 13 | 14 | /** 15 | * @author Alexandre Daubois 16 | * 17 | * @experimental 18 | */ 19 | class JsonCrawlerException extends \RuntimeException implements ExceptionInterface 20 | { 21 | public function __construct(string $path, string $message, ?\Throwable $previous = null) 22 | { 23 | parent::__construct(\sprintf('Error while crawling JSON with JSON path "%s": %s.', $path, $message), previous: $previous); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /JsonCrawler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath; 13 | 14 | use Symfony\Component\JsonPath\Exception\InvalidArgumentException; 15 | use Symfony\Component\JsonPath\Exception\InvalidJsonStringInputException; 16 | use Symfony\Component\JsonPath\Exception\JsonCrawlerException; 17 | use Symfony\Component\JsonPath\Tokenizer\JsonPathToken; 18 | use Symfony\Component\JsonPath\Tokenizer\JsonPathTokenizer; 19 | use Symfony\Component\JsonPath\Tokenizer\TokenType; 20 | use Symfony\Component\JsonStreamer\Read\Splitter; 21 | 22 | /** 23 | * Crawls a JSON document using a JSON Path as described in the RFC 9535. 24 | * 25 | * @see https://datatracker.ietf.org/doc/html/rfc9535 26 | * 27 | * @author Alexandre Daubois 28 | * 29 | * @experimental 30 | */ 31 | final class JsonCrawler implements JsonCrawlerInterface 32 | { 33 | private const RFC9535_FUNCTIONS = [ 34 | 'length' => true, 35 | 'count' => true, 36 | 'match' => true, 37 | 'search' => true, 38 | 'value' => true, 39 | ]; 40 | 41 | /** 42 | * @param resource|string $raw 43 | */ 44 | public function __construct( 45 | private readonly mixed $raw, 46 | ) { 47 | if (!\is_string($raw) && !\is_resource($raw)) { 48 | throw new InvalidArgumentException(\sprintf('Expected string or resource, got "%s".', get_debug_type($raw))); 49 | } 50 | } 51 | 52 | public function find(string|JsonPath $query): array 53 | { 54 | return $this->evaluate(\is_string($query) ? new JsonPath($query) : $query); 55 | } 56 | 57 | private function evaluate(JsonPath $query): array 58 | { 59 | try { 60 | $tokens = JsonPathTokenizer::tokenize($query); 61 | $json = $this->raw; 62 | 63 | if (\is_resource($this->raw)) { 64 | if (!class_exists(Splitter::class)) { 65 | throw new \LogicException('The JsonStreamer package is required to evaluate a path against a resource. Try running "composer require symfony/json-streamer".'); 66 | } 67 | 68 | $simplified = JsonPathUtils::findSmallestDeserializableStringAndPath( 69 | $tokens, 70 | $this->raw, 71 | ); 72 | 73 | $tokens = $simplified['tokens']; 74 | $json = $simplified['json']; 75 | } 76 | 77 | try { 78 | $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); 79 | } catch (\JsonException $e) { 80 | throw new InvalidJsonStringInputException($e->getMessage(), $e); 81 | } 82 | 83 | $current = [$data]; 84 | 85 | foreach ($tokens as $token) { 86 | $next = []; 87 | foreach ($current as $value) { 88 | $result = $this->evaluateToken($token, $value); 89 | $next = array_merge($next, $result); 90 | } 91 | 92 | $current = $next; 93 | } 94 | 95 | return $current; 96 | } catch (InvalidArgumentException $e) { 97 | throw $e; 98 | } catch (\Throwable $e) { 99 | throw new JsonCrawlerException($query, $e->getMessage(), previous: $e); 100 | } 101 | } 102 | 103 | private function evaluateToken(JsonPathToken $token, mixed $value): array 104 | { 105 | return match ($token->type) { 106 | TokenType::Name => $this->evaluateName($token->value, $value), 107 | TokenType::Bracket => $this->evaluateBracket($token->value, $value), 108 | TokenType::Recursive => $this->evaluateRecursive($value), 109 | }; 110 | } 111 | 112 | private function evaluateName(string $name, mixed $value): array 113 | { 114 | if (!\is_array($value)) { 115 | return []; 116 | } 117 | 118 | if ('*' === $name) { 119 | return array_values($value); 120 | } 121 | 122 | return \array_key_exists($name, $value) ? [$value[$name]] : []; 123 | } 124 | 125 | private function evaluateBracket(string $expr, mixed $value): array 126 | { 127 | if (!\is_array($value)) { 128 | return []; 129 | } 130 | 131 | if ('*' === $expr) { 132 | return array_values($value); 133 | } 134 | 135 | // single negative index 136 | if (preg_match('/^-\d+$/', $expr)) { 137 | if (!array_is_list($value)) { 138 | return []; 139 | } 140 | 141 | $index = \count($value) + (int) $expr; 142 | 143 | return isset($value[$index]) ? [$value[$index]] : []; 144 | } 145 | 146 | // start and end index 147 | if (preg_match('/^-?\d+(?:\s*,\s*-?\d+)*$/', $expr)) { 148 | if (!array_is_list($value)) { 149 | return []; 150 | } 151 | 152 | $result = []; 153 | foreach (explode(',', $expr) as $index) { 154 | $index = (int) trim($index); 155 | if ($index < 0) { 156 | $index = \count($value) + $index; 157 | } 158 | if (isset($value[$index])) { 159 | $result[] = $value[$index]; 160 | } 161 | } 162 | 163 | return $result; 164 | } 165 | 166 | // start, end and step 167 | if (preg_match('/^(-?\d*):(-?\d*)(?::(-?\d+))?$/', $expr, $matches)) { 168 | if (!array_is_list($value)) { 169 | return []; 170 | } 171 | 172 | $length = \count($value); 173 | $start = '' !== $matches[1] ? (int) $matches[1] : null; 174 | $end = '' !== $matches[2] ? (int) $matches[2] : null; 175 | $step = isset($matches[3]) && '' !== $matches[3] ? (int) $matches[3] : 1; 176 | 177 | if (0 === $step || $start > $length) { 178 | return []; 179 | } 180 | 181 | if (null === $start) { 182 | $start = $step > 0 ? 0 : $length - 1; 183 | } else { 184 | if ($start < 0) { 185 | $start = $length + $start; 186 | } 187 | $start = max(0, min($start, $length - 1)); 188 | } 189 | 190 | if (null === $end) { 191 | $end = $step > 0 ? $length : -1; 192 | } else { 193 | if ($end < 0) { 194 | $end = $length + $end; 195 | } 196 | if ($step > 0) { 197 | $end = max(0, min($end, $length)); 198 | } else { 199 | $end = max(-1, min($end, $length - 1)); 200 | } 201 | } 202 | 203 | $result = []; 204 | for ($i = $start; $step > 0 ? $i < $end : $i > $end; $i += $step) { 205 | if (isset($value[$i])) { 206 | $result[] = $value[$i]; 207 | } 208 | } 209 | 210 | return $result; 211 | } 212 | 213 | // filter expressions 214 | if (preg_match('/^\?(.*)$/', $expr, $matches)) { 215 | $filterExpr = $matches[1]; 216 | 217 | if (preg_match('/^(\w+)\s*\([^()]*\)\s*([<>=!]+.*)?$/', $filterExpr)) { 218 | $filterExpr = "($filterExpr)"; 219 | } 220 | 221 | if (!str_starts_with($filterExpr, '(')) { 222 | throw new JsonCrawlerException($expr, 'Invalid filter expression'); 223 | } 224 | 225 | // remove outer filter parentheses 226 | $innerExpr = substr(substr($filterExpr, 1), 0, -1); 227 | 228 | return $this->evaluateFilter($innerExpr, $value); 229 | } 230 | 231 | // quoted strings for object keys 232 | if (preg_match('/^([\'"])(.*)\1$/', $expr, $matches)) { 233 | $key = JsonPathUtils::unescapeString($matches[2], $matches[1]); 234 | 235 | return \array_key_exists($key, $value) ? [$value[$key]] : []; 236 | } 237 | 238 | throw new \LogicException(\sprintf('Unsupported bracket expression "%s".', $expr)); 239 | } 240 | 241 | private function evaluateFilter(string $expr, mixed $value): array 242 | { 243 | if (!\is_array($value)) { 244 | return []; 245 | } 246 | 247 | $result = []; 248 | foreach ($value as $item) { 249 | if (!\is_array($item)) { 250 | continue; 251 | } 252 | 253 | if ($this->evaluateFilterExpression($expr, $item)) { 254 | $result[] = $item; 255 | } 256 | } 257 | 258 | return $result; 259 | } 260 | 261 | private function evaluateFilterExpression(string $expr, array $context): bool 262 | { 263 | $expr = trim($expr); 264 | 265 | if (str_contains($expr, '&&')) { 266 | $parts = array_map('trim', explode('&&', $expr)); 267 | foreach ($parts as $part) { 268 | if (!$this->evaluateFilterExpression($part, $context)) { 269 | return false; 270 | } 271 | } 272 | 273 | return true; 274 | } 275 | 276 | if (str_contains($expr, '||')) { 277 | $parts = array_map('trim', explode('||', $expr)); 278 | $result = false; 279 | foreach ($parts as $part) { 280 | $result = $result || $this->evaluateFilterExpression($part, $context); 281 | } 282 | 283 | return $result; 284 | } 285 | 286 | $operators = ['!=', '==', '>=', '<=', '>', '<']; 287 | foreach ($operators as $op) { 288 | if (str_contains($expr, $op)) { 289 | [$left, $right] = array_map('trim', explode($op, $expr, 2)); 290 | $leftValue = $this->evaluateScalar($left, $context); 291 | $rightValue = $this->evaluateScalar($right, $context); 292 | 293 | return $this->compare($leftValue, $rightValue, $op); 294 | } 295 | } 296 | 297 | if (str_starts_with($expr, '@.')) { 298 | $path = substr($expr, 2); 299 | 300 | return \array_key_exists($path, $context); 301 | } 302 | 303 | // function calls 304 | if (preg_match('/^(\w+)\((.*)\)$/', $expr, $matches)) { 305 | $functionName = $matches[1]; 306 | if (!isset(self::RFC9535_FUNCTIONS[$functionName])) { 307 | throw new JsonCrawlerException($expr, \sprintf('invalid function "%s"', $functionName)); 308 | } 309 | 310 | $functionResult = $this->evaluateFunction($functionName, $matches[2], $context); 311 | 312 | return is_numeric($functionResult) ? $functionResult > 0 : (bool) $functionResult; 313 | } 314 | 315 | return false; 316 | } 317 | 318 | private function evaluateScalar(string $expr, array $context): mixed 319 | { 320 | if (is_numeric($expr)) { 321 | return str_contains($expr, '.') ? (float) $expr : (int) $expr; 322 | } 323 | 324 | if ('true' === $expr) { 325 | return true; 326 | } 327 | 328 | if ('false' === $expr) { 329 | return false; 330 | } 331 | 332 | if ('null' === $expr) { 333 | return null; 334 | } 335 | 336 | // string literals 337 | if (preg_match('/^([\'"])(.*)\1$/', $expr, $matches)) { 338 | return JsonPathUtils::unescapeString($matches[2], $matches[1]); 339 | } 340 | 341 | // current node references 342 | if (str_starts_with($expr, '@.')) { 343 | $path = substr($expr, 2); 344 | 345 | return $context[$path] ?? null; 346 | } 347 | 348 | // function calls 349 | if (preg_match('/^(\w+)\((.*)\)$/', $expr, $matches)) { 350 | $functionName = $matches[1]; 351 | if (!isset(self::RFC9535_FUNCTIONS[$functionName])) { 352 | throw new JsonCrawlerException($expr, \sprintf('invalid function "%s"', $functionName)); 353 | } 354 | 355 | return $this->evaluateFunction($functionName, $matches[2], $context); 356 | } 357 | 358 | return null; 359 | } 360 | 361 | private function evaluateFunction(string $name, string $args, array $context): mixed 362 | { 363 | $args = array_map( 364 | fn ($arg) => $this->evaluateScalar(trim($arg), $context), 365 | explode(',', $args) 366 | ); 367 | 368 | $value = $args[0] ?? null; 369 | 370 | return match ($name) { 371 | 'length' => match (true) { 372 | \is_string($value) => mb_strlen($value), 373 | \is_array($value) => \count($value), 374 | default => 0, 375 | }, 376 | 'count' => \is_array($value) ? \count($value) : 0, 377 | 'match' => match (true) { 378 | \is_string($value) && \is_string($args[1] ?? null) => (bool) @preg_match(\sprintf('/^%s$/', $args[1]), $value), 379 | default => false, 380 | }, 381 | 'search' => match (true) { 382 | \is_string($value) && \is_string($args[1] ?? null) => (bool) @preg_match("/$args[1]/", $value), 383 | default => false, 384 | }, 385 | 'value' => $value, 386 | default => null, 387 | }; 388 | } 389 | 390 | private function evaluateRecursive(mixed $value): array 391 | { 392 | if (!\is_array($value)) { 393 | return []; 394 | } 395 | 396 | $result = [$value]; 397 | foreach ($value as $item) { 398 | if (\is_array($item)) { 399 | $result = array_merge($result, $this->evaluateRecursive($item)); 400 | } 401 | } 402 | 403 | return $result; 404 | } 405 | 406 | private function compare(mixed $left, mixed $right, string $operator): bool 407 | { 408 | return match ($operator) { 409 | '==' => $left === $right, 410 | '!=' => $left !== $right, 411 | '>' => $left > $right, 412 | '>=' => $left >= $right, 413 | '<' => $left < $right, 414 | '<=' => $left <= $right, 415 | default => false, 416 | }; 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /JsonCrawlerInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath; 13 | 14 | use Symfony\Component\JsonPath\Exception\InvalidArgumentException; 15 | use Symfony\Component\JsonPath\Exception\JsonCrawlerException; 16 | 17 | /** 18 | * @author Alexandre Daubois 19 | * 20 | * @experimental 21 | */ 22 | interface JsonCrawlerInterface 23 | { 24 | /** 25 | * @return list 26 | * 27 | * @throws InvalidArgumentException When the JSON string provided to the crawler cannot be decoded 28 | * @throws JsonCrawlerException When a syntax error occurs in the provided JSON path 29 | */ 30 | public function find(string|JsonPath $query): array; 31 | } 32 | -------------------------------------------------------------------------------- /JsonPath.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath; 13 | 14 | /** 15 | * @author Alexandre Daubois 16 | * 17 | * @immutable 18 | * 19 | * @experimental 20 | */ 21 | final class JsonPath 22 | { 23 | /** 24 | * @param non-empty-string $path 25 | */ 26 | public function __construct( 27 | private readonly string $path = '$', 28 | ) { 29 | } 30 | 31 | public function key(string $key): static 32 | { 33 | $escaped = $this->escapeKey($key); 34 | 35 | return new self($this->path.'["'.$escaped.'"]'); 36 | } 37 | 38 | public function index(int $index): static 39 | { 40 | return new self($this->path.'['.$index.']'); 41 | } 42 | 43 | public function deepScan(): static 44 | { 45 | return new self($this->path.'..'); 46 | } 47 | 48 | public function all(): static 49 | { 50 | return new self($this->path.'[*]'); 51 | } 52 | 53 | public function first(): static 54 | { 55 | return new self($this->path.'[0]'); 56 | } 57 | 58 | public function last(): static 59 | { 60 | return new self($this->path.'[-1]'); 61 | } 62 | 63 | public function slice(int $start, ?int $end = null, ?int $step = null): static 64 | { 65 | $slice = $start; 66 | if (null !== $end) { 67 | $slice .= ':'.$end; 68 | if (null !== $step) { 69 | $slice .= ':'.$step; 70 | } 71 | } 72 | 73 | return new self($this->path.'['.$slice.']'); 74 | } 75 | 76 | public function filter(string $expression): static 77 | { 78 | return new self($this->path.'[?('.$expression.')]'); 79 | } 80 | 81 | public function __toString(): string 82 | { 83 | return $this->path; 84 | } 85 | 86 | private function escapeKey(string $key): string 87 | { 88 | $key = strtr($key, [ 89 | '\\' => '\\\\', 90 | '"' => '\\"', 91 | "\n" => '\\n', 92 | "\r" => '\\r', 93 | "\t" => '\\t', 94 | "\b" => '\\b', 95 | "\f" => '\\f', 96 | ]); 97 | 98 | for ($i = 0; $i <= 31; ++$i) { 99 | if ($i < 8 || $i > 13) { 100 | $key = str_replace(\chr($i), \sprintf('\\u%04x', $i), $key); 101 | } 102 | } 103 | 104 | return $key; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /JsonPathUtils.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath; 13 | 14 | use Symfony\Component\JsonPath\Exception\InvalidArgumentException; 15 | use Symfony\Component\JsonPath\Tokenizer\JsonPathToken; 16 | use Symfony\Component\JsonPath\Tokenizer\TokenType; 17 | use Symfony\Component\JsonStreamer\Read\Splitter; 18 | 19 | /** 20 | * Get the smallest deserializable JSON string from a list of tokens that doesn't need any processing. 21 | * 22 | * @author Alexandre Daubois 23 | * 24 | * @internal 25 | */ 26 | final class JsonPathUtils 27 | { 28 | /** 29 | * @param JsonPathToken[] $tokens 30 | * @param resource $json 31 | * 32 | * @return array{json: string, tokens: list} 33 | */ 34 | public static function findSmallestDeserializableStringAndPath(array $tokens, mixed $json): array 35 | { 36 | if (!\is_resource($json)) { 37 | throw new InvalidArgumentException('The JSON parameter must be a resource.'); 38 | } 39 | 40 | $currentOffset = 0; 41 | $currentLength = null; 42 | 43 | $remainingTokens = $tokens; 44 | rewind($json); 45 | 46 | foreach ($tokens as $token) { 47 | $boundaries = []; 48 | 49 | if (TokenType::Name === $token->type) { 50 | foreach (Splitter::splitDict($json, $currentOffset, $currentLength) as $key => $bound) { 51 | $boundaries[$key] = $bound; 52 | if ($key === $token->value) { 53 | break; 54 | } 55 | } 56 | } elseif (TokenType::Bracket === $token->type && preg_match('/^\d+$/', $token->value)) { 57 | foreach (Splitter::splitList($json, $currentOffset, $currentLength) as $key => $bound) { 58 | $boundaries[$key] = $bound; 59 | if ($key === $token->value) { 60 | break; 61 | } 62 | } 63 | } 64 | 65 | if (!$boundaries) { 66 | // in case of a recursive descent or a filter, we can't reduce the JSON string 67 | break; 68 | } 69 | 70 | if (!\array_key_exists($token->value, $boundaries) || \count($remainingTokens) <= 1) { 71 | // the key given in the path is not found by the splitter or there is no remaining token to shift 72 | break; 73 | } 74 | 75 | // boundaries for the current token are found, we can remove it from the list 76 | // and substring the JSON string later 77 | $currentOffset = $boundaries[$token->value][0]; 78 | $currentLength = $boundaries[$token->value][1]; 79 | 80 | array_shift($remainingTokens); 81 | } 82 | 83 | return [ 84 | 'json' => stream_get_contents($json, $currentLength, $currentOffset ?: -1), 85 | 'tokens' => $remainingTokens, 86 | ]; 87 | } 88 | 89 | public static function unescapeString(string $str, string $quoteChar): string 90 | { 91 | if ('"' === $quoteChar) { 92 | // try JSON decoding first for unicode sequences 93 | $jsonStr = '"'.$str.'"'; 94 | $decoded = json_decode($jsonStr, true); 95 | 96 | if (null !== $decoded) { 97 | return $decoded; 98 | } 99 | } 100 | 101 | $result = ''; 102 | $length = \strlen($str); 103 | 104 | for ($i = 0; $i < $length; ++$i) { 105 | if ('\\' === $str[$i] && $i + 1 < $length) { 106 | $result .= match ($str[$i + 1]) { 107 | '"' => '"', 108 | "'" => "'", 109 | '\\' => '\\', 110 | '/' => '/', 111 | 'b' => "\b", 112 | 'f' => "\f", 113 | 'n' => "\n", 114 | 'r' => "\r", 115 | 't' => "\t", 116 | 'u' => self::unescapeUnicodeSequence($str, $length, $i), 117 | default => $str[$i].$str[$i + 1], // keep the backslash 118 | }; 119 | 120 | ++$i; 121 | } else { 122 | $result .= $str[$i]; 123 | } 124 | } 125 | 126 | return $result; 127 | } 128 | 129 | private static function unescapeUnicodeSequence(string $str, int $length, int &$i): string 130 | { 131 | if ($i + 5 >= $length) { 132 | // not enough characters for Unicode escape, treat as literal 133 | return $str[$i]; 134 | } 135 | 136 | $hex = substr($str, $i + 2, 4); 137 | if (!ctype_xdigit($hex)) { 138 | // invalid hex, treat as literal 139 | return $str[$i]; 140 | } 141 | 142 | $codepoint = hexdec($hex); 143 | // looks like a valid Unicode codepoint, string length is sufficient and it starts with \u 144 | if (0xD800 <= $codepoint && $codepoint <= 0xDBFF && $i + 11 < $length && '\\' === $str[$i + 6] && 'u' === $str[$i + 7]) { 145 | $lowHex = substr($str, $i + 8, 4); 146 | if (ctype_xdigit($lowHex)) { 147 | $lowSurrogate = hexdec($lowHex); 148 | if (0xDC00 <= $lowSurrogate && $lowSurrogate <= 0xDFFF) { 149 | $codepoint = 0x10000 + (($codepoint & 0x3FF) << 10) + ($lowSurrogate & 0x3FF); 150 | $i += 10; // skip surrogate pair 151 | 152 | return mb_chr($codepoint, 'UTF-8'); 153 | } 154 | } 155 | } 156 | 157 | // single Unicode character or invalid surrogate, skip the sequence 158 | $i += 4; 159 | 160 | return mb_chr($codepoint, 'UTF-8'); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | JsonPath Component 2 | ================== 3 | 4 | The JsonPath component eases JSON navigation using the JSONPath syntax as described in [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html). 5 | 6 | **This Component is experimental**. 7 | [Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) 8 | are not covered by Symfony's 9 | [Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). 10 | 11 | Getting Started 12 | --------------- 13 | 14 | ```bash 15 | composer require symfony/json-path 16 | ``` 17 | 18 | ```php 19 | use Symfony\Component\JsonPath\JsonCrawler; 20 | 21 | $json = <<<'JSON' 22 | {"store": {"book": [ 23 | {"category": "reference", "author": "Nigel Rees", "title": "Sayings", "price": 8.95}, 24 | {"category": "fiction", "author": "Evelyn Waugh", "title": "Sword", "price": 12.99} 25 | ]}} 26 | JSON; 27 | 28 | $crawler = new JsonCrawler($json); 29 | 30 | $result = $crawler->find('$.store.book[0].title'); 31 | $result = $crawler->find('$.store.book[?match(@.author, "[A-Z].*el.+")]'); 32 | $result = $crawler->find("$.store.book[?(@.category == 'fiction')].title"); 33 | ``` 34 | 35 | Resources 36 | --------- 37 | 38 | * [Documentation](https://symfony.com/doc/current/components/dom_crawler.html) 39 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 40 | * [Report issues](https://github.com/symfony/symfony/issues) and 41 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 42 | in the [main Symfony repository](https://github.com/symfony/symfony) 43 | -------------------------------------------------------------------------------- /Test/JsonPathAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Test; 13 | 14 | use PHPUnit\Framework\Assert; 15 | use PHPUnit\Framework\ExpectationFailedException; 16 | use Symfony\Component\JsonPath\JsonPath; 17 | 18 | /** 19 | * @author Alexandre Daubois 20 | * 21 | * @experimental 22 | */ 23 | trait JsonPathAssertionsTrait 24 | { 25 | /** 26 | * @throws ExpectationFailedException 27 | */ 28 | final public static function assertJsonPathEquals(mixed $expectedValue, JsonPath|string $jsonPath, string $json, string $message = ''): void 29 | { 30 | Assert::assertThat($expectedValue, new JsonPathEquals($jsonPath, $json), $message); 31 | } 32 | 33 | /** 34 | * @throws ExpectationFailedException 35 | */ 36 | final public static function assertJsonPathNotEquals(mixed $expectedValue, JsonPath|string $jsonPath, string $json, string $message = ''): void 37 | { 38 | Assert::assertThat($expectedValue, new JsonPathNotEquals($jsonPath, $json), $message); 39 | } 40 | 41 | /** 42 | * @throws ExpectationFailedException 43 | */ 44 | final public static function assertJsonPathCount(int $expectedCount, JsonPath|string $jsonPath, string $json, string $message = ''): void 45 | { 46 | Assert::assertThat($expectedCount, new JsonPathCount($jsonPath, $json), $message); 47 | } 48 | 49 | /** 50 | * @throws ExpectationFailedException 51 | */ 52 | final public static function assertJsonPathSame(mixed $expectedValue, JsonPath|string $jsonPath, string $json, string $message = ''): void 53 | { 54 | Assert::assertThat($expectedValue, new JsonPathSame($jsonPath, $json), $message); 55 | } 56 | 57 | /** 58 | * @throws ExpectationFailedException 59 | */ 60 | final public static function assertJsonPathNotSame(mixed $expectedValue, JsonPath|string $jsonPath, string $json, string $message = ''): void 61 | { 62 | Assert::assertThat($expectedValue, new JsonPathNotSame($jsonPath, $json), $message); 63 | } 64 | 65 | /** 66 | * @throws ExpectationFailedException 67 | */ 68 | final public static function assertJsonPathContains(mixed $expectedValue, JsonPath|string $jsonPath, string $json, bool $strict = true, string $message = ''): void 69 | { 70 | Assert::assertThat($expectedValue, new JsonPathContains($jsonPath, $json, $strict), $message); 71 | } 72 | 73 | /** 74 | * @throws ExpectationFailedException 75 | */ 76 | final public static function assertJsonPathNotContains(mixed $expectedValue, JsonPath|string $jsonPath, string $json, bool $strict = true, string $message = ''): void 77 | { 78 | Assert::assertThat($expectedValue, new JsonPathNotContains($jsonPath, $json, $strict), $message); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Test/JsonPathContains.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Test; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\JsonPath\JsonCrawler; 16 | use Symfony\Component\JsonPath\JsonPath; 17 | 18 | /** 19 | * @author Alexandre Daubois 20 | * 21 | * @experimental 22 | */ 23 | class JsonPathContains extends Constraint 24 | { 25 | public function __construct( 26 | private JsonPath|string $jsonPath, 27 | private string $json, 28 | private bool $strict = true, 29 | ) { 30 | } 31 | 32 | public function toString(): string 33 | { 34 | return \sprintf('is found in elements at JSON path "%s"', $this->jsonPath); 35 | } 36 | 37 | protected function matches(mixed $other): bool 38 | { 39 | $result = (new JsonCrawler($this->json))->find($this->jsonPath); 40 | 41 | return \in_array($other, $result, $this->strict); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Test/JsonPathCount.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Test; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\JsonPath\JsonCrawler; 16 | use Symfony\Component\JsonPath\JsonPath; 17 | 18 | /** 19 | * @author Alexandre Daubois 20 | * 21 | * @experimental 22 | */ 23 | class JsonPathCount extends Constraint 24 | { 25 | public function __construct( 26 | private JsonPath|string $jsonPath, 27 | private string $json, 28 | ) { 29 | } 30 | 31 | public function toString(): string 32 | { 33 | return \sprintf('matches expected count of JSON path "%s"', $this->jsonPath); 34 | } 35 | 36 | protected function matches(mixed $other): bool 37 | { 38 | return $other === \count((new JsonCrawler($this->json))->find($this->jsonPath)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Test/JsonPathEquals.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Test; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\JsonPath\JsonCrawler; 16 | use Symfony\Component\JsonPath\JsonPath; 17 | 18 | /** 19 | * @author Alexandre Daubois 20 | * 21 | * @experimental 22 | */ 23 | class JsonPathEquals extends Constraint 24 | { 25 | public function __construct( 26 | private JsonPath|string $jsonPath, 27 | private string $json, 28 | ) { 29 | } 30 | 31 | public function toString(): string 32 | { 33 | return \sprintf('equals JSON path "%s" result', $this->jsonPath); 34 | } 35 | 36 | protected function matches(mixed $other): bool 37 | { 38 | return (new JsonCrawler($this->json))->find($this->jsonPath) == $other; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Test/JsonPathNotContains.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Test; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\JsonPath\JsonCrawler; 16 | use Symfony\Component\JsonPath\JsonPath; 17 | 18 | /** 19 | * @author Alexandre Daubois 20 | * 21 | * @experimental 22 | */ 23 | class JsonPathNotContains extends Constraint 24 | { 25 | public function __construct( 26 | private JsonPath|string $jsonPath, 27 | private string $json, 28 | private bool $strict = true, 29 | ) { 30 | } 31 | 32 | public function toString(): string 33 | { 34 | return \sprintf('is not found in elements at JSON path "%s"', $this->jsonPath); 35 | } 36 | 37 | protected function matches(mixed $other): bool 38 | { 39 | $result = (new JsonCrawler($this->json))->find($this->jsonPath); 40 | 41 | return !\in_array($other, $result, $this->strict); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Test/JsonPathNotEquals.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Test; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\JsonPath\JsonCrawler; 16 | use Symfony\Component\JsonPath\JsonPath; 17 | 18 | /** 19 | * @author Alexandre Daubois 20 | * 21 | * @experimental 22 | */ 23 | class JsonPathNotEquals extends Constraint 24 | { 25 | public function __construct( 26 | private JsonPath|string $jsonPath, 27 | private string $json, 28 | ) { 29 | } 30 | 31 | public function toString(): string 32 | { 33 | return \sprintf('does not equal JSON path "%s" result', $this->jsonPath); 34 | } 35 | 36 | protected function matches(mixed $other): bool 37 | { 38 | return (new JsonCrawler($this->json))->find($this->jsonPath) != $other; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Test/JsonPathNotSame.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Test; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\JsonPath\JsonCrawler; 16 | use Symfony\Component\JsonPath\JsonPath; 17 | 18 | /** 19 | * @author Alexandre Daubois 20 | * 21 | * @experimental 22 | */ 23 | class JsonPathNotSame extends Constraint 24 | { 25 | public function __construct( 26 | private JsonPath|string $jsonPath, 27 | private string $json, 28 | ) { 29 | } 30 | 31 | public function toString(): string 32 | { 33 | return \sprintf('is not identical to JSON path "%s" result', $this->jsonPath); 34 | } 35 | 36 | protected function matches(mixed $other): bool 37 | { 38 | return (new JsonCrawler($this->json))->find($this->jsonPath) !== $other; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Test/JsonPathSame.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Test; 13 | 14 | use PHPUnit\Framework\Constraint\Constraint; 15 | use Symfony\Component\JsonPath\JsonCrawler; 16 | use Symfony\Component\JsonPath\JsonPath; 17 | 18 | /** 19 | * @author Alexandre Daubois 20 | * 21 | * @experimental 22 | */ 23 | class JsonPathSame extends Constraint 24 | { 25 | public function __construct( 26 | private JsonPath|string $jsonPath, 27 | private string $json, 28 | ) { 29 | } 30 | 31 | public function toString(): string 32 | { 33 | return \sprintf('is identical to JSON path "%s" result', $this->jsonPath); 34 | } 35 | 36 | protected function matches(mixed $other): bool 37 | { 38 | return (new JsonCrawler($this->json))->find($this->jsonPath) === $other; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tokenizer/JsonPathToken.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Tokenizer; 13 | 14 | /** 15 | * @author Alexandre Daubois 16 | * 17 | * @internal 18 | */ 19 | final class JsonPathToken 20 | { 21 | public function __construct( 22 | public TokenType $type, 23 | public string $value, 24 | ) { 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tokenizer/JsonPathTokenizer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Tokenizer; 13 | 14 | use Symfony\Component\JsonPath\Exception\InvalidJsonPathException; 15 | use Symfony\Component\JsonPath\JsonPath; 16 | 17 | /** 18 | * @author Alexandre Daubois 19 | * 20 | * @internal 21 | */ 22 | final class JsonPathTokenizer 23 | { 24 | /** 25 | * @return JsonPathToken[] 26 | */ 27 | public static function tokenize(JsonPath $query): array 28 | { 29 | $tokens = []; 30 | $current = ''; 31 | $inBracket = false; 32 | $bracketDepth = 0; 33 | $inFilter = false; 34 | $inQuote = false; 35 | $quoteChar = ''; 36 | $filterParenthesisDepth = 0; 37 | 38 | $chars = mb_str_split((string) $query); 39 | $length = \count($chars); 40 | 41 | if (0 === $length) { 42 | throw new InvalidJsonPathException('empty JSONPath expression.'); 43 | } 44 | 45 | if ('$' !== $chars[0]) { 46 | throw new InvalidJsonPathException('expression must start with $.'); 47 | } 48 | 49 | for ($i = 0; $i < $length; ++$i) { 50 | $char = $chars[$i]; 51 | $position = $i; 52 | 53 | if (('"' === $char || "'" === $char) && !$inQuote) { 54 | $inQuote = true; 55 | $quoteChar = $char; 56 | $current .= $char; 57 | continue; 58 | } 59 | 60 | if ($inQuote) { 61 | $current .= $char; 62 | if ($char === $quoteChar && '\\' !== $chars[$i - 1]) { 63 | $inQuote = false; 64 | } 65 | if ($i === $length - 1 && $inQuote) { 66 | throw new InvalidJsonPathException('unclosed string literal.', $position); 67 | } 68 | continue; 69 | } 70 | 71 | if ('$' === $char && 0 === $i) { 72 | continue; 73 | } 74 | 75 | if ('[' === $char && !$inFilter) { 76 | if ('' !== $current) { 77 | $tokens[] = new JsonPathToken(TokenType::Name, $current); 78 | $current = ''; 79 | } 80 | 81 | $inBracket = true; 82 | ++$bracketDepth; 83 | continue; 84 | } 85 | 86 | if (']' === $char) { 87 | if ($inFilter && $filterParenthesisDepth > 0) { 88 | $current .= $char; 89 | continue; 90 | } 91 | 92 | if (--$bracketDepth < 0) { 93 | throw new InvalidJsonPathException('unmatched closing bracket.', $position); 94 | } 95 | 96 | if (0 === $bracketDepth) { 97 | if ('' === $current) { 98 | throw new InvalidJsonPathException('empty brackets are not allowed.', $position); 99 | } 100 | 101 | $tokens[] = new JsonPathToken(TokenType::Bracket, $current); 102 | $current = ''; 103 | $inBracket = false; 104 | $inFilter = false; 105 | $filterParenthesisDepth = 0; 106 | continue; 107 | } 108 | } 109 | 110 | if ('?' === $char && $inBracket && !$inFilter) { 111 | if ('' !== $current) { 112 | throw new InvalidJsonPathException('unexpected characters before filter expression.', $position); 113 | } 114 | $inFilter = true; 115 | $filterParenthesisDepth = 0; 116 | } 117 | 118 | if ($inFilter) { 119 | if ('(' === $char) { 120 | ++$filterParenthesisDepth; 121 | } elseif (')' === $char) { 122 | if (--$filterParenthesisDepth < 0) { 123 | throw new InvalidJsonPathException('unmatched closing parenthesis in filter.', $position); 124 | } 125 | } 126 | } 127 | 128 | // recursive descent 129 | if ('.' === $char && !$inBracket) { 130 | if ('' !== $current) { 131 | $tokens[] = new JsonPathToken(TokenType::Name, $current); 132 | $current = ''; 133 | } 134 | 135 | if ($i + 1 < $length && '.' === $chars[$i + 1]) { 136 | // more than two consecutive dots? 137 | if ($i + 2 < $length && '.' === $chars[$i + 2]) { 138 | throw new InvalidJsonPathException('invalid character "." in property name.', $i + 2); 139 | } 140 | 141 | $tokens[] = new JsonPathToken(TokenType::Recursive, '..'); 142 | ++$i; 143 | } elseif ($i + 1 >= $length) { 144 | throw new InvalidJsonPathException('path cannot end with a dot.', $position); 145 | } 146 | 147 | continue; 148 | } 149 | 150 | $current .= $char; 151 | } 152 | 153 | if ($inBracket) { 154 | throw new InvalidJsonPathException('unclosed bracket.', $length - 1); 155 | } 156 | 157 | if ($inQuote) { 158 | throw new InvalidJsonPathException('unclosed string literal.', $length - 1); 159 | } 160 | 161 | if ('' !== $current) { 162 | // final validation of the whole name 163 | if (!preg_match('/^(?:\*|[a-zA-Z_\x{0080}-\x{D7FF}\x{E000}-\x{10FFFF}][a-zA-Z0-9_\x{0080}-\x{D7FF}\x{E000}-\x{10FFFF}]*)$/u', $current)) { 164 | throw new InvalidJsonPathException(\sprintf('invalid character in property name "%s"', $current)); 165 | } 166 | 167 | $tokens[] = new JsonPathToken(TokenType::Name, $current); 168 | } 169 | 170 | return $tokens; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Tokenizer/TokenType.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\JsonPath\Tokenizer; 13 | 14 | /** 15 | * @author Alexandre Daubois 16 | * 17 | * @internal 18 | */ 19 | enum TokenType 20 | { 21 | case Name; 22 | case Bracket; 23 | case Recursive; 24 | } 25 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/json-path", 3 | "type": "library", 4 | "description": "Eases JSON navigation using the JSONPath syntax as described in RFC 9535", 5 | "keywords": [], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Alexandre Daubois", 11 | "email": "alex.daubois@gmail.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "symfony/polyfill-ctype": "^1.8", 21 | "symfony/polyfill-mbstring": "~1.0" 22 | }, 23 | "require-dev": { 24 | "symfony/json-streamer": "7.3.*" 25 | }, 26 | "conflict": { 27 | "symfony/json-streamer": ">=7.4" 28 | }, 29 | "autoload": { 30 | "psr-4": { "Symfony\\Component\\JsonPath\\": "" }, 31 | "exclude-from-classmap": [ 32 | "/Tests/" 33 | ] 34 | }, 35 | "minimum-stability": "dev" 36 | } 37 | --------------------------------------------------------------------------------