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