Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.00% covered (warning)
80.00%
92 / 115
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
RunItemMethodAction
80.00% covered (warning)
80.00%
92 / 115
63.64% covered (warning)
63.64%
7 / 11
59.49
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
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
18.37
 __invoke
72.00% covered (warning)
72.00%
36 / 50
0.00% covered (danger)
0.00%
0 / 1
10.78
 shouldReturnResource
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getDisplayNameForMethod
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 getInputType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getOutputType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getPossibleActionResponseStatuses
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getDescription
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getTags
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getRouteAttributes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2namespace Apie\Common\Actions;
3
4use Apie\Common\IntegrationTestLogger;
5use Apie\Common\Other\LockUtil;
6use Apie\Core\Actions\ActionResponse;
7use Apie\Core\Actions\ActionResponseStatus;
8use Apie\Core\Actions\ActionResponseStatusList;
9use Apie\Core\Actions\ApieFacadeInterface;
10use Apie\Core\Actions\MethodActionInterface;
11use Apie\Core\BoundedContext\BoundedContextId;
12use Apie\Core\Context\ApieContext;
13use Apie\Core\ContextConstants;
14use Apie\Core\Entities\EntityInterface;
15use Apie\Core\Exceptions\EntityNotFoundException;
16use Apie\Core\Exceptions\InvalidTypeException;
17use Apie\Core\IdentifierUtils;
18use Apie\Core\Lists\StringList;
19use Apie\Core\TypeUtils;
20use Apie\Core\Utils\EntityUtils;
21use Apie\Core\ValueObjects\Exceptions\InvalidStringForValueObjectException;
22use Apie\Serializer\Exceptions\ValidationException;
23use Exception;
24use LogicException;
25use ReflectionClass;
26use ReflectionException;
27use ReflectionMethod;
28use ReflectionNamedType;
29
30/**
31 * Runs a method from  a resource (and persist resource afterwards).
32 */
33final class RunItemMethodAction implements MethodActionInterface
34{
35    public function __construct(private readonly ApieFacadeInterface $apieFacade)
36    {
37    }
38
39    public static function isAuthorized(ApieContext $context, bool $runtimeChecks, bool $throwError = false): bool
40    {
41        $refl = new ReflectionClass($context->getContext(ContextConstants::RESOURCE_NAME, $throwError));
42        $methodName = $context->getContext(ContextConstants::METHOD_NAME, $throwError);
43        $method = new ReflectionMethod(
44            $context->getContext(ContextConstants::METHOD_CLASS, $throwError),
45            $methodName
46        );
47        if (EntityUtils::isPolymorphicEntity($refl) && $runtimeChecks && $context->hasContext(ContextConstants::RESOURCE) &&!$method->isStatic()) {
48            $refl = new ReflectionClass($context->getContext(ContextConstants::RESOURCE, $throwError));
49            if (!$refl->hasMethod($methodName)) {
50                if ($throwError) {
51                    throw new LogicException('Method ' . $methodName . ' does not exist on this entity');
52                }
53                return false;
54            }
55            $method = $refl->getMethod($methodName);
56        }
57        if (!$context->appliesToContext($refl, $runtimeChecks, $throwError ? new LogicException('Class access is not allowed!') : null)) {
58            return false;
59        }
60        return $context->appliesToContext($method, $runtimeChecks, $throwError ? new LogicException('Class method is not allowed') : null);
61    }
62
63    /**
64     * @param array<string|int, mixed> $rawContents
65     */
66    public function __invoke(ApieContext $context, array $rawContents): ActionResponse
67    {
68        $context->withContext(ContextConstants::APIE_ACTION, __CLASS__)->checkAuthorization();
69        $resourceClass = new ReflectionClass($context->getContext(ContextConstants::RESOURCE_NAME));
70        if (!$resourceClass->implementsInterface(EntityInterface::class)) {
71            throw new InvalidTypeException($resourceClass->name, 'EntityInterface');
72        }
73        $method = new ReflectionMethod(
74            $context->getContext(ContextConstants::METHOD_CLASS),
75            $context->getContext(ContextConstants::METHOD_NAME)
76        );
77        $lock = LockUtil::createLock(
78            $context,
79            [ContextConstants::BOUNDED_CONTEXT_ID, ContextConstants::RESOURCE_NAME, ContextConstants::RESOURCE_ID],
80            write: true
81        );
82        try {
83            if ($method->isStatic()) {
84                $resource = null;
85            } else {
86                $id = $context->getContext(ContextConstants::RESOURCE_ID);
87                try {
88                    $resource = $this->apieFacade->find(
89                        IdentifierUtils::idStringToIdentifier($id, $context),
90                        new BoundedContextId($context->getContext(ContextConstants::BOUNDED_CONTEXT_ID))
91                    );
92                } catch (InvalidStringForValueObjectException|EntityNotFoundException $error) {
93                    IntegrationTestLogger::logException($error);
94                    return ActionResponse::createClientError($this->apieFacade, $context, $error);
95                }
96                $context = $context->withContext(ContextConstants::RESOURCE, $resource);
97                // polymorphic relation, so could be the incorrect declared method
98                if (!$method->getDeclaringClass()->isInstance($resource)) {
99                    try {
100                        $method = (new ReflectionClass($resource))->getMethod($method->name);
101                    } catch (ReflectionException $methodError) {
102                        $error = new Exception(
103                            sprintf('Resource "%s" does not support "%s"!', $id, $method->name),
104                            0,
105                            $methodError
106                        );
107                        throw ValidationException::createFromArray(['' => $error]);
108                    }
109                }
110            }
111
112            $result = $this->apieFacade->denormalizeOnMethodCall(
113                $rawContents,
114                $resource,
115                $method,
116                $context
117            );
118            if ($resource !== null) {
119                if (!$lock->isAcquired()) {
120                    throw new \LogicException('Lock was released before modification was finished!');
121                }
122                $resource = $this->apieFacade->persistExisting(
123                    $resource,
124                    new BoundedContextId($context->getContext(ContextConstants::BOUNDED_CONTEXT_ID))
125                );
126            }
127        } finally {
128            $lock->release();
129        }
130        if (self::shouldReturnResource($method)) {
131            $result = $resource;
132        }
133        return ActionResponse::createRunSuccess($this->apieFacade, $context, $result, $resource);
134    }
135
136    /**
137     * Returns true if we should not return the return value of the method, but should return the return value of the resource.
138     * This is the case if:
139     * - The method returns void
140     * - The method call starts with 'add' or 'remove' and has arguments.
141     */
142    public static function shouldReturnResource(ReflectionMethod $method): bool
143    {
144        $returnType = $method->getReturnType();
145        if ($returnType instanceof ReflectionNamedType && 'void' === $returnType->getName()) {
146            return true;
147        }
148        if ($method->getNumberOfParameters() === 0) {
149            return false;
150        }
151                
152        return str_starts_with($method->name, 'add') || str_starts_with($method->name, 'remove');
153    }
154
155    /**
156     * Returns a string how we should display the method. For example we remove 'add' or 'remove' from the string.
157     */
158    public static function getDisplayNameForMethod(ReflectionMethod $method): string
159    {
160        if ($method->getNumberOfParameters() > 0) {
161            if (str_starts_with($method->name, 'remove')) {
162                return lcfirst(substr($method->name, strlen('remove')));
163            }
164            if (str_starts_with($method->name, 'add')) {
165                return lcfirst(substr($method->name, strlen('add')));
166            }
167        }
168        if (str_starts_with($method->name, 'get') && TypeUtils::couldBeAStream($method->getReturnType())) {
169            return lcfirst(substr($method->name, strlen('get')));
170        }
171        return $method->name;
172    }
173
174    /** @param ReflectionClass<object> $class */
175    public static function getInputType(ReflectionClass $class, ?ReflectionMethod $method = null): ReflectionMethod
176    {
177        assert($method instanceof ReflectionMethod);
178        return $method;
179    }
180
181    /** @param ReflectionClass<object> $class */
182    public static function getOutputType(ReflectionClass $class, ?ReflectionMethod $method = null): ReflectionMethod|ReflectionClass
183    {
184        assert($method instanceof ReflectionMethod);
185        if (RunItemMethodAction::shouldReturnResource($method)) {
186            return $class;
187        }
188        return $method;
189    }
190
191    public static function getPossibleActionResponseStatuses(?ReflectionMethod $method = null): ActionResponseStatusList
192    {
193        assert($method instanceof ReflectionMethod);
194        $list = [ActionResponseStatus::SUCCESS];
195
196        if (!empty($method->getParameters())) {
197            $list[] = ActionResponseStatus::CLIENT_ERROR;
198        }
199        if (!$method->isStatic()) {
200            $list[] = ActionResponseStatus::NOT_FOUND;
201        }
202        return new ActionResponseStatusList($list);
203    }
204
205    /**
206     * @param ReflectionClass<object> $class
207     */
208    public static function getDescription(ReflectionClass $class, ?ReflectionMethod $method = null): string
209    {
210        assert($method instanceof ReflectionMethod);
211        $name = self::getDisplayNameForMethod($method);
212        if (str_starts_with($method->name, 'add')) {
213            return 'Adds ' . $name . ' to ' . $class->getShortName();
214        }
215        if (str_starts_with($method->name, 'remove')) {
216            return 'Removes ' . $name . ' from ' . $class->getShortName();
217        }
218        return 'Runs method ' . $name . ' on a ' . $class->getShortName() . ' with a specific id';
219    }
220    
221    /**
222     * @param ReflectionClass<object> $class
223     */
224    public static function getTags(ReflectionClass $class, ?ReflectionMethod $method = null): StringList
225    {
226        $className = $class->getShortName();
227        $declared = $method ? $method->getDeclaringClass()->getShortName() : $className;
228        if ($className !== $declared) {
229            return new StringList([$className, $declared, 'action']);
230        }
231        return new StringList([$className, 'action']);
232    }
233
234    /**
235     * @param ReflectionClass<object> $class
236     */
237    public static function getRouteAttributes(ReflectionClass $class, ?ReflectionMethod $method = null): array
238    {
239        return
240        [
241            ContextConstants::GET_OBJECT => true,
242            ContextConstants::RESOURCE_METHOD => true,
243            ContextConstants::RESOURCE_NAME => $class->name,
244            ContextConstants::METHOD_CLASS => $method->getDeclaringClass()->name,
245            ContextConstants::METHOD_NAME => $method->name,
246            ContextConstants::DISPLAY_FORM => true,
247        ];
248    }
249}