Passed
Push — main ( 04aeb5...9de857 )
by Daniel
04:28
created

PublishableNormalizer::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 8
dl 0
loc 11
ccs 0
cts 2
cp 0
crap 2
rs 10
c 3
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
/*
4
 * This file is part of the Silverback API Components Bundle Project
5
 *
6
 * (c) Daniel West <[email protected]>
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 Silverback\ApiComponentsBundle\Serializer\Normalizer;
15
16
use ApiPlatform\Api\IriConverterInterface;
17
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
18
use ApiPlatform\Validator\ValidatorInterface;
19
use Doctrine\Common\Collections\ArrayCollection;
20
use Doctrine\ORM\Mapping\ClassMetadataInfo;
21
use Doctrine\Persistence\ManagerRegistry;
22
use Doctrine\Persistence\ObjectManager;
23
use Silverback\ApiComponentsBundle\Annotation\Publishable;
24
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
25
use Silverback\ApiComponentsBundle\Event\ResourceChangedEvent;
26
use Silverback\ApiComponentsBundle\Exception\InvalidArgumentException;
27
use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker;
28
use Silverback\ApiComponentsBundle\Helper\Uploadable\UploadableFileManager;
29
use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider;
30
use Silverback\ApiComponentsBundle\Validator\PublishableValidator;
31
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
32
use Symfony\Component\HttpFoundation\RequestStack;
33
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
34
use Symfony\Component\PropertyAccess\PropertyAccess;
35
use Symfony\Component\PropertyAccess\PropertyAccessor;
36
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
37
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
38
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
39
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
40
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
41
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
42
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
43
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
44
45
/**
46
 * Adds `published` property on response, if not set.
47
 *
48
 * @author Vincent Chalamon <[email protected]>
49
 */
50
final class PublishableNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface, DenormalizerInterface, DenormalizerAwareInterface
51
{
52
    use DenormalizerAwareTrait;
53
    use NormalizerAwareTrait;
54
55
    private const ALREADY_CALLED = 'PUBLISHABLE_NORMALIZER_ALREADY_CALLED';
56
    private const ASSOCIATION = 'PUBLISHABLE_ASSOCIATION';
57
58
    private PropertyAccessor $propertyAccessor;
59
60
    public function __construct(
61
        private readonly PublishableStatusChecker $publishableStatusChecker,
62
        private readonly ManagerRegistry $registry,
63
        private readonly RequestStack $requestStack,
64
        private readonly ValidatorInterface $validator,
65
        private readonly IriConverterInterface $iriConverter,
66
        private readonly UploadableFileManager $uploadableFileManager,
67
        private readonly ResourceMetadataProvider $resourceMetadataProvider,
68
        private readonly EventDispatcherInterface $eventDispatcher
69
    ) {
70
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
71
    }
72
73
    public function normalize($object, $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
74
    {
75
        $context[self::ALREADY_CALLED][] = $this->propertyAccessor->getValue($object, 'id');
76
77
        if (isset($context[self::ASSOCIATION]) && $context[self::ASSOCIATION] === $object) {
78
            return $this->iriConverter->getIriFromResource($object);
79
        }
80
81
        $isPublished = $this->publishableStatusChecker->isActivePublishedAt($object);
82
83
        $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object);
84
        $resourceMetadata->setPublishable($isPublished);
85
86
        $type = \get_class($object);
87
        $configuration = $this->publishableStatusChecker->getAttributeReader()->getConfiguration($type);
88
        $em = $this->getManagerFromType($type);
89
        $classMetadata = $this->getClassMetadataInfo($em, $type);
90
91
        $publishedAtDateTime = $classMetadata->getFieldValue($object, $configuration->fieldName);
92
        if ($publishedAtDateTime instanceof \DateTimeInterface) {
93
            $publishedAtDateTime = $publishedAtDateTime->format(\DateTimeInterface::RFC3339_EXTENDED);
94
        }
95
96
        // using static name 'publishedAt' for predictable API and easy metadata object instead of dynamic $configuration->fieldName
97
        if ($publishedAtDateTime) {
98
            $resourceMetadata->setPublishable($isPublished, $publishedAtDateTime);
99
        }
100
101
        if (\is_object($assocObject = $classMetadata->getFieldValue($object, $configuration->associationName))) {
102
            $context[self::ASSOCIATION] = $assocObject;
103
        } elseif (\is_object($reverseAssocObject = $classMetadata->getFieldValue($object, $configuration->reverseAssociationName))) {
104
            $context[self::ASSOCIATION] = $reverseAssocObject;
105
        }
106
107
        // display soft validation violations in the response
108
        if ($this->publishableStatusChecker->isGranted($object)) {
109
            try {
110
                $this->validator->validate($object, [PublishableValidator::PUBLISHED_KEY => true]);
111
            } catch (ValidationException $exception) {
112
                $resourceMetadata->setViolationList($exception->getConstraintViolationList());
113
            }
114
        }
115
116
        return $this->normalizer->normalize($object, $format, $context);
117
    }
118
119
    public function supportsNormalization($data, $format = null, $context = []): bool
120
    {
121
        if (!\is_object($data) || $data instanceof \Traversable) {
122
            return false;
123
        }
124
        if (!isset($context[self::ALREADY_CALLED])) {
125
            $context[self::ALREADY_CALLED] = [];
126
        }
127
        try {
128
            $id = $this->propertyAccessor->getValue($data, 'id');
129
        } catch (NoSuchPropertyException $e) {
130
            return false;
131
        }
132
133
        return !\in_array($id, $context[self::ALREADY_CALLED], true) &&
134
            $this->publishableStatusChecker->getAttributeReader()->isConfigured($data);
135
    }
136
137
    /**
138
     * {@inheritdoc}
139
     */
140
    public function denormalize($data, $type, $format = null, array $context = []): mixed
141
    {
142
        $context[self::ALREADY_CALLED] = true;
143
        $configuration = $this->publishableStatusChecker->getAttributeReader()->getConfiguration($type);
144
145
        $data = $this->unsetRestrictedData($type, $data, $configuration);
146
147
        $request = $this->requestStack->getMainRequest();
148
        if ($request && true === $this->publishableStatusChecker->isRequestForPublished($request)) {
149
            return $this->denormalizer->denormalize($data, $type, $format, $context);
0 ignored issues
show
Bug introduced by
It seems like $type can also be of type object; however, parameter $type of Symfony\Component\Serial...nterface::denormalize() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

149
            return $this->denormalizer->denormalize($data, /** @scrutinizer ignore-type */ $type, $format, $context);
Loading history...
150
        }
151
152
        // It's a new object
153
        if (!isset($context[AbstractNormalizer::OBJECT_TO_POPULATE])) {
154
            // User doesn't have draft access: force publication date
155
            if (!$this->publishableStatusChecker->isGranted($type)) {
156
                $data[$configuration->fieldName] = date('Y-m-d H:i:s');
157
            }
158
159
            return $this->denormalizer->denormalize($data, $type, $format, $context);
160
        }
161
162
        $object = $context[AbstractNormalizer::OBJECT_TO_POPULATE];
163
        $data = $this->setPublishedAt($data, $configuration, $object);
164
165
        // No field has been updated (after publishedAt verified and cleaned/unset if needed): nothing to do here anymore
166
        // or User doesn't have draft access: update the original object
167
        if (
168
            empty($data) ||
169
            !$this->publishableStatusChecker->isActivePublishedAt($object) ||
170
            !$this->publishableStatusChecker->isGranted($type)
171
        ) {
172
            return $this->denormalizer->denormalize($data, $type, $format, $context);
173
        }
174
175
        // Any field has been modified: create a draft
176
        $draft = $this->createDraft($object, $configuration, $type);
0 ignored issues
show
Bug introduced by
It seems like $type can also be of type object; however, parameter $type of Silverback\ApiComponents...rmalizer::createDraft() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

176
        $draft = $this->createDraft($object, $configuration, /** @scrutinizer ignore-type */ $type);
Loading history...
177
        $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $draft;
178
179
        return $this->denormalizer->denormalize($data, $type, $format, $context);
180
    }
181
182
    private function setPublishedAt(array $data, Publishable $configuration, object $object): array
183
    {
184
        if (isset($data[$configuration->fieldName])) {
185
            $publicationDate = new \DateTimeImmutable($data[$configuration->fieldName]);
186
187
            // User changed the publication date with an earlier one on a published resource: ignore it
188
            if (
189
                $this->publishableStatusChecker->isActivePublishedAt($object) &&
190
                new \DateTimeImmutable() >= $publicationDate
191
            ) {
192
                unset($data[$configuration->fieldName]);
193
            }
194
        }
195
196
        return $data;
197
    }
198
199
    private function unsetRestrictedData($type, array $data, Publishable $configuration): array
200
    {
201
        // It's not possible to change the publishedResource and draftResource properties
202
        unset($data[$configuration->associationName], $data[$configuration->reverseAssociationName]);
203
204
        // User doesn't have draft access: cannot set or change the publication date
205
        if (!$this->publishableStatusChecker->isGranted($type)) {
206
            unset($data[$configuration->fieldName]);
207
        }
208
209
        return $data;
210
    }
211
212
    public function createDraft(object $object, Publishable $configuration, string $type): object
213
    {
214
        $em = $this->getManagerFromType($type);
215
        $classMetadata = $this->getClassMetadataInfo($em, $type);
216
217
        // Resource is a draft: nothing to do here anymore
218
        if (null !== $classMetadata->getFieldValue($object, $configuration->associationName)) {
219
            return $object;
220
        }
221
222
        $draft = clone $object; // Identifier(s) should be reset from AbstractComponent::__clone method
223
224
        // Empty publishedDate on draft
225
        $classMetadata->setFieldValue($draft, $configuration->fieldName, null);
226
227
        // Set publishedResource on draft
228
        $classMetadata->setFieldValue($draft, $configuration->associationName, $object);
229
230
        // Set draftResource on data if we have permission
231
        $classMetadata->setFieldValue($object, $configuration->reverseAssociationName, $draft);
232
233
        // Clear any writable one-to-many fields, these should still reference the published component, such as component positions
234
        // Doesn't matter usually it seems, but where we process uploadable, the one-to-many is not then reassigned later back to the publishable during normalization
235
        foreach ($classMetadata->getAssociationMappings() as $fieldName => $mapping) {
236
            if (ClassMetadataInfo::ONE_TO_MANY === $mapping['type'] && $this->propertyAccessor->isWritable($draft, $fieldName)) {
237
                $this->propertyAccessor->setValue($draft, $fieldName, new ArrayCollection());
238
            }
239
        }
240
241
        try {
242
            $this->uploadableFileManager->processClonedUploadable($object, $draft);
243
        } catch (\InvalidArgumentException $e) {
244
            // ok exception, it may not be uploadable...
245
        }
246
        // Add draft object to UnitOfWork
247
        $em->persist($draft);
248
249
        // Clear the cache of the published resource because it should now also return an associated draft
250
        $event = new ResourceChangedEvent($object, 'updated');
251
        $this->eventDispatcher->dispatch($event);
252
253
        return $draft;
254
    }
255
256
    /**
257
     * {@inheritdoc}
258
     */
259
    public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
260
    {
261
        return !isset($context[self::ALREADY_CALLED]) && $this->publishableStatusChecker->getAttributeReader()->isConfigured($type);
262
    }
263
264
    public function hasCacheableSupportsMethod(): bool
265
    {
266
        return false;
267
    }
268
269
    private function getManagerFromType(string $type): ObjectManager
270
    {
271
        $em = $this->registry->getManagerForClass($type);
272
        if (!$em) {
273
            throw new InvalidArgumentException(sprintf('Could not find entity manager for class %s', $type));
274
        }
275
276
        return $em;
277
    }
278
279
    private function getClassMetadataInfo(ObjectManager $em, string $type): ClassMetadataInfo
280
    {
281
        $classMetadata = $em->getClassMetadata($type);
282
        if (!$classMetadata instanceof ClassMetadataInfo) {
283
            throw new InvalidArgumentException(sprintf('Class metadata for %s was not an instance of %s', $type, ClassMetadataInfo::class));
284
        }
285
286
        return $classMetadata;
287
    }
288
}
289