Skip to content

Commit

Permalink
[GraphQL] Support serialized name (#3516)
Browse files Browse the repository at this point in the history
  • Loading branch information
alanpoulain committed Apr 22, 2020
1 parent 7e69666 commit 8d7290e
Show file tree
Hide file tree
Showing 10 changed files with 168 additions and 33 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -8,6 +8,7 @@
* GraphQL: Allow to format GraphQL errors based on exceptions (#3063)
* GraphQL: Add page-based pagination (#3175)
* GraphQL: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514)
* GraphQL: Support for field name conversion (serialized name) (#3455, #3516)
* OpenAPI: Add PHP default values to the documentation (#2386)
* Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346)

Expand Down
3 changes: 2 additions & 1 deletion features/doctrine/search_filter.feature
Expand Up @@ -65,7 +65,8 @@ Feature: Search filter on collections
"prop": "blue"
}
],
"uuid": []
"uuid": [],
"carBrand": "DummyBrand"
}
],
"hydra:totalItems": 1,
Expand Down
12 changes: 12 additions & 0 deletions features/graphql/query.feature
Expand Up @@ -126,6 +126,18 @@ Feature: GraphQL query support
And the header "Content-Type" should be equal to "application/json"
And the JSON node "data.dummyGroup.foo" should be equal to "Foo #1"

Scenario: Query a serialized name
Given there is a DummyCar entity with related colors
When I send the following GraphQL request:
"""
{
dummyCar(id: "/dummy_cars/1") {
carBrand
}
}
"""
Then the JSON node "data.dummyCar.carBrand" should be equal to "DummyBrand"

Scenario: Fetch only the internal id
When I send the following GraphQL request:
"""
Expand Down
36 changes: 22 additions & 14 deletions src/GraphQl/Serializer/SerializerContextBuilder.php
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use GraphQL\Type\Definition\ResolveInfo;
use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

/**
Expand Down Expand Up @@ -45,10 +46,6 @@ public function create(?string $resourceClass, string $operationName, array $res
'graphql_operation_name' => $operationName,
];

if ($normalization) {
$context['attributes'] = $this->fieldsToAttributes($resourceMetadata, $resolverContext);
}

if (isset($resolverContext['fields'])) {
$context['no_resolver_data'] = true;
}
Expand All @@ -61,25 +58,29 @@ public function create(?string $resourceClass, string $operationName, array $res
$context = array_merge($resourceMetadata->getGraphqlAttribute($operationName, $key, [], true), $context);
}

if ($normalization) {
$context['attributes'] = $this->fieldsToAttributes($resourceClass, $resourceMetadata, $resolverContext, $context);
}

return $context;
}

/**
* Retrieves fields, recursively replaces the "_id" key (the raw id) by "id" (the name of the property expected by the Serializer) and flattens edge and node structures (pagination).
*/
private function fieldsToAttributes(?ResourceMetadata $resourceMetadata, array $context): array
private function fieldsToAttributes(?string $resourceClass, ?ResourceMetadata $resourceMetadata, array $resolverContext, array $context): array
{
if (isset($context['fields'])) {
$fields = $context['fields'];
if (isset($resolverContext['fields'])) {
$fields = $resolverContext['fields'];
} else {
/** @var ResolveInfo $info */
$info = $context['info'];
$info = $resolverContext['info'];
$fields = $info->getFieldSelection(PHP_INT_MAX);
}

$attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields);
$attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields, $resourceClass, $context);

if ($context['is_mutation'] || $context['is_subscription']) {
if ($resolverContext['is_mutation'] || $resolverContext['is_subscription']) {
if (!$resourceMetadata) {
throw new \LogicException('ResourceMetadata should always exist for a mutation or a subscription.');
}
Expand All @@ -92,7 +93,7 @@ private function fieldsToAttributes(?ResourceMetadata $resourceMetadata, array $
return $attributes;
}

private function replaceIdKeys(array $fields): array
private function replaceIdKeys(array $fields, ?string $resourceClass, array $context): array
{
$denormalizedFields = [];

Expand All @@ -103,14 +104,21 @@ private function replaceIdKeys(array $fields): array
continue;
}

$denormalizedFields[$this->denormalizePropertyName((string) $key)] = \is_array($fields[$key]) ? $this->replaceIdKeys($fields[$key]) : $value;
$denormalizedFields[$this->denormalizePropertyName((string) $key, $resourceClass, $context)] = \is_array($fields[$key]) ? $this->replaceIdKeys($fields[$key], $resourceClass, $context) : $value;
}

return $denormalizedFields;
}

private function denormalizePropertyName(string $property): string
private function denormalizePropertyName(string $property, ?string $resourceClass, array $context): string
{
return null !== $this->nameConverter ? $this->nameConverter->denormalize($property) : $property;
if (null === $this->nameConverter) {
return $property;
}
if ($this->nameConverter instanceof AdvancedNameConverterInterface) {
return $this->nameConverter->denormalize($property, $resourceClass, null, $context);
}

return $this->nameConverter->denormalize($property);
}
}
10 changes: 9 additions & 1 deletion src/GraphQl/Type/FieldsBuilder.php
Expand Up @@ -29,6 +29,7 @@
use Psr\Container\ContainerInterface;
use Symfony\Component\Config\Definition\Exception\InvalidTypeException;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

/**
Expand Down Expand Up @@ -497,6 +498,13 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin

private function normalizePropertyName(string $property, string $resourceClass): string
{
return null !== $this->nameConverter ? $this->nameConverter->normalize($property, $resourceClass) : $property;
if (null === $this->nameConverter) {
return $property;
}
if ($this->nameConverter instanceof AdvancedNameConverterInterface) {
return $this->nameConverter->normalize($property, $resourceClass);
}

return $this->nameConverter->normalize($property);
}
}
20 changes: 20 additions & 0 deletions tests/Fixtures/TestBundle/Document/DummyCar.php
Expand Up @@ -110,6 +110,16 @@ class DummyCar
*/
private $availableAt;

/**
* @var string
*
* @Serializer\Groups({"colors"})
* @Serializer\SerializedName("carBrand")
*
* @ODM\Field
*/
private $brand = 'DummyBrand';

public function __construct()
{
$this->colors = new ArrayCollection();
Expand Down Expand Up @@ -191,4 +201,14 @@ public function setAvailableAt(\DateTime $availableAt)
{
$this->availableAt = $availableAt;
}

public function getBrand(): string
{
return $this->brand;
}

public function setBrand(string $brand): void
{
$this->brand = $brand;
}
}
20 changes: 20 additions & 0 deletions tests/Fixtures/TestBundle/Entity/DummyCar.php
Expand Up @@ -115,6 +115,16 @@ class DummyCar
*/
private $availableAt;

/**
* @var string
*
* @Serializer\Groups({"colors"})
* @Serializer\SerializedName("carBrand")
*
* @ORM\Column
*/
private $brand = 'DummyBrand';

public function __construct()
{
$this->colors = new ArrayCollection();
Expand Down Expand Up @@ -199,4 +209,14 @@ public function setAvailableAt(\DateTime $availableAt)
{
$this->availableAt = $availableAt;
}

public function getBrand(): string
{
return $this->brand;
}

public function setBrand(string $brand): void
{
$this->brand = $brand;
}
}
59 changes: 46 additions & 13 deletions tests/GraphQl/Serializer/SerializerContextBuilderTest.php
Expand Up @@ -19,6 +19,8 @@
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter;
use GraphQL\Type\Definition\ResolveInfo;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface;

/**
* @author Alan Poulain <contact@alanpoulain.eu>
Expand All @@ -36,16 +38,18 @@ protected function setUp(): void
{
$this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);

$this->serializerContextBuilder = new SerializerContextBuilder(
$this->resourceMetadataFactoryProphecy->reveal(),
new CustomConverter()
);
$this->serializerContextBuilder = $this->buildSerializerContextBuilder();
}

private function buildSerializerContextBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): SerializerContextBuilder
{
return new SerializerContextBuilder($this->resourceMetadataFactoryProphecy->reveal(), $advancedNameConverter ?? new CustomConverter());
}

/**
* @dataProvider createNormalizationContextProvider
*/
public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, bool $isMutation, bool $isSubscription, bool $noInfo, array $expectedContext, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void
public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, bool $isMutation, bool $isSubscription, bool $noInfo, array $expectedContext, ?AdvancedNameConverterInterface $advancedNameConverter = null, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void
{
$resolverContext = [
'is_mutation' => $isMutation,
Expand Down Expand Up @@ -76,13 +80,21 @@ public function testCreateNormalizationContext(?string $resourceClass, string $o
$this->expectExceptionMessage($expectedExceptionMessage);
}

$context = $this->serializerContextBuilder->create($resourceClass, $operationName, $resolverContext, true);
$serializerContextBuilder = $this->serializerContextBuilder;
if ($advancedNameConverter) {
$serializerContextBuilder = $this->buildSerializerContextBuilder($advancedNameConverter);
}

$context = $serializerContextBuilder->create($resourceClass, $operationName, $resolverContext, true);

$this->assertSame($expectedContext, $context);
}

public function createNormalizationContextProvider(): array
{
$advancedNameConverter = $this->prophesize(AdvancedNameConverterInterface::class);
$advancedNameConverter->denormalize('field', 'myResource', null, Argument::type('array'))->willReturn('denormalizedField');

return [
'nominal' => [
$resourceClass = 'myResource',
Expand All @@ -95,13 +107,33 @@ public function createNormalizationContextProvider(): array
'groups' => ['normalization_group'],
'resource_class' => $resourceClass,
'graphql_operation_name' => $operationName,
'input' => ['class' => 'inputClass'],
'output' => ['class' => 'outputClass'],
'attributes' => [
'id' => 3,
'field' => 'foo',
],
],
],
'nominal with advanced name converter' => [
$resourceClass = 'myResource',
$operationName = 'item_query',
['_id' => 3, 'field' => 'foo'],
false,
false,
false,
[
'groups' => ['normalization_group'],
'resource_class' => $resourceClass,
'graphql_operation_name' => $operationName,
'input' => ['class' => 'inputClass'],
'output' => ['class' => 'outputClass'],
'attributes' => [
'id' => 3,
'denormalizedField' => 'foo',
],
],
$advancedNameConverter->reveal(),
],
'nominal collection' => [
$resourceClass = 'myResource',
Expand All @@ -114,11 +146,11 @@ public function createNormalizationContextProvider(): array
'groups' => ['normalization_group'],
'resource_class' => $resourceClass,
'graphql_operation_name' => $operationName,
'input' => ['class' => 'inputClass'],
'output' => ['class' => 'outputClass'],
'attributes' => [
'nodeField' => 'baz',
],
'input' => ['class' => 'inputClass'],
'output' => ['class' => 'outputClass'],
],
],
'no resource class' => [
Expand Down Expand Up @@ -147,12 +179,12 @@ public function createNormalizationContextProvider(): array
'groups' => ['normalization_group'],
'resource_class' => $resourceClass,
'graphql_operation_name' => $operationName,
'input' => ['class' => 'inputClass'],
'output' => ['class' => 'outputClass'],
'attributes' => [
'id' => 7,
'related' => ['field' => 'bar'],
],
'input' => ['class' => 'inputClass'],
'output' => ['class' => 'outputClass'],
],
],
'mutation without resource class' => [
Expand All @@ -163,6 +195,7 @@ public function createNormalizationContextProvider(): array
false,
false,
[],
null,
\LogicException::class,
'ResourceMetadata should always exist for a mutation or a subscription.',
],
Expand All @@ -177,13 +210,13 @@ public function createNormalizationContextProvider(): array
'groups' => ['normalization_group'],
'resource_class' => $resourceClass,
'graphql_operation_name' => $operationName,
'no_resolver_data' => true,
'input' => ['class' => 'inputClass'],
'output' => ['class' => 'outputClass'],
'attributes' => [
'id' => 7,
'related' => ['field' => 'bar'],
],
'no_resolver_data' => true,
'input' => ['class' => 'inputClass'],
'output' => ['class' => 'outputClass'],
],
],
];
Expand Down

0 comments on commit 8d7290e

Please sign in to comment.