From 59de5331e7ee1fbe9a1ef36ada60cfc6b0f07f57 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Sun, 30 Mar 2025 17:08:37 +0200 Subject: [PATCH] [JsonPath] Add FrameworkBundle integration --- .../DependencyInjection/Configuration.php | 14 ++ .../FrameworkExtension.php | 14 ++ .../Resources/config/json_path.php | 21 +++ .../DependencyInjection/ConfigurationTest.php | 4 + .../Bundle/FrameworkBundle/composer.json | 3 +- .../Component/JsonPath/CrawlerInterface.php | 31 ++++ .../Component/JsonPath/JsonCrawler.php | 43 +++-- .../JsonPath/JsonCrawlerInterface.php | 10 +- .../JsonPath/Tests/JsonCrawlerTest.php | 148 ++++++++++++------ 9 files changed, 211 insertions(+), 77 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/json_path.php create mode 100644 src/Symfony/Component/JsonPath/CrawlerInterface.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index f4e137f04b98..170b16c0233f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -31,6 +31,7 @@ use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\IpUtils; +use Symfony\Component\JsonPath\JsonPath; use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\Lock\Lock; use Symfony\Component\Lock\Store\SemaphoreStore; @@ -186,6 +187,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addWebhookSection($rootNode, $enableIfStandalone); $this->addRemoteEventSection($rootNode, $enableIfStandalone); $this->addJsonStreamerSection($rootNode, $enableIfStandalone); + $this->addJsonPathSection($rootNode, $enableIfStandalone); return $treeBuilder; } @@ -2763,4 +2765,16 @@ private function addJsonStreamerSection(ArrayNodeDefinition $rootNode, callable ->end() ; } + + private function addJsonPathSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void + { + $rootNode + ->children() + ->arrayNode('json_path') + ->info('JsonPath configuration') + ->{$enableIfStandalone('symfony/json-path', JsonPath::class)}() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2910c7cbe650..0b1f2c3a136b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -107,6 +107,7 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\HttpKernel\Log\DebugLoggerConfigurator; use Symfony\Component\HttpKernel\Profiler\ProfilerStateChecker; +use Symfony\Component\JsonPath\JsonPath; use Symfony\Component\JsonStreamer\Attribute\JsonStreamable; use Symfony\Component\JsonStreamer\JsonStreamWriter; use Symfony\Component\JsonStreamer\StreamReaderInterface; @@ -476,6 +477,10 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerJsonStreamerConfiguration($config['json_streamer'], $container, $loader); } + if ($this->readConfigEnabled('json_path', $container, $config['json_path'])) { + $this->registerJsonPathConfiguration($config['json_path'], $container, $loader); + } + if ($this->readConfigEnabled('lock', $container, $config['lock'])) { $this->registerLockConfiguration($config['lock'], $container, $loader); } @@ -2134,6 +2139,15 @@ private function registerJsonStreamerConfiguration(array $config, ContainerBuild } } + private function registerJsonPathConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (class_exists(JsonPath::class)) { + throw new LogicException('JsonPath support cannot be enabled as the JsonPath component is not installed. Try running "composer require symfony/json-path".'); + } + + $loader->load('json_path.php'); + } + private function registerPropertyInfoConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void { if (!interface_exists(PropertyInfoExtractorInterface::class)) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/json_path.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/json_path.php new file mode 100644 index 000000000000..5a2882df9d32 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/json_path.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\JsonPath\JsonCrawler; +use Symfony\Component\JsonPath\JsonCrawlerInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('json_path.crawler', JsonCrawler::class) + ->alias(JsonCrawlerInterface::class, 'json_path.crawler'); +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index c8142e98ab1a..7879256aff5a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -22,6 +22,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\JsonPath\JsonPath; use Symfony\Component\JsonStreamer\JsonStreamWriter; use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Mailer\Mailer; @@ -1014,6 +1015,9 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'json_streamer' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(JsonStreamWriter::class), ], + 'json_path' => [ + 'enabled' => !class_exists(FullStack::class) && class_exists(JsonPath::class), + ] ]; } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 15a9496d1106..9b10e4bddf21 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -49,6 +49,8 @@ "symfony/expression-language": "^6.4|^7.0", "symfony/html-sanitizer": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", + "symfony/json-path": "7.3.*", + "symfony/json-streamer": "7.3.*", "symfony/lock": "^6.4|^7.0", "symfony/mailer": "^6.4|^7.0", "symfony/messenger": "^6.4|^7.0", @@ -70,7 +72,6 @@ "symfony/workflow": "^7.3", "symfony/yaml": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", - "symfony/json-streamer": "7.3.*", "symfony/uid": "^6.4|^7.0", "symfony/web-link": "^6.4|^7.0", "symfony/webhook": "^7.2", diff --git a/src/Symfony/Component/JsonPath/CrawlerInterface.php b/src/Symfony/Component/JsonPath/CrawlerInterface.php new file mode 100644 index 000000000000..977c636524df --- /dev/null +++ b/src/Symfony/Component/JsonPath/CrawlerInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath; + +use Symfony\Component\JsonPath\Exception\InvalidArgumentException; +use Symfony\Component\JsonPath\Exception\JsonCrawlerException; + +/** + * @author Alexandre Daubois + * + * @experimental + */ +interface CrawlerInterface +{ + /** + * @return list + * + * @throws InvalidArgumentException When the data source provided to the crawler cannot be decoded + * @throws JsonCrawlerException When a syntax error occurs in the provided JSON path + */ + public function find(string|JsonPath $query): array; +} diff --git a/src/Symfony/Component/JsonPath/JsonCrawler.php b/src/Symfony/Component/JsonPath/JsonCrawler.php index 75c61e14f79d..08ceb90cefc0 100644 --- a/src/Symfony/Component/JsonPath/JsonCrawler.php +++ b/src/Symfony/Component/JsonPath/JsonCrawler.php @@ -28,7 +28,7 @@ * * @experimental */ -final class JsonCrawler implements JsonCrawlerInterface +final class JsonCrawler implements CrawlerInterface, JsonCrawlerInterface { private const RFC9535_FUNCTIONS = [ 'length' => true, @@ -39,16 +39,23 @@ final class JsonCrawler implements JsonCrawlerInterface ]; /** - * @param resource|string $raw + * @param resource|string|array $data */ - public function __construct( - private readonly mixed $raw, - ) { - if (!\is_string($raw) && !\is_resource($raw)) { - throw new InvalidArgumentException(\sprintf('Expected string or resource, got "%s".', get_debug_type($raw))); + public function __construct(private mixed $data = []) + { + if (!\is_string($data) && !\is_resource($data) && !\is_array($data)) { + throw new \TypeError(\sprintf('Argument #1 ($data) must be of type string, array or resource, %s given.', get_debug_type($data))); } } + /** + * @param resource|string|array $data + */ + public function fromJson(mixed $data): CrawlerInterface + { + return new self($data); + } + public function find(string|JsonPath $query): array { return $this->evaluate(\is_string($query) ? new JsonPath($query) : $query); @@ -58,29 +65,33 @@ private function evaluate(JsonPath $query): array { try { $tokens = JsonPathTokenizer::tokenize($query); - $json = $this->raw; + $json = $this->data; - if (\is_resource($this->raw)) { + if (\is_resource($this->data)) { if (!class_exists(Splitter::class)) { throw new \LogicException('The JsonStreamer package is required to evaluate a path against a resource. Try running "composer require symfony/json-streamer".'); } $simplified = JsonPathUtils::findSmallestDeserializableStringAndPath( $tokens, - $this->raw, + $this->data, ); $tokens = $simplified['tokens']; $json = $simplified['json']; } - try { - $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new InvalidJsonStringInputException($e->getMessage(), $e); - } + if (\is_array($json)) { + $current = [$json]; + } else { + try { + $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InvalidJsonStringInputException($e->getMessage(), $e); + } - $current = [$data]; + $current = [$data]; + } foreach ($tokens as $token) { $next = []; diff --git a/src/Symfony/Component/JsonPath/JsonCrawlerInterface.php b/src/Symfony/Component/JsonPath/JsonCrawlerInterface.php index 3e8a222f0ba8..82191b8260a7 100644 --- a/src/Symfony/Component/JsonPath/JsonCrawlerInterface.php +++ b/src/Symfony/Component/JsonPath/JsonCrawlerInterface.php @@ -11,9 +11,6 @@ namespace Symfony\Component\JsonPath; -use Symfony\Component\JsonPath\Exception\InvalidArgumentException; -use Symfony\Component\JsonPath\Exception\JsonCrawlerException; - /** * @author Alexandre Daubois * @@ -22,10 +19,7 @@ interface JsonCrawlerInterface { /** - * @return list - * - * @throws InvalidArgumentException When the JSON string provided to the crawler cannot be decoded - * @throws JsonCrawlerException When a syntax error occurs in the provided JSON path + * @param resource|string|array $data */ - public function find(string|JsonPath $query): array; + public function fromJson(mixed $data): CrawlerInterface; } diff --git a/src/Symfony/Component/JsonPath/Tests/JsonCrawlerTest.php b/src/Symfony/Component/JsonPath/Tests/JsonCrawlerTest.php index 6871a5651189..50230b8b141c 100644 --- a/src/Symfony/Component/JsonPath/Tests/JsonCrawlerTest.php +++ b/src/Symfony/Component/JsonPath/Tests/JsonCrawlerTest.php @@ -20,10 +20,10 @@ class JsonCrawlerTest extends TestCase { - public function testNotStringOrResourceThrows() + public function testWrongDataType() { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Expected string or resource, got "int".'); + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument #1 ($data) must be of type string, array or resource, int given.'); new JsonCrawler(42); } @@ -33,7 +33,24 @@ public function testInvalidInputJson() $this->expectException(InvalidJsonStringInputException::class); $this->expectExceptionMessage('Invalid JSON input: Syntax error.'); - (new JsonCrawler('invalid'))->find('$..*'); + (new JsonCrawler('invalid')) + ->find('$..*'); + } + + public function testFromJson() + { + $crawler = (new JsonCrawler())->fromJson('{"a": 1, "b": 2}'); + + $result = $crawler->find('$.*'); + $this->assertSame([1, 2], $result); + } + + public function testNoDataInConstructorMakesCrawlerNoop() + { + $crawler = new JsonCrawler(); + + $result = $crawler->find('$..*'); + $this->assertSame([], $result); } public function testAllAuthors() @@ -49,6 +66,33 @@ public function testAllAuthors() ], $result); } + public function testArrayAsInput() + { + $crawler = new JsonCrawler([ + 'a' => true, + 'b' => false, + 'c' => null, + 'd' => ['e' => 1], + ]); + + $result = $crawler->find('$.*'); + $this->assertSame([true, false, null, ['e' => 1]], $result); + } + + public function testArrayInputWithFilter() + { + $crawler = new JsonCrawler([ + 'a' => true, + 'b' => false, + 'c' => null, + 'd' => ['e' => 1], + ]); + + $result = $crawler->find('$[?(@.e == 1)]'); + $this->assertCount(1, $result); + $this->assertSame([['e' => 1]], $result); + } + public function testAllThingsInStore() { $result = self::getBookstoreCrawler()->find('$.store.*'); @@ -61,8 +105,8 @@ public function testAllThingsInStore() public function testEscapedDoubleQuotesInFieldName() { $crawler = new JsonCrawler(<<find("$['a']['b\\\"c']"); @@ -142,8 +186,8 @@ public function testRecursiveWildcard() public function testSliceWithStep() { $crawler = new JsonCrawler(<<find('$.a[1:5:2]'); $this->assertSame([5, 2], $result); @@ -152,8 +196,8 @@ public function testSliceWithStep() public function testNegativeSlice() { $crawler = new JsonCrawler(<<find('$.a[-3:]'); @@ -242,8 +286,8 @@ public function testNegativeIndicesEdgeCases() public function testBoundaryConditions() { $crawler = new JsonCrawler(<<find('$.a[0:6]'); $this->assertSame([3, 5, 1, 2, 4, 6], $result); @@ -258,8 +302,8 @@ public function testBoundaryConditions() public function testFilterByValue() { $crawler = new JsonCrawler(<<find("$.a[?(@.b == 'kilo')]"); @@ -407,50 +451,50 @@ public function testAcceptsJsonPath() private static function getBookstoreCrawler(): JsonCrawler { return new JsonCrawler(<<