• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 14640911521

24 Apr 2025 11:52AM UTC coverage: 8.525% (+0.3%) from 8.252%
14640911521

Pull #6904

github

web-flow
Merge cc46e696b into a3e5e53ea
Pull Request #6904: feat(graphql): added support for graphql subscriptions to work for actions

110 of 184 new or added lines in 7 files covered. (59.78%)

2 existing lines in 2 files now uncovered.

13133 of 154056 relevant lines covered (8.52%)

14.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.1
/src/GraphQl/Serializer/ItemNormalizer.php
1
<?php
2

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

12
declare(strict_types=1);
13

14
namespace ApiPlatform\GraphQl\Serializer;
15

16
use ApiPlatform\GraphQl\State\Provider\NoopProvider;
17
use ApiPlatform\Metadata\ApiProperty;
18
use ApiPlatform\Metadata\GraphQl\Query;
19
use ApiPlatform\Metadata\GraphQl\QueryCollection;
20
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
21
use ApiPlatform\Metadata\IriConverterInterface;
22
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
23
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
24
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
26
use ApiPlatform\Metadata\ResourceClassResolverInterface;
27
use ApiPlatform\Metadata\Util\ClassInfoTrait;
28
use ApiPlatform\Serializer\CacheKeyTrait;
29
use ApiPlatform\Serializer\ItemNormalizer as BaseItemNormalizer;
30
use Doctrine\Common\Collections\Collection;
31
use Psr\Log\LoggerInterface;
32
use Psr\Log\NullLogger;
33
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
34
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
35
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
36
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
37

38
/**
39
 * GraphQL normalizer.
40
 *
41
 * @author Kévin Dunglas <dunglas@gmail.com>
42
 */
43
final class ItemNormalizer extends BaseItemNormalizer
44
{
45
    use CacheKeyTrait;
46
    use ClassInfoTrait;
47

48
    public const FORMAT = 'graphql';
49
    public const ITEM_RESOURCE_CLASS_KEY = '#itemResourceClass';
50
    public const ITEM_IDENTIFIERS_KEY = '#itemIdentifiers';
51

52
    private array $safeCacheKeysCache = [];
53

54
    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, private readonly IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null)
55
    {
56
        parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $logger ?: new NullLogger(), $resourceMetadataCollectionFactory, $resourceAccessChecker);
1,160✔
57
    }
58

59
    /**
60
     * {@inheritdoc}
61
     */
62
    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
63
    {
64
        return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context);
114✔
65
    }
66

67
    public function getSupportedTypes($format): array
68
    {
69
        return self::FORMAT === $format ? parent::getSupportedTypes($format) : [];
1,041✔
70
    }
71

72
    /**
73
     * {@inheritdoc}
74
     *
75
     * @param array<string, mixed> $context
76
     *
77
     * @throws UnexpectedValueException
78
     */
79
    public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
80
    {
81
        $resourceClass = $this->getObjectClass($object);
114✔
82

83
        if ($this->getOutputClass($context)) {
114✔
84
            $context['graphql_identifiers'] = [
3✔
85
                self::ITEM_RESOURCE_CLASS_KEY => $context['operation']->getClass(),
3✔
86
                self::ITEM_IDENTIFIERS_KEY => $this->identifiersExtractor->getIdentifiersFromItem($object, $context['operation'] ?? null),
3✔
87
            ];
3✔
88

89
            return parent::normalize($object, $format, $context);
3✔
90
        }
91

92
        if ($this->isCacheKeySafe($context)) {
114✔
93
            $context['cache_key'] = $this->getCacheKey($format, $context);
98✔
94
        } else {
95
            $context['cache_key'] = false;
26✔
96
        }
97

98
        unset($context['operation_name'], $context['operation']); // Remove operation and operation_name only when cache key has been created
114✔
99
        $data = parent::normalize($object, $format, $context);
114✔
100
        if (!\is_array($data)) {
114✔
101
            throw new UnexpectedValueException('Expected data to be an array.');
×
102
        }
103

104
        if (isset($context['graphql_identifiers'])) {
114✔
105
            $data += $context['graphql_identifiers'];
3✔
106
        } elseif (!($context['no_resolver_data'] ?? false)) {
112✔
107
            $data[self::ITEM_RESOURCE_CLASS_KEY] = $resourceClass;
111✔
108
            $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($object, $context['operation'] ?? null);
111✔
109
        }
110

111
        return $data;
114✔
112
    }
113

114
    /**
115
     * {@inheritdoc}
116
     */
117
    protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
118
    {
119
        // check for nested collection
120
        $operation = $this->resourceMetadataCollectionFactory?->create($resourceClass)->getOperation(forceCollection: true, forceGraphQl: true);
20✔
121
        if ($operation instanceof Query && $operation->getNested() && !$operation->getResolver() && (!$operation->getProvider() || NoopProvider::class === $operation->getProvider())) {
20✔
122
            return [...$attributeValue];
2✔
123
        }
124

125
        // Handle relationships for mercure subscriptions
126
        if ($operation instanceof QueryCollection && $context['graphql_operation_name'] === 'mercure_subscription' && $attributeValue instanceof Collection && !$attributeValue->isEmpty()) {
20✔
NEW
127
            $relationContext = $context;
×
128
            // Grab collection attributes
NEW
129
            $relationContext['attributes'] = $context['attributes']['collection'];
×
130
            // Iterate over the collection and normalize each item
NEW
131
            $data['collection'] =  $attributeValue
×
NEW
132
                ->map(
×
NEW
133
                    function($item) use ($format, $relationContext) {
×
134
                        // Convert collection entity/item to array
NEW
135
                        $normalized = $this->normalize($item, $format, $relationContext);
×
136
                        // Add IRI to the normalized data to match GraphQL responses for ApiPlatform
NEW
137
                        if (isset($normalized['id'])) {
×
NEW
138
                            $normalized['_id'] = $normalized['id'];
×
NEW
139
                            $normalized['id'] = $this->iriConverter->getIriFromResource($item);
×
140
                        }
NEW
141
                        return $normalized;
×
NEW
142
                    }
×
NEW
143
                )
×
NEW
144
                // Convert the collection to an array
×
NEW
145
                ->toArray();
×
146
            // Handle pagination if it's enabled in the query
NEW
147
            $data = $this->addPagination($attributeValue, $data, $context);
×
NEW
148
            return $data;
×
149
        }
150

151
        // to-many are handled directly by the GraphQL resolver
152
        return [];
20✔
153
    }
154

155
    private function addPagination(Collection $collection, array $data, array $context): array
156
    {
NEW
157
        if ($context['attributes']['paginationInfo'] ?? false) {
×
NEW
158
            $data['paginationInfo'] = [];
×
NEW
159
            if (array_key_exists('hasNextPage', $context['attributes']['paginationInfo'])) {
×
NEW
160
                $data['paginationInfo']['hasNextPage'] = $collection->count() > ($context['pagination']['itemsPerPage'] ?? 10);
×
161
            }
NEW
162
            if (array_key_exists('itemsPerPage', $context['attributes']['paginationInfo'])) {
×
NEW
163
                $data['paginationInfo']['itemsPerPage'] = $context['pagination']['itemsPerPage'] ?? 10;
×
164
            }
NEW
165
            if (array_key_exists('lastPage', $context['attributes']['paginationInfo'])) {
×
NEW
166
                $data['paginationInfo']['lastPage'] = (int) ceil($collection->count() / ($context['pagination']['itemsPerPage'] ?? 10));
×
167
            }
NEW
168
            if (array_key_exists('totalCount', $context['attributes']['paginationInfo'])) {
×
NEW
169
                $data['paginationInfo']['totalCount'] = $collection->count();
×
170
            }
171
        }
NEW
172
        return $data;
×
173
    }
174

175
    /**
176
     * {@inheritdoc}
177
     */
178
    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
179
    {
180
        return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context);
29✔
181
    }
182

183
    /**
184
     * {@inheritdoc}
185
     */
186
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
187
    {
188
        $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
117✔
189

190
        if (($context['api_denormalize'] ?? false) && \is_array($allowedAttributes) && false !== ($indexId = array_search('id', $allowedAttributes, true))) {
117✔
191
            $allowedAttributes[] = '_id';
3✔
192
            array_splice($allowedAttributes, (int) $indexId, 1);
3✔
193
        }
194

195
        return $allowedAttributes;
117✔
196
    }
197

198
    /**
199
     * {@inheritdoc}
200
     */
201
    protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = []): void
202
    {
203
        if ('_id' === $attribute) {
23✔
204
            $attribute = 'id';
1✔
205
        }
206

207
        parent::setAttributeValue($object, $attribute, $value, $format, $context);
23✔
208
    }
209

210
    /**
211
     * Check if any property contains a security grants, which makes the cache key not safe,
212
     * as allowed_properties can differ for 2 instances of the same object.
213
     */
214
    private function isCacheKeySafe(array $context): bool
215
    {
216
        if (!isset($context['resource_class']) || !$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
114✔
217
            return false;
4✔
218
        }
219
        $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']);
112✔
220
        if (isset($this->safeCacheKeysCache[$resourceClass])) {
112✔
221
            return $this->safeCacheKeysCache[$resourceClass];
37✔
222
        }
223
        $options = $this->getFactoryOptions($context);
112✔
224
        $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options);
112✔
225

226
        $this->safeCacheKeysCache[$resourceClass] = true;
112✔
227
        foreach ($propertyNames as $propertyName) {
112✔
228
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
112✔
229
            if (null !== $propertyMetadata->getSecurity()) {
112✔
230
                $this->safeCacheKeysCache[$resourceClass] = false;
22✔
231
                break;
22✔
232
            }
233
        }
234

235
        return $this->safeCacheKeysCache[$resourceClass];
112✔
236
    }
237
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc