PublishableNormalizer::__construct()   A
last analyzed

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\Metadata\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\Event\ResourceChangedEvent;
25
use Silverback\ApiComponentsBundle\Exception\InvalidArgumentException;
26
use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker;
27
use Silverback\ApiComponentsBundle\Helper\Uploadable\UploadableFileManager;
28
use Silverback\ApiComponentsBundle\Serializer\ResourceMetadata\ResourceMetadataProvider;
29
use Silverback\ApiComponentsBundle\Validator\PublishableValidator;
30
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
31
use Symfony\Component\HttpFoundation\RequestStack;
32
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
33
use Symfony\Component\PropertyAccess\PropertyAccess;
34
use Symfony\Component\PropertyAccess\PropertyAccessor;
35
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
36
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
37
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
38
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
39
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
40
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
41
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
42
43
/**
44
 * Adds `published` property on response, if not set.
45
 *
46
 * @author Vincent Chalamon <[email protected]>
47
 */
48
final class PublishableNormalizer implements NormalizerInterface, NormalizerAwareInterface, DenormalizerInterface, DenormalizerAwareInterface
49
{
50
    use DenormalizerAwareTrait;
51
    use NormalizerAwareTrait;
52
53
    private const ALREADY_CALLED = 'PUBLISHABLE_NORMALIZER_ALREADY_CALLED';
54
    private const ASSOCIATION = 'PUBLISHABLE_ASSOCIATION';
55
56
    private PropertyAccessor $propertyAccessor;
57
58
    public function __construct(
59
        private readonly PublishableStatusChecker $publishableStatusChecker,
60
        private readonly ManagerRegistry $registry,
61
        private readonly RequestStack $requestStack,
62
        private readonly ValidatorInterface $validator,
63
        private readonly IriConverterInterface $iriConverter,
64
        private readonly UploadableFileManager $uploadableFileManager,
65
        private readonly ResourceMetadataProvider $resourceMetadataProvider,
66
        private readonly EventDispatcherInterface $eventDispatcher
67
    ) {
68
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
69
    }
70
71
    public function normalize($object, $format = null, array $context = []): float|array|\ArrayObject|bool|int|string|null
72
    {
73
        $context[self::ALREADY_CALLED][] = $this->propertyAccessor->getValue($object, 'id');
74
75
        if (isset($context[self::ASSOCIATION]) && $context[self::ASSOCIATION] === $object) {
76
            return $this->iriConverter->getIriFromResource($object);
77
        }
78
79
        $isPublished = $this->publishableStatusChecker->isActivePublishedAt($object);
80
81
        $resourceMetadata = $this->resourceMetadataProvider->findResourceMetadata($object);
82
        $resourceMetadata->setPublishable($isPublished);
83
84
        $type = $object::class;
85
        $configuration = $this->publishableStatusChecker->getAttributeReader()->getConfiguration($type);
86
        $em = $this->getManagerFromType($type);
0 ignored issues
show
Bug introduced by
It seems like $type can also be of type object; however, parameter $type of Silverback\ApiComponents...r::getManagerFromType() 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

86
        $em = $this->getManagerFromType(/** @scrutinizer ignore-type */ $type);
Loading history...
87
        $classMetadata = $this->getClassMetadataInfo($em, $type);
88
89
        $publishedAtDateTime = $classMetadata->getFieldValue($object, $configuration->fieldName);
90
        if ($publishedAtDateTime instanceof \DateTimeInterface) {
91
            $publishedAtDateTime = $publishedAtDateTime->format(\DateTimeInterface::RFC3339_EXTENDED);
92
        }
93
94
        // using static name 'publishedAt' for predictable API and easy metadata object instead of dynamic $configuration->fieldName
95
        if ($publishedAtDateTime) {
96
            $resourceMetadata->setPublishable($isPublished, $publishedAtDateTime);
97
        }
98
99
        if (\is_object($assocObject = $classMetadata->getFieldValue($object, $configuration->associationName))) {
100
            $context[self::ASSOCIATION] = $assocObject;
101
        } elseif (\is_object($reverseAssocObject = $classMetadata->getFieldValue($object, $configuration->reverseAssociationName))) {
102
            $context[self::ASSOCIATION] = $reverseAssocObject;
103
        }
104
105
        // display soft validation violations in the response
106
        if ($this->publishableStatusChecker->isGranted($object)) {
107
            try {
108
                $this->validator->validate($object, [PublishableValidator::PUBLISHED_KEY => true]);
109
            } catch (ValidationException $exception) {
110
                $resourceMetadata->setViolations($exception->getConstraintViolationList());
111
            }
112
        }
113
114
        return $this->normalizer->normalize($object, $format, $context);
115
    }
116
117
    public function supportsNormalization($data, $format = null, $context = []): bool
118
    {
119
        if (!\is_object($data) || $data instanceof \Traversable) {
120
            return false;
121
        }
122
        if (!isset($context[self::ALREADY_CALLED])) {
123
            $context[self::ALREADY_CALLED] = [];
124
        }
125
        try {
126
            $id = $this->propertyAccessor->getValue($data, 'id');
127
        } catch (NoSuchPropertyException $e) {
128
            return false;
129
        }
130
131
        return !\in_array($id, $context[self::ALREADY_CALLED], true)
132
            && $this->publishableStatusChecker->getAttributeReader()->isConfigured($data);
133
    }
134
135
    /**
136
     * {@inheritdoc}
137
     */
138
    public function denormalize($data, $type, $format = null, array $context = []): mixed
139
    {
140
        $context[self::ALREADY_CALLED] = true;
141
        $configuration = $this->publishableStatusChecker->getAttributeReader()->getConfiguration($type);
142
143
        $data = $this->unsetRestrictedData($type, $data, $configuration);
144
145
        $request = $this->requestStack->getMainRequest();
146
        if ($request && true === $this->publishableStatusChecker->isRequestForPublished($request)) {
147
            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

147
            return $this->denormalizer->denormalize($data, /** @scrutinizer ignore-type */ $type, $format, $context);
Loading history...
148
        }
149
150
        // It's a new object
151
        if (!isset($context[AbstractNormalizer::OBJECT_TO_POPULATE])) {
152
            // User doesn't have draft access: force publication date
153
            if (!$this->publishableStatusChecker->isGranted($type)) {
154
                $data[$configuration->fieldName] = date('Y-m-d H:i:s');
155
            }
156
157
            return $this->denormalizer->denormalize($data, $type, $format, $context);
158
        }
159
160
        $object = $context[AbstractNormalizer::OBJECT_TO_POPULATE];
161
        $data = $this->setPublishedAt($data, $configuration, $object);
162
163
        // No field has been updated (after publishedAt verified and cleaned/unset if needed): nothing to do here anymore
164
        // or User doesn't have draft access: update the original object
165
        if (
166
            empty($data)
167
            || !$this->publishableStatusChecker->isActivePublishedAt($object)
168
            || !$this->publishableStatusChecker->isGranted($type)
169
        ) {
170
            return $this->denormalizer->denormalize($data, $type, $format, $context);
171
        }
172
173
        // Any field has been modified: create a draft
174
        $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

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

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

282
    public function getSupportedTypes(/** @scrutinizer ignore-unused */ ?string $format): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
283
    {
284
        return ['object' => false];
285
    }
286
}
287