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

api-platform / core / 15904482964

26 Jun 2025 02:22PM UTC coverage: 21.957%. First build
15904482964

Pull #6904

github

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

55 of 252 new or added lines in 9 files covered. (21.83%)

11494 of 52347 relevant lines covered (21.96%)

21.6 hits per line

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

45.83
/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);
512✔
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);
20✔
65
    }
66

67
    public function getSupportedTypes($format): array
68
    {
69
        return self::FORMAT === $format ? parent::getSupportedTypes($format) : [];
456✔
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);
20✔
82

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

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

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

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

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

111
        if (isset($context['graphql_operation_name']) && 'mercure_subscription' === $context['graphql_operation_name'] && \is_object($object) && isset($data['id']) && !isset($data['_id'])) {
20✔
NEW
112
            $data['_id'] = $data['id'];
×
NEW
113
            $data['id'] = $this->iriConverter->getIriFromResource($object);
×
114
        }
115

116
        return $data;
20✔
117
    }
118

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

130
        // Handle relationships for mercure subscriptions
NEW
131
        if ($operation instanceof QueryCollection && 'mercure_subscription' === $context['graphql_operation_name'] && $attributeValue instanceof Collection && !$attributeValue->isEmpty()) {
×
NEW
132
            $relationContext = $context;
×
133
            // Grab collection attributes
NEW
134
            $relationContext['attributes'] = $context['attributes']['collection'];
×
135
            // Iterate over the collection and normalize each item
NEW
136
            $data['collection'] = $attributeValue
×
NEW
137
                ->map(fn ($item) => $this->normalize($item, $format, $relationContext))
×
NEW
138
                // Convert the collection to an array
×
NEW
139
                ->toArray();
×
140

141
            // Handle pagination if it's enabled in the query
NEW
142
            return $this->addPagination($attributeValue, $data, $context);
×
143
        }
144

145
        // to-many are handled directly by the GraphQL resolver
146
        return [];
×
147
    }
148

149
    private function addPagination(Collection $collection, array $data, array $context): array
150
    {
NEW
151
        if ($context['attributes']['paginationInfo'] ?? false) {
×
NEW
152
            $data['paginationInfo'] = [];
×
NEW
153
            if (\array_key_exists('hasNextPage', $context['attributes']['paginationInfo'])) {
×
NEW
154
                $data['paginationInfo']['hasNextPage'] = $collection->count() > ($context['pagination']['itemsPerPage'] ?? 10);
×
155
            }
NEW
156
            if (\array_key_exists('itemsPerPage', $context['attributes']['paginationInfo'])) {
×
NEW
157
                $data['paginationInfo']['itemsPerPage'] = $context['pagination']['itemsPerPage'] ?? 10;
×
158
            }
NEW
159
            if (\array_key_exists('lastPage', $context['attributes']['paginationInfo'])) {
×
NEW
160
                $data['paginationInfo']['lastPage'] = (int) ceil($collection->count() / ($context['pagination']['itemsPerPage'] ?? 10));
×
161
            }
NEW
162
            if (\array_key_exists('totalCount', $context['attributes']['paginationInfo'])) {
×
NEW
163
                $data['paginationInfo']['totalCount'] = $collection->count();
×
164
            }
165
        }
166

NEW
167
        return $data;
×
168
    }
169

170
    /**
171
     * {@inheritdoc}
172
     */
173
    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
174
    {
175
        return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context);
×
176
    }
177

178
    /**
179
     * {@inheritdoc}
180
     */
181
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
182
    {
183
        $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
20✔
184

185
        if (($context['api_denormalize'] ?? false) && \is_array($allowedAttributes) && false !== ($indexId = array_search('id', $allowedAttributes, true))) {
20✔
186
            $allowedAttributes[] = '_id';
×
187
            array_splice($allowedAttributes, (int) $indexId, 1);
×
188
        }
189

190
        return $allowedAttributes;
20✔
191
    }
192

193
    /**
194
     * {@inheritdoc}
195
     */
196
    protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = []): void
197
    {
198
        if ('_id' === $attribute) {
×
199
            $attribute = 'id';
×
200
        }
201

202
        parent::setAttributeValue($object, $attribute, $value, $format, $context);
×
203
    }
204

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

221
        $this->safeCacheKeysCache[$resourceClass] = true;
20✔
222
        foreach ($propertyNames as $propertyName) {
20✔
223
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
20✔
224
            if (null !== $propertyMetadata->getSecurity()) {
20✔
225
                $this->safeCacheKeysCache[$resourceClass] = false;
2✔
226
                break;
2✔
227
            }
228
        }
229

230
        return $this->safeCacheKeysCache[$resourceClass];
20✔
231
    }
232
}
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