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

api-platform / core / 14648308077

24 Apr 2025 05:54PM UTC coverage: 7.021%. First build
14648308077

Pull #6904

github

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

55 of 188 new or added lines in 9 files covered. (29.26%)

11165 of 159026 relevant lines covered (7.02%)

6.23 hits per line

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

45.21
/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);
456✔
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) : [];
404✔
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']) &&  $context['graphql_operation_name'] === 'mercure_subscription' && 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 && $context['graphql_operation_name'] === 'mercure_subscription' && $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
            // Handle pagination if it's enabled in the query
NEW
141
            $data = $this->addPagination($attributeValue, $data, $context);
×
NEW
142
            return $data;
×
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
        }
NEW
166
        return $data;
×
167
    }
168

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

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

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

189
        return $allowedAttributes;
20✔
190
    }
191

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

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

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

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

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