Skip to content

[GraphQL] Support serialized name #3516

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 <[email protected]>
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
Loading