Passed
Push — master ( 812ff0...c48dee )
by Daniel
13:31
created

PublishableNormalizer::normalize()   B

Complexity

Conditions 7
Paths 13

Size

Total Lines 32
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 2
Bugs 1 Features 1
Metric Value
cc 7
eloc 19
c 2
b 1
f 1
nc 13
nop 3
dl 0
loc 32
ccs 0
cts 19
cp 0
crap 56
rs 8.8333
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\Core\Api\IriConverterInterface;
17
use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException;
18
use ApiPlatform\Core\Validator\ValidatorInterface;
19
use Doctrine\ORM\Mapping\ClassMetadataInfo;
20
use Doctrine\Persistence\ManagerRegistry;
21
use Doctrine\Persistence\ObjectManager;
22
use Silverback\ApiComponentsBundle\Annotation\Publishable;
23
use Silverback\ApiComponentsBundle\Exception\InvalidArgumentException;
24
use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker;
25
use Silverback\ApiComponentsBundle\Validator\PublishableValidator;
26
use Symfony\Component\HttpFoundation\RequestStack;
27
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
28
use Symfony\Component\PropertyAccess\PropertyAccess;
29
use Symfony\Component\PropertyAccess\PropertyAccessor;
30
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
31
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
32
use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
33
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
34
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
35
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
36
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
37
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
38
39
/**
40
 * Adds `published` property on response, if not set.
41
 *
42
 * @author Vincent Chalamon <[email protected]>
43
 */
44
final class PublishableNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface, ContextAwareDenormalizerInterface, DenormalizerAwareInterface
45
{
46
    use DenormalizerAwareTrait;
47
    use NormalizerAwareTrait;
48
49
    private const ALREADY_CALLED = 'PUBLISHABLE_NORMALIZER_ALREADY_CALLED';
50
    private const ASSOCIATION = 'PUBLISHABLE_ASSOCIATION';
51
52
    private PublishableStatusChecker $publishableStatusChecker;
53
    private ManagerRegistry $registry;
54
    private RequestStack $requestStack;
55
    private ValidatorInterface $validator;
56
    private PropertyAccessor $propertyAccessor;
57
    private IriConverterInterface $iriConverter;
58
59
    public function __construct(
60
        PublishableStatusChecker $publishableStatusChecker,
61
        ManagerRegistry $registry,
62
        RequestStack $requestStack,
63
        ValidatorInterface $validator,
64
        IriConverterInterface $iriConverter
65
    ) {
66
        $this->publishableStatusChecker = $publishableStatusChecker;
67
        $this->registry = $registry;
68
        $this->requestStack = $requestStack;
69
        $this->validator = $validator;
70
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
71
        $this->iriConverter = $iriConverter;
72
    }
73
74
    public function normalize($object, $format = null, array $context = [])
75
    {
76
        $context[self::ALREADY_CALLED][] = $this->propertyAccessor->getValue($object, 'id');
77
        $context[MetadataNormalizer::METADATA_CONTEXT]['published'] = $this->publishableStatusChecker->isActivePublishedAt($object);
78
79
        if (isset($context[self::ASSOCIATION]) && $context[self::ASSOCIATION] === $object) {
80
            return $this->iriConverter->getIriFromItem($object);
81
        }
82
83
        $type = \get_class($object);
84
        $configuration = $this->publishableStatusChecker->getAnnotationReader()->getConfiguration($type);
85
        $em = $this->getManagerFromType($type);
86
        $classMetadata = $this->getClassMetadataInfo($em, $type);
87
88
        $context[MetadataNormalizer::METADATA_CONTEXT][$configuration->fieldName] = $classMetadata->getFieldValue($object, $configuration->fieldName);
89
        if (\is_object($assocObject = $classMetadata->getFieldValue($object, $configuration->associationName))) {
90
            $context[self::ASSOCIATION] = $assocObject;
91
        }
92
        if (\is_object($reverseAssocObject = $classMetadata->getFieldValue($object, $configuration->reverseAssociationName))) {
93
            $context[self::ASSOCIATION] = $reverseAssocObject;
94
        }
95
96
        // display soft validation violations in the response
97
        if ($this->publishableStatusChecker->isGranted($object)) {
98
            try {
99
                $this->validator->validate($object, [PublishableValidator::PUBLISHED_KEY => true]);
100
            } catch (ValidationException $exception) {
101
                $context[MetadataNormalizer::METADATA_CONTEXT]['violation_list'] = $this->normalizer->normalize($exception->getConstraintViolationList(), $format);
102
            }
103
        }
104
105
        return $this->normalizer->normalize($object, $format, $context);
106
    }
107
108
    public function supportsNormalization($data, $format = null, $context = []): bool
109
    {
110
        if (!\is_object($data) || $data instanceof \Traversable) {
111
            return false;
112
        }
113
        if (!isset($context[self::ALREADY_CALLED])) {
114
            $context[self::ALREADY_CALLED] = [];
115
        }
116
        try {
117
            $id = $this->propertyAccessor->getValue($data, 'id');
118
        } catch (NoSuchPropertyException $e) {
119
            return false;
120
        }
121
122
        return !\in_array($id, $context[self::ALREADY_CALLED], true) &&
123
            $this->publishableStatusChecker->getAnnotationReader()->isConfigured($data);
124
    }
125
126
    /**
127
     * {@inheritdoc}
128
     */
129
    public function denormalize($data, $type, $format = null, array $context = [])
130
    {
131
        $context[self::ALREADY_CALLED] = true;
132
        $configuration = $this->publishableStatusChecker->getAnnotationReader()->getConfiguration($type);
133
134
        $data = $this->unsetRestrictedData($type, $data, $configuration);
135
136
        $request = $this->requestStack->getMasterRequest();
0 ignored issues
show
Deprecated Code introduced by
The function Symfony\Component\HttpFo...ack::getMasterRequest() has been deprecated: since symfony/http-foundation 5.3, use getMainRequest() instead ( Ignorable by Annotation )

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

136
        $request = /** @scrutinizer ignore-deprecated */ $this->requestStack->getMasterRequest();

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
137
        if ($request && true === $this->publishableStatusChecker->isPublishedRequest($request)) {
138
            return $this->denormalizer->denormalize($data, $type, $format, $context);
139
        }
140
141
        // It's a new object
142
        if (!isset($context[AbstractNormalizer::OBJECT_TO_POPULATE])) {
143
            // User doesn't have draft access: force publication date
144
            if (!$this->publishableStatusChecker->isGranted($type)) {
145
                $data[$configuration->fieldName] = date('Y-m-d H:i:s');
146
            }
147
148
            return $this->denormalizer->denormalize($data, $type, $format, $context);
149
        }
150
151
        $object = $context[AbstractNormalizer::OBJECT_TO_POPULATE];
152
        $data = $this->setPublishedAt($data, $configuration, $object);
153
154
        // No field has been updated (after publishedAt verified and cleaned/unset if needed): nothing to do here anymore
155
        // or User doesn't have draft access: update the original object
156
        if (
157
            empty($data) ||
158
            !$this->publishableStatusChecker->isActivePublishedAt($object) ||
159
            !$this->publishableStatusChecker->isGranted($type)
160
        ) {
161
            return $this->denormalizer->denormalize($data, $type, $format, $context);
162
        }
163
164
        // Any field has been modified: create a draft
165
        $draft = $this->createDraft($object, $configuration, $type);
166
167
        $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $draft;
168
169
        return $this->denormalizer->denormalize($data, $type, $format, $context);
170
    }
171
172
    private function setPublishedAt(array $data, Publishable $configuration, object $object): array
173
    {
174
        if (isset($data[$configuration->fieldName])) {
175
            $publicationDate = new \DateTimeImmutable($data[$configuration->fieldName]);
176
177
            // User changed the publication date with an earlier one on a published resource: ignore it
178
            if (
179
                $this->publishableStatusChecker->isActivePublishedAt($object) &&
180
                new \DateTimeImmutable() >= $publicationDate
181
            ) {
182
                unset($data[$configuration->fieldName]);
183
            }
184
        }
185
186
        return $data;
187
    }
188
189
    private function unsetRestrictedData($type, array $data, Publishable $configuration): array
190
    {
191
        // It's not possible to change the publishedResource and draftResource properties
192
        unset($data[$configuration->associationName], $data[$configuration->reverseAssociationName]);
193
194
        // User doesn't have draft access: cannot set or change the publication date
195
        if (!$this->publishableStatusChecker->isGranted($type)) {
196
            unset($data[$configuration->fieldName]);
197
        }
198
199
        return $data;
200
    }
201
202
    private function createDraft(object $object, Publishable $configuration, string $type): object
203
    {
204
        $em = $this->getManagerFromType($type);
205
        $classMetadata = $this->getClassMetadataInfo($em, $type);
206
207
        // Resource is a draft: nothing to do here anymore
208
        if (null !== $classMetadata->getFieldValue($object, $configuration->associationName)) {
209
            return $object;
210
        }
211
212
        $draft = clone $object; // Identifier(s) should be reset from AbstractComponent::__clone method
213
214
        // Empty publishedDate on draft
215
        $classMetadata->setFieldValue($draft, $configuration->fieldName, null);
216
217
        // Set publishedResource on draft
218
        $classMetadata->setFieldValue($draft, $configuration->associationName, $object);
219
220
        // Set draftResource on data if we have permission
221
        $classMetadata->setFieldValue($object, $configuration->reverseAssociationName, $draft);
222
223
        // Add draft object to UnitOfWork
224
        $em->persist($draft);
225
226
        return $draft;
227
    }
228
229
    /**
230
     * {@inheritdoc}
231
     */
232
    public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
233
    {
234
        return !isset($context[self::ALREADY_CALLED]) && $this->publishableStatusChecker->getAnnotationReader()->isConfigured($type);
235
    }
236
237
    public function hasCacheableSupportsMethod(): bool
238
    {
239
        return false;
240
    }
241
242
    private function getManagerFromType(string $type): ObjectManager
243
    {
244
        $em = $this->registry->getManagerForClass($type);
245
        if (!$em) {
246
            throw new InvalidArgumentException(sprintf('Could not find entity manager for class %s', $type));
247
        }
248
249
        return $em;
250
    }
251
252
    private function getClassMetadataInfo(ObjectManager $em, string $type): ClassMetadataInfo
253
    {
254
        $classMetadata = $em->getClassMetadata($type);
255
        if (!$classMetadata instanceof ClassMetadataInfo) {
256
            throw new InvalidArgumentException(sprintf('Class metadata for %s was not an instance of %s', $type, ClassMetadataInfo::class));
257
        }
258
259
        return $classMetadata;
260
    }
261
}
262