Skip to content

Commit

Permalink
GraphQL: Fix GraphQL fetching with Elasticsearch
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka authored and vincentchalamon committed Apr 20, 2021
1 parent 8d4891a commit 94ac305
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@
* Filter validation: Fix issue in Required filter validator with dot notation (#4221)
* OpenAPI: Fix notice/warning for `response` without `content` in the `openapi_context` (#4210)
* Serializer: Convert internal error to HTTP 400 in Ramsey uuid denormalization from invalid body string (#4200)
* GraphQL: Fix graphql fetching with Elasticsearch (#4217)

## 2.6.4

Expand Down
1 change: 1 addition & 0 deletions behat.yml.dist
Expand Up @@ -84,6 +84,7 @@ elasticsearch:
contexts:
- 'ApiPlatform\Core\Tests\Behat\CommandContext'
- 'ApiPlatform\Core\Tests\Behat\ElasticsearchContext'
- 'ApiPlatform\Core\Tests\Behat\GraphqlContext'
- 'ApiPlatform\Core\Tests\Behat\JsonContext'
- 'Behat\MinkExtension\Context\MinkContext'
- 'behatch:context:rest'
Expand Down
100 changes: 100 additions & 0 deletions features/elasticsearch/graphql.feature
@@ -0,0 +1,100 @@
Feature: GraphQL query support

@elasticsearch
Scenario: Execute a GraphQL query on an ElasticSearch model with SubResources
When I send the following GraphQL request:
"""
query {
users {
edges {
node {
id,
gender,
tweets {
edges {
node {
id,
message,
}
}
}
}
}
}
}
"""
Then print last response
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON should be equal to:
"""
{
"data": {
"users": {
"edges": [
{
"node": {
"id": "/users/116b83f8-6c32-48d8-8e28-c5c247532d3f",
"gender": "male",
"tweets": {
"edges": [
{
"node": {
"id": "/tweets/89601e1c-3ef2-4ef7-bca2-7511d38611c6",
"message": "Great day in any endur... During the Himalayas were very talented skimo racer junior podiums, top 10."
}
},
{
"node": {
"id": "/tweets/9da70727-d656-42d9-876a-1be6321f171b",
"message": "During the path and his Summits Of My Life project. Next Wednesday, Kilian Jornet..."
}
},
{
"node": {
"id": "/tweets/f36a0026-0635-4865-86a6-5adb21d94d64",
"message": "The north summit, Store Vengetind Thanks for t... These Top 10 Women of a fk... Francois is the field which."
}
}
]
}
}
},
{
"node": {
"id": "/users/15fce6f1-18fd-4ef6-acab-7e6a3333ec7f",
"gender": "male",
"tweets": {
"edges": [
{
"node": {
"id": "/tweets/0cfe3d33-6116-416b-8c50-3b8319331998",
"message": "Thanks! Fun day with Next up one of our 2018 cover: One look into what races we'll be running that they!"
}
}
]
}
}
},
{
"node": {
"id": "/users/6a457188-d1ba-45e3-8509-81e5c66a5297",
"gender": "female",
"tweets": {
"edges": [
{
"node": {
"id": "/tweets/9de3308c-6f82-4a57-a33c-4e3cd5d5a3f6",
"message": "In case you do! A humble beginning to traverse... Im so now until you can't tell how strong she run at!"
}
}
]
}
}
}
]
}
}
}
"""
141 changes: 141 additions & 0 deletions src/Bridge/Elasticsearch/DataProvider/SubresourceDataProvider.php
@@ -0,0 +1,141 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Core\Bridge\Elasticsearch\DataProvider;

use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface;
use ApiPlatform\Core\Bridge\Elasticsearch\Exception\IndexNotFoundException;
use ApiPlatform\Core\Bridge\Elasticsearch\Exception\NonUniqueIdentifierException;
use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface;
use ApiPlatform\Core\DataProvider\Pagination;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use Elasticsearch\Client;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

final class SubresourceDataProvider implements SubresourceDataProviderInterface, RestrictedDataProviderInterface
{
private $client;
private $documentMetadataFactory;
private $identifierExtractor;
private $denormalizer;
private $pagination;
private $resourceMetadataFactory;
private $propertyNameCollectionFactory;
private $propertyMetadataFactory;
private $collectionExtensions;

public function __construct(Client $client, DocumentMetadataFactoryInterface $documentMetadataFactory, IdentifierExtractorInterface $identifierExtractor, DenormalizerInterface $denormalizer, Pagination $pagination, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [])
{
$this->client = $client;
$this->documentMetadataFactory = $documentMetadataFactory;
$this->identifierExtractor = $identifierExtractor;
$this->denormalizer = $denormalizer;
$this->pagination = $pagination;
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
$this->propertyMetadataFactory = $propertyMetadataFactory;
$this->collectionExtensions = $collectionExtensions;
}

/**
* {@inheritdoc}
*/
public function supports(string $resourceClass, ?string $operationName = null, array $context = []): bool
{
try {
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
if (false === $resourceMetadata->getCollectionOperationAttribute($operationName, 'elasticsearch', true, true)) {
return false;
}
} catch (ResourceClassNotFoundException $e) {
return false;
}

try {
$this->documentMetadataFactory->create($resourceClass);
} catch (IndexNotFoundException $e) {
return false;
}

// Elasticsearch does not support subresources as items (yet)
if (false === ($context['collection'] ?? false)) {
return false;
}

try {
$this->identifierExtractor->getIdentifierFromResourceClass($resourceClass);
} catch (NonUniqueIdentifierException $e) {
return false;
}

return true;
}

/**
* {@inheritdoc}
*/
public function getSubresource(string $resourceClass, array $identifiers, array $context, string $operationName = null)
{
$documentMetadata = $this->documentMetadataFactory->create($resourceClass);
$body = [];

foreach ($this->collectionExtensions as $collectionExtension) {
$body = $collectionExtension->applyToCollection($body, $resourceClass, $operationName, $context);
}

[$relationClass, $identiferProperty] = $context['identifiers']['id'];

$propertyName = null;

foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) {
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property);
$type = $propertyMetadata->getType();

if ($type && $type->getClassName() === $relationClass) {
$propertyName = $property;
break;
}
}

$relationQuery = ['match' => [$propertyName.'.'.$identiferProperty => $identifiers['id']]];

if (!isset($body['query']) && !isset($body['aggs'])) {
$body['query'] = $relationQuery;
} else {
$body['query']['constant_score']['filter']['bool']['must'][] = $relationQuery;
}

$limit = $body['size'] = $body['size'] ?? $this->pagination->getLimit($resourceClass, $operationName, $context);
$offset = $body['from'] = $body['from'] ?? $this->pagination->getOffset($resourceClass, $operationName, $context);

$documents = $this->client->search([
'index' => $documentMetadata->getIndex(),
'type' => $documentMetadata->getType(),
'body' => $body,
]);

return new Paginator(
$this->denormalizer,
$documents,
$resourceClass,
$limit,
$offset,
$context
);
}
}
14 changes: 14 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml
Expand Up @@ -82,6 +82,20 @@
<tag name="api_platform.collection_data_provider" priority="5" />
</service>

<service id="api_platform.elasticsearch.subresource_data_provider" class="ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\SubresourceDataProvider" public="false">
<argument type="service" id="api_platform.elasticsearch.client" />
<argument type="service" id="api_platform.elasticsearch.metadata.document.metadata_factory" />
<argument type="service" id="api_platform.elasticsearch.identifier_extractor" />
<argument type="service" id="serializer" />
<argument type="service" id="api_platform.pagination" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
<argument type="tagged" tag="api_platform.elasticsearch.request_body_search_extension.collection" />

<tag name="api_platform.subresource_data_provider" priority="5" />
</service>

<service id="api_platform.elasticsearch.request_body_search_extension.filter" public="false" abstract="true">
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="api_platform.filter_locator" />
Expand Down
Expand Up @@ -674,6 +674,7 @@ public function testEnableElasticsearch()
$containerBuilderProphecy->setDefinition('api_platform.elasticsearch.term_filter', Argument::type(Definition::class))->shouldBeCalled();
$containerBuilderProphecy->setDefinition('api_platform.elasticsearch.order_filter', Argument::type(Definition::class))->shouldBeCalled();
$containerBuilderProphecy->setDefinition('api_platform.elasticsearch.match_filter', Argument::type(Definition::class))->shouldBeCalled();
$containerBuilderProphecy->setDefinition('api_platform.elasticsearch.subresource_data_provider', Argument::type(Definition::class))->shouldBeCalled();
$containerBuilderProphecy->setAlias('api_platform.elasticsearch.metadata.document.metadata_factory', 'api_platform.elasticsearch.metadata.document.metadata_factory.configured')->shouldBeCalled();
$containerBuilderProphecy->setAlias(DocumentMetadataFactoryInterface::class, 'api_platform.elasticsearch.metadata.document.metadata_factory')->shouldBeCalled();
$containerBuilderProphecy->setAlias(IdentifierExtractorInterface::class, 'api_platform.elasticsearch.identifier_extractor')->shouldBeCalled();
Expand Down

0 comments on commit 94ac305

Please sign in to comment.