diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 096380d0c7f..e24473440de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,7 +306,7 @@ jobs: continue-on-error: true phpunit-components: - name: PHPUnit ${{ matrix.component }} (PHP ${{ matrix.php.version }} ${{ matrix.php.coverage && 'coverage' || '' }}${{ matrix.php.lowest && 'lowest' || '' }}${{ matrix.php.minimal-changes && 'minimal-changes' || '' }}) + name: PHPUnit ${{ matrix.component }} (PHP ${{ matrix.php.version }} ${{ matrix.php.coverage && 'coverage' || '' }}${{ matrix.php.lowest && 'lowest' || '' }}${{ matrix.php.minimal-changes && 'minimal-changes' || '' }}${{ matrix.opensearch && 'opensearch' || '' }}) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -338,6 +338,11 @@ jobs: - api-platform/state - api-platform/symfony - api-platform/validator + include: + - php: + version: '8.5' + component: api-platform/elasticsearch + opensearch: true fail-fast: false steps: - name: Checkout @@ -368,6 +373,11 @@ jobs: run: | cd $(composer ${{matrix.component}} --cwd) composer update${{ matrix.php.lowest && ' --prefer-lowest --prefer-source' || '' }}${{ matrix.php.minimal-changes && ' --minimal-changes' || '' }} + - name: Install OpenSearch PHP client + if: ${{ matrix.opensearch }} + run: | + cd $(composer ${{matrix.component}} --cwd) + composer require --dev opensearch-project/opensearch-php "^2.5" -W - name: Run ${{ matrix.component }} tests run: | mkdir -p /tmp/build/logs/phpunit @@ -888,6 +898,64 @@ jobs: - name: Run Behat tests run: vendor/bin/behat --out=std --format=progress --profile=elasticsearch --no-interaction + opensearch: + name: Behat (PHP ${{ matrix.php }}) (OpenSearch ${{ matrix.opensearch-version }}) + runs-on: ubuntu-22.04 + timeout-minutes: 20 + strategy: + matrix: + include: + - php: '8.5' + opensearch-version: '2.19.4' + extensions: 'intl, bcmath, curl, openssl, mbstring' + fail-fast: false + env: + APP_ENV: opensearch + services: + opensearch: + image: opensearchproject/opensearch:${{ matrix.opensearch-version }} + ports: + - 9200:9200 + env: + discovery.type: single-node + DISABLE_SECURITY_PLUGIN: 'true' + OPENSEARCH_INITIAL_ADMIN_PASSWORD: 'Admin_1234!' + options: >- + --health-cmd "curl -f http://localhost:9200/_cluster/health || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: ${{ matrix.extensions }} + coverage: none + ini-values: memory_limit=-1 + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Update project dependencies + run: | + composer global require soyuka/pmu + composer global config allow-plugins.soyuka/pmu true --no-interaction + composer global link . + composer require --dev opensearch-project/opensearch-php "^2.5" -W + - name: Clear test app cache + run: tests/Fixtures/app/console cache:clear --ansi + - name: Run Behat tests + run: vendor/bin/behat --out=std --format=progress --profile=opensearch --no-interaction + phpunit-no-deprecations: name: PHPUnit (PHP ${{ matrix.php }}) (no deprecations) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 71298e7280b..6ccf07834e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +* [f74d7ba1a](https://github.com/api-platform/core/commit/f74d7ba1a) feat(elasticsearch): add OpenSearch support (#7519) + ## v4.3.0-alpha.2 ### Bug fixes diff --git a/behat.yml.dist b/behat.yml.dist index 5f7419cf8c0..c96ba8d3a43 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -107,6 +107,21 @@ elasticsearch: filters: tags: '@elasticsearch&&~@mercure&&~@query_parameter_validator' +opensearch: + suites: + default: false + opensearch: + paths: + - '%paths.base%/features/elasticsearch' + contexts: + - 'ApiPlatform\Tests\Behat\CommandContext' + - 'ApiPlatform\Tests\Behat\ElasticsearchContext' + - 'ApiPlatform\Tests\Behat\JsonContext' + - 'Behat\MinkExtension\Context\MinkContext' + - 'behatch:context:rest' + filters: + tags: '@elasticsearch&&~@mercure&&~@query_parameter_validator' + default-coverage: suites: default: &default-coverage-suite diff --git a/composer.json b/composer.json index 6f1c9c3dde5..0b922b61b16 100644 --- a/composer.json +++ b/composer.json @@ -202,6 +202,7 @@ "suggest": { "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", + "opensearch-project/opensearch-php": "To support OpenSearch (^2.5).", "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", "ramsey/uuid": "To support Ramsey's UUID identifiers.", diff --git a/src/Elasticsearch/State/CollectionProvider.php b/src/Elasticsearch/State/CollectionProvider.php index df84a52e32d..4e78be7fa66 100644 --- a/src/Elasticsearch/State/CollectionProvider.php +++ b/src/Elasticsearch/State/CollectionProvider.php @@ -26,6 +26,8 @@ use Elastic\Elasticsearch\Response\Elasticsearch; use Elasticsearch\Client as V7Client; use Elasticsearch\Common\Exceptions\Missing404Exception as V7Missing404Exception; +use OpenSearch\Client as OpenSearchClient; +use OpenSearch\Common\Exceptions\Missing404Exception as OpenSearchMissing404Exception; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; /** @@ -40,7 +42,7 @@ final class CollectionProvider implements ProviderInterface * @param RequestBodySearchCollectionExtensionInterface[] $collectionExtensions */ public function __construct( - private readonly V7Client|Client $client, // @phpstan-ignore-line + private readonly V7Client|Client|OpenSearchClient $client, // @phpstan-ignore-line private readonly ?DenormalizerInterface $denormalizer = null, private readonly ?Pagination $pagination = null, private readonly iterable $collectionExtensions = [], @@ -76,7 +78,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $documents = $this->client->search($params); // @phpstan-ignore-line - } catch (V7Missing404Exception $e) { // @phpstan-ignore-line + } catch (V7Missing404Exception|OpenSearchMissing404Exception $e) { // @phpstan-ignore-line throw new Error(status: $e->getCode(), detail: $e->getMessage(), title: $e->getMessage(), originalTrace: $e->getTrace()); // @phpstan-ignore-line } catch (ClientResponseException $e) { $response = $e->getResponse(); diff --git a/src/Elasticsearch/State/ItemProvider.php b/src/Elasticsearch/State/ItemProvider.php index 49158ae547e..88321d26fa2 100644 --- a/src/Elasticsearch/State/ItemProvider.php +++ b/src/Elasticsearch/State/ItemProvider.php @@ -24,6 +24,8 @@ use Elastic\Elasticsearch\Response\Elasticsearch; use Elasticsearch\Client as V7Client; use Elasticsearch\Common\Exceptions\Missing404Exception as V7Missing404Exception; +use OpenSearch\Client as OpenSearchClient; +use OpenSearch\Common\Exceptions\Missing404Exception as OpenSearchMissing404Exception; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -36,7 +38,7 @@ final class ItemProvider implements ProviderInterface { public function __construct( - private readonly V7Client|Client $client, // @phpstan-ignore-line + private readonly V7Client|Client|OpenSearchClient $client, // @phpstan-ignore-line private readonly ?DenormalizerInterface $denormalizer = null, private readonly ?InflectorInterface $inflector = new Inflector(), ) { @@ -60,7 +62,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $document = $this->client->get($params); // @phpstan-ignore-line - } catch (V7Missing404Exception) { // @phpstan-ignore-line + } catch (V7Missing404Exception|OpenSearchMissing404Exception) { // @phpstan-ignore-line return null; } catch (ClientResponseException $e) { $response = $e->getResponse(); diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index 82ee303920f..9c59704a885 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -36,6 +36,9 @@ "symfony/type-info": "^7.3 || ^8.0", "symfony/uid": "^6.4 || ^7.0 || ^8.0" }, + "suggest": { + "opensearch-project/opensearch-php": "Required to use OpenSearch instead of Elasticsearch (^2.5)" + }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^12.2" diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 42b625cf8ee..da27c443f5c 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -991,16 +991,21 @@ private function registerElasticsearchConfiguration(ContainerBuilder $container, throw new \LogicException('Elasticsearch support cannot be enabled as the Elasticsearch component is not installed. Try running "composer require api-platform/elasticsearch".'); } - $clientClass = !class_exists(\Elasticsearch\Client::class) - // ES v7 - ? \Elastic\Elasticsearch\Client::class + if ('opensearch' === $config['elasticsearch']['client']) { + $clientClass = \OpenSearch\Client::class; // @phpstan-ignore class.notFound + } elseif (!class_exists(\Elasticsearch\Client::class)) { // ES v8 and up - : \Elasticsearch\Client::class; + $clientClass = \Elastic\Elasticsearch\Client::class; + } else { + // ES v7 + $clientClass = \Elasticsearch\Client::class; + } $clientDefinition = new Definition($clientClass); $container->setDefinition('api_platform.elasticsearch.client', $clientDefinition); $container->registerForAutoconfiguration(RequestBodySearchCollectionExtensionInterface::class) ->addTag('api_platform.elasticsearch.request_body_search_extension.collection'); + $container->setParameter('api_platform.elasticsearch.client', $config['elasticsearch']['client']); $container->setParameter('api_platform.elasticsearch.hosts', $config['elasticsearch']['hosts']); $container->setParameter('api_platform.elasticsearch.ssl_ca_bundle', $config['elasticsearch']['ssl_ca_bundle']); $container->setParameter('api_platform.elasticsearch.ssl_verification', $config['elasticsearch']['ssl_verification']); diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPass.php index 7a429febe1f..d676c46866f 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPass.php @@ -39,7 +39,9 @@ public function process(ContainerBuilder $container): void $clientConfiguration['hosts'] = $hosts; } - if (class_exists(\Elasticsearch\ClientBuilder::class)) { + if ('opensearch' === $container->getParameter('api_platform.elasticsearch.client')) { + $builderName = \OpenSearch\ClientBuilder::class; // @phpstan-ignore class.notFound + } elseif (class_exists(\Elasticsearch\ClientBuilder::class)) { // ES v7 $builderName = \Elasticsearch\ClientBuilder::class; } else { diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 43954615e50..b5ecf65fa8a 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -505,8 +505,10 @@ private function addElasticsearchSection(ArrayNodeDefinition $rootNode): void !class_exists(\Elasticsearch\Client::class) // ES v8 and up && !class_exists(\Elastic\Elasticsearch\Client::class) + // OpenSearch + && !class_exists(\OpenSearch\Client::class) ) { - throw new InvalidConfigurationException('The elasticsearch/elasticsearch package is required for Elasticsearch support.'); + throw new InvalidConfigurationException('The elasticsearch/elasticsearch or opensearch-project/opensearch-php package is required for Elasticsearch support.'); } return $v; @@ -526,6 +528,21 @@ private function addElasticsearchSection(ArrayNodeDefinition $rootNode): void ->defaultTrue() ->info('Enable or disable SSL verification for Elasticsearch connections.') ->end() + ->enumNode('client') + ->values(['elasticsearch', 'opensearch']) + ->defaultValue('elasticsearch') + ->info('The search engine client to use: "elasticsearch" or "opensearch".') + ->validate() + ->ifString() + ->then(static function (string $v): string { + if ('opensearch' === $v && !class_exists(\OpenSearch\Client::class)) { + throw new InvalidConfigurationException('Setting api_platform.elasticsearch.client to "opensearch" requires the opensearch-project/opensearch-php package. Try running "composer require opensearch-project/opensearch-php".'); + } + + return $v; + }) + ->end() + ->end() ->end() ->end() ->end(); diff --git a/tests/Behat/ElasticsearchContext.php b/tests/Behat/ElasticsearchContext.php index 5c8c2debb95..cc8fa176b67 100644 --- a/tests/Behat/ElasticsearchContext.php +++ b/tests/Behat/ElasticsearchContext.php @@ -16,6 +16,7 @@ use Behat\Behat\Context\Context; use Elastic\Elasticsearch\Client; use Elasticsearch\Client as V7Client; +use OpenSearch\Client as OpenSearchClient; use Symfony\Component\Finder\Finder; /** @@ -26,7 +27,7 @@ final class ElasticsearchContext implements Context { public function __construct( - private readonly V7Client|Client $client, // @phpstan-ignore-line + private readonly V7Client|Client|OpenSearchClient $client, // @phpstan-ignore-line private readonly string $elasticsearchMappingsPath, private readonly string $elasticsearchFixturesPath, ) { diff --git a/tests/Fixtures/app/config/config_opensearch.yml b/tests/Fixtures/app/config/config_opensearch.yml new file mode 100644 index 00000000000..66c518f9503 --- /dev/null +++ b/tests/Fixtures/app/config/config_opensearch.yml @@ -0,0 +1,27 @@ +imports: + - { resource: config_common.yml } + - { resource: config_behat_orm.yml } + +parameters: + env(ELASTICSEARCH_URL): http://localhost:9200 + +api_platform: + doctrine: false + mapping: + paths: + - '%kernel.project_dir%/../Elasticsearch/Model' + elasticsearch: + hosts: '%env(resolve:ELASTICSEARCH_URL)%' + client: opensearch + +services: + test.api_platform.elasticsearch.client: + parent: api_platform.elasticsearch.client + public: true + + ApiPlatform\Tests\Behat\ElasticsearchContext: + public: true + arguments: + $client: '@test.api_platform.elasticsearch.client' + $elasticsearchMappingsPath: '%kernel.project_dir%/../Elasticsearch/Mappings/' + $elasticsearchFixturesPath: '%kernel.project_dir%/../Elasticsearch/Fixtures/' diff --git a/tests/Fixtures/app/config/routing_opensearch.yml b/tests/Fixtures/app/config/routing_opensearch.yml new file mode 100644 index 00000000000..eae54ec1b02 --- /dev/null +++ b/tests/Fixtures/app/config/routing_opensearch.yml @@ -0,0 +1,2 @@ +_main: + resource: routing.yml diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPassTest.php index 70e954540ee..12ced7b3662 100644 --- a/tests/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPassTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPassTest.php @@ -68,6 +68,7 @@ public function testProcess(): void $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); $containerBuilderProphecy->getParameter('api_platform.elasticsearch.enabled')->willReturn(true)->shouldBeCalled(); + $containerBuilderProphecy->getParameter('api_platform.elasticsearch.client')->willReturn('elasticsearch')->shouldBeCalled(); $containerBuilderProphecy->getParameter('api_platform.elasticsearch.hosts')->willReturn(['http://localhost:9200'])->shouldBeCalled(); $containerBuilderProphecy->getParameter('api_platform.elasticsearch.ssl_ca_bundle')->willReturn(null)->shouldBeCalled(); $containerBuilderProphecy->getParameter('api_platform.elasticsearch.ssl_verification')->willReturn(true)->shouldBeCalled(); @@ -77,6 +78,53 @@ public function testProcess(): void (new ElasticsearchClientPass())->process($containerBuilderProphecy->reveal()); } + public function testProcessWithOpenSearchClient(): void + { + if (!class_exists(\OpenSearch\ClientBuilder::class)) { + self::markTestSkipped('opensearch-project/opensearch-php is not installed.'); + } + + $clientDefinition = $this->createMock(Definition::class); + $clientDefinition->expects($this->once()) + ->method('setFactory') + ->with([\OpenSearch\ClientBuilder::class, 'fromConfig']) + ->willReturnSelf(); + + $clientDefinition->expects($this->once()) + ->method('setArguments') + ->with($this->callback(static function ($arguments) { + $config = $arguments[0]; + + return isset($config['hosts']) + && $config['hosts'] === ['http://localhost:9200'] + && isset($config['logger']) + && $config['logger'] instanceof Reference; + })) + ->willReturnSelf(); + + $containerBuilder = $this->createMock(ContainerBuilder::class); + $containerBuilder->method('getParameter') + ->willReturnMap([ + ['api_platform.elasticsearch.enabled', true], + ['api_platform.elasticsearch.client', 'opensearch'], + ['api_platform.elasticsearch.hosts', ['http://localhost:9200']], + ['api_platform.elasticsearch.ssl_ca_bundle', null], + ['api_platform.elasticsearch.ssl_verification', true], + ]); + + $containerBuilder->expects($this->once()) + ->method('has') + ->with('logger') + ->willReturn(true); + + $containerBuilder->expects($this->once()) + ->method('getDefinition') + ->with('api_platform.elasticsearch.client') + ->willReturn($clientDefinition); + + (new ElasticsearchClientPass())->process($containerBuilder); + } + public function testProcessWithoutConfiguration(): void { $clientBuilder = class_exists(\Elasticsearch\ClientBuilder::class) @@ -90,6 +138,7 @@ public function testProcessWithoutConfiguration(): void $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); $containerBuilderProphecy->getParameter('api_platform.elasticsearch.enabled')->willReturn(true)->shouldBeCalled(); + $containerBuilderProphecy->getParameter('api_platform.elasticsearch.client')->willReturn('elasticsearch')->shouldBeCalled(); $containerBuilderProphecy->getParameter('api_platform.elasticsearch.hosts')->willReturn([])->shouldBeCalled(); $containerBuilderProphecy->getParameter('api_platform.elasticsearch.ssl_ca_bundle')->willReturn(null)->shouldBeCalled(); $containerBuilderProphecy->getParameter('api_platform.elasticsearch.ssl_verification')->willReturn(true)->shouldBeCalled(); @@ -139,6 +188,7 @@ public function testProcessWithSslCaBundle(): void $containerBuilder->method('getParameter') ->willReturnMap([ ['api_platform.elasticsearch.enabled', true], + ['api_platform.elasticsearch.client', 'elasticsearch'], ['api_platform.elasticsearch.hosts', ['https://localhost:9200']], ['api_platform.elasticsearch.ssl_ca_bundle', '/path/to/ca-bundle.crt'], ['api_platform.elasticsearch.ssl_verification', true], @@ -187,6 +237,7 @@ public function testProcessWithSslVerificationDisabled(): void $containerBuilder->method('getParameter') ->willReturnMap([ ['api_platform.elasticsearch.enabled', true], + ['api_platform.elasticsearch.client', 'elasticsearch'], ['api_platform.elasticsearch.hosts', ['https://localhost:9200']], ['api_platform.elasticsearch.ssl_ca_bundle', null], ['api_platform.elasticsearch.ssl_verification', false], diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 52b3d33f3fa..def95beb140 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -141,6 +141,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'hosts' => [], 'ssl_ca_bundle' => null, 'ssl_verification' => true, + 'client' => 'elasticsearch', ], 'oauth' => [ 'enabled' => false, @@ -489,6 +490,25 @@ public function testElasticsearchSslVerificationDisabled(): void $this->assertNull($config['elasticsearch']['ssl_ca_bundle']); } + public function testElasticsearchOpenSearchClientRequiresPackage(): void + { + if (class_exists(\OpenSearch\Client::class)) { + self::markTestSkipped('opensearch-project/opensearch-php is installed.'); + } + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Setting api_platform.elasticsearch.client to "opensearch" requires the opensearch-project/opensearch-php package.'); + + $this->processor->processConfiguration($this->configuration, [ + 'api_platform' => [ + 'elasticsearch' => [ + 'enabled' => true, + 'client' => 'opensearch', + ], + ], + ]); + } + public function testElasticsearchSslCaBundleAndVerificationDisabledMutuallyExclusive(): void { $this->expectException(InvalidConfigurationException::class);