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