Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.66% covered (warning)
89.66%
78 / 87
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReplaceObjectAction
89.66% covered (warning)
89.66%
78 / 87
66.67% covered (warning)
66.67%
6 / 9
35.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAuthorized
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
12.03
 __invoke
91.38% covered (success)
91.38%
53 / 58
0.00% covered (danger)
0.00%
0 / 1
16.16
 getInputType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOutputType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPossibleActionResponseStatuses
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getDescription
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRouteAttributes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2namespace Apie\Common\Actions;
3
4use Apie\Common\Events\ApieResourceCreated;
5use Apie\Common\Exceptions\EntityEtagConflictException;
6use Apie\Common\IntegrationTestLogger;
7use Apie\Common\Other\LockUtil;
8use Apie\Core\Actions\ActionInterface;
9use Apie\Core\Actions\ActionResponse;
10use Apie\Core\Actions\ActionResponseStatus;
11use Apie\Core\Actions\ActionResponseStatusList;
12use Apie\Core\Actions\ApieFacadeInterface;
13use Apie\Core\Attributes\Description;
14use Apie\Core\BoundedContext\BoundedContextId;
15use Apie\Core\Context\ApieContext;
16use Apie\Core\ContextConstants;
17use Apie\Core\Datalayers\ApieDatalayer;
18use Apie\Core\Entities\EntityInterface;
19use Apie\Core\Enums\RequestMethod;
20use Apie\Core\Exceptions\EntityAlreadyPersisted;
21use Apie\Core\Exceptions\EntityNotFoundException;
22use Apie\Core\Exceptions\IndexNotFoundException;
23use Apie\Core\Exceptions\InvalidTypeException;
24use Apie\Core\Lists\StringList;
25use Apie\Core\Utils\EntityUtils;
26use Apie\Core\ValueObjects\Utils;
27use Exception;
28use LogicException;
29use Psr\Http\Message\ServerRequestInterface;
30use ReflectionClass;
31
32/**
33 * Action to create/replace a new object.
34 */
35final class ReplaceObjectAction implements ActionInterface
36{
37    public function __construct(private readonly ApieFacadeInterface $apieFacade)
38    {
39    }
40
41    public static function isAuthorized(ApieContext $context, bool $runtimeChecks, bool $throwError = false): bool
42    {
43        $refl = new ReflectionClass($context->getContext(ContextConstants::RESOURCE_NAME, $throwError));
44        if (!$context->appliesToContext($refl, $runtimeChecks, $throwError ? new LogicException('Class access is not allowed') : null)) {
45            return false;
46        }
47        if (EntityUtils::isPolymorphicEntity($refl) && $runtimeChecks && $context->hasContext(ContextConstants::RAW_CONTENTS)) {
48            $rawContents = Utils::toArray($context->getContext(ContextConstants::RAW_CONTENTS, $throwError));
49            try {
50                $refl = EntityUtils::findClass($rawContents, $refl);
51            } catch (IndexNotFoundException) {
52            }
53        }
54        $constructor = $refl->getConstructor();
55        if ($constructor && !$context->appliesToContext($constructor, $runtimeChecks, $throwError ? new LogicException('Class instantiation is not allowed') : null)) {
56            return false;
57        }
58        return true;
59    }
60    
61    /**
62     * @param array<string|int, mixed> $rawContents
63     */
64    public function __invoke(ApieContext $context, array $rawContents): ActionResponse
65    {
66        if ($context->hasContext(ContextConstants::RESOURCE_ID)) {
67            $rawContents['id'] = $context->getContext(ContextConstants::RESOURCE_ID);
68        }
69
70        $context->withContext(ContextConstants::RAW_CONTENTS, $rawContents)
71            ->withContext(ContextConstants::APIE_ACTION, __CLASS__)
72            ->checkAuthorization();
73        try {
74            $resource = $this->apieFacade->denormalizeNewObject(
75                $rawContents,
76                $context->getContext(ContextConstants::RESOURCE_NAME),
77                $context
78            );
79        } catch (Exception $error) {
80            IntegrationTestLogger::logException($error);
81            return ActionResponse::createClientError($this->apieFacade, $context, $error);
82        }
83
84        if (!$resource instanceof EntityInterface) {
85            throw new InvalidTypeException($resource, EntityInterface::class);
86        }
87        $request = $context->getContext(ServerRequestInterface::class, false);
88        $ifMatch = $request instanceof ServerRequestInterface && $request->hasHeader('If-Match')
89            ? trim($request->getHeaderLine('If-Match'))
90            : null;
91
92        $context = $context->withContext(ContextConstants::RESOURCE, $resource);
93        $boundedContextId = new BoundedContextId($context->getContext(ContextConstants::BOUNDED_CONTEXT_ID));
94        $request = $context->getContext(ServerRequestInterface::class, false);
95        $ifMatch = $request instanceof ServerRequestInterface && $request->hasHeader('If-Match')
96            ? trim($request->getHeaderLine('If-Match'))
97            : null;
98        $putContext = $context
99            ->withContext(ContextConstants::DISABLE_CONTEXT_FILTER, true);
100        $lock = LockUtil::createLock(
101            $putContext,
102            [ContextConstants::BOUNDED_CONTEXT_ID, ContextConstants::RESOURCE_NAME, ContextConstants::RESOURCE_ID],
103            write: true
104        );
105        try {
106            $existing = null;
107            try {
108                $existing = $this->apieFacade->find($resource->getId(), $boundedContextId);
109            } catch (EntityNotFoundException) {
110            }
111            if ($existing === null) {
112                if ($ifMatch !== null) {
113                    throw new EntityEtagConflictException();
114                }
115            } else {
116                $resourceContext = $putContext
117                    ->withContext(ContextConstants::RESOURCE, $existing)
118                    ->withContext(ContextConstants::APIE_ACTION, GetItemAction::class);
119                if (!GetItemAction::isAuthorized($resourceContext, true)) {
120                    throw new EntityAlreadyPersisted($resource);
121                }
122                if ($ifMatch !== null) {
123                    $metadata = $context->getContext(ApieDatalayer::class)
124                        ->getMetadata($resource->getId(), $boundedContextId);
125                    $currentEtag = '"' . $metadata->etag . '"';
126                    $providedEtags = array_map('trim', explode(',', $ifMatch));
127                    if (!in_array('*', $providedEtags, true) && !in_array($currentEtag, $providedEtags, true)) {
128                        throw new EntityEtagConflictException();
129                    }
130                }
131            }
132            $resource = $this->apieFacade->upsert($resource, $boundedContextId);
133        } catch (Exception $error) {
134            IntegrationTestLogger::logException($error);
135            return ActionResponse::createClientError($this->apieFacade, $context, $error);
136        } finally {
137            $lock->release();
138        }
139        $context = $context->withContext(ContextConstants::RESOURCE, $resource);
140        $context->dispatchEvent(new ApieResourceCreated($resource, $context));
141        return ActionResponse::createCreationSuccess($this->apieFacade, $context, $resource, $resource);
142    }
143
144    /**
145     * @return ReflectionClass<EntityInterface>
146     */
147    public static function getInputType(ReflectionClass $class): ReflectionClass
148    {
149        return $class;
150    }
151
152    /**
153     * @return ReflectionClass<EntityInterface>
154     */
155    public static function getOutputType(ReflectionClass $class): ReflectionClass
156    {
157        return $class;
158    }
159
160    public static function getPossibleActionResponseStatuses(): ActionResponseStatusList
161    {
162        return new ActionResponseStatusList([
163            ActionResponseStatus::CREATED,
164            ActionResponseStatus::CLIENT_ERROR,
165            ActionResponseStatus::PERISTENCE_ERROR
166        ]);
167    }
168
169    /**
170     * @param ReflectionClass<covariant object> $class
171     */
172    public static function getDescription(ReflectionClass $class): string
173    {
174        $description = 'Creates an instance of ' . $class->getShortName();
175        foreach ($class->getAttributes(Description::class) as $attribute) {
176            $description .= '. ' . $attribute->newInstance()->description;
177        }
178
179        return $description;
180    }
181    
182    /**
183     * @param ReflectionClass<covariant object> $class
184     */
185    public static function getTags(ReflectionClass $class): StringList
186    {
187        return new StringList([$class->getShortName(), 'resource']);
188    }
189
190    /**
191     * @param ReflectionClass<covariant object> $class
192     */
193    public static function getRouteAttributes(ReflectionClass $class): array
194    {
195        return [
196            ContextConstants::REPLACE_OBJECT => true,
197            ContextConstants::RESOURCE_NAME => $class->name,
198            ContextConstants::DISPLAY_FORM => true,
199        ];
200    }
201}