Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.46% covered (warning)
79.46%
89 / 112
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApieContext
79.46% covered (warning)
79.46%
89 / 112
78.57% covered (warning)
78.57%
11 / 14
101.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 withContext
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 withMultipleContext
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hasContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getContext
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 registerOrMarkAmbiguous
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 registerInstance
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getApplicableGetters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 getApplicableSetters
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
90
 getApplicableMethods
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
8.50
 appliesToContext
70.37% covered (warning)
70.37%
19 / 27
0.00% covered (danger)
0.00%
0 / 1
22.66
 checkAuthorization
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isAuthorized
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dispatchEvent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2namespace Apie\Core\Context;
3
4use Apie\Core\Attributes\ApieContextAttribute;
5use Apie\Core\Attributes\Internal;
6use Apie\Core\Attributes\RuntimeCheck;
7use Apie\Core\Attributes\StaticCheck;
8use Apie\Core\ContextConstants;
9use Apie\Core\Entities\EntityWithStatesInterface;
10use Apie\Core\Exceptions\ActionNotAllowedException;
11use Apie\Core\Exceptions\IndexNotFoundException;
12use Apie\Core\Metadata\Concerns\UseContextKey;
13use Apie\Core\Utils\EntityUtils;
14use LogicException;
15use Psr\EventDispatcher\EventDispatcherInterface;
16use ReflectionClass;
17use ReflectionEnumUnitCase;
18use ReflectionMethod;
19use ReflectionProperty;
20use ReflectionType;
21use Throwable;
22
23/**
24 * ApieContext is used as builder/mediator and passed though many Apie functions. It can be used to filter (for example
25 * only show property when authenticated) or can be used to provide extra functionality to other methods.
26 */
27final class ApieContext
28{
29    use UseContextKey;
30
31    /** @var array<int, class-string<ApieContextAttribute>> */
32    private const ATTRIBUTES = [
33        StaticCheck::class
34    ];
35    /** @var array<string, \Closure> */
36    private array $predefined;
37
38    /**
39     * @param array<string, mixed> $context
40     */
41    public function __construct(private array $context = [])
42    {
43        $this->predefined = [
44            ApieContext::class => function (ApieContext $context) {
45                return $context;
46            }
47        ];
48    }
49
50    public function withContext(string $key, mixed $value): self
51    {
52        $instance = clone $this;
53        $instance->context[$key] = $value;
54        return $instance;
55    }
56
57    /**
58     * @param array<string, mixed> $keyValuePairs
59     */
60    public function withMultipleContext(array $keyValuePairs): self
61    {
62        $instance = clone $this;
63        foreach ($keyValuePairs as $key => $value) {
64            $instance->context[$key] = $value;
65        }
66        return $instance;
67    }
68
69    public function hasContext(string $key): bool
70    {
71        return array_key_exists($key, $this->context) || isset($this->predefined[$key]);
72    }
73
74    public function getContext(string $key, bool $throwError = true): mixed
75    {
76        if (isset($this->predefined[$key])) {
77            return $this->predefined[$key]($this);
78        }
79        if (!array_key_exists($key, $this->context)) {
80            if (!$throwError) {
81                return null;
82            }
83            throw new IndexNotFoundException($key);
84        }
85
86        return $this->context[$key];
87    }
88
89    private function registerOrMarkAmbiguous(string $offset, object $instance): void
90    {
91        if (!isset($this->context[$offset])) {
92            $this->context[$offset] = $instance;
93            return;
94        }
95        if ($this->context[$offset] instanceof AmbiguousCall) {
96            $this->context[$offset] = $this->context[$offset]->withAddedName(get_class($instance));
97        } else {
98            $this->context[$offset] = new AmbiguousCall($offset, get_class($this->context[$offset]), get_class($instance));
99        }
100    }
101
102    public function registerInstance(object $object): self
103    {
104        $refl = new ReflectionClass($object);
105
106        $instance = $this->withContext($refl->name, $object);
107        foreach ($refl->getInterfaceNames() as $interface) {
108            $instance->registerOrMarkAmbiguous($interface, $object);
109        }
110        $refl = $refl->getParentClass();
111        while ($refl) {
112            $instance->registerOrMarkAmbiguous($refl->name, $object);
113            $refl = $refl->getParentClass();
114        }
115
116        return $instance;
117    }
118
119    /**
120     * @param ReflectionClass<object> $class
121     */
122    public function getApplicableGetters(ReflectionClass $class, bool $runtimeChecks = true): ReflectionHashmap
123    {
124        $list = [];
125        foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
126            if ($this->appliesToContext($property, $runtimeChecks)) {
127                $list[$property->getName()] = $property;
128            }
129        }
130        foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
131            if (preg_match('/^(get|has|is).+$/i', $method->name) && $this->appliesToContext($method, $runtimeChecks) && !$method->isStatic() && !$method->isAbstract()) {
132                if (strpos($method->name, 'is') === 0) {
133                    $list[lcfirst(substr($method->name, 2))] = $method;
134                } else {
135                    $list[lcfirst(substr($method->name, 3))] = $method;
136                }
137            }
138        }
139        return new ReflectionHashmap($list);
140    }
141
142    /**
143     * @param ReflectionClass<object> $class
144     */
145    public function getApplicableSetters(ReflectionClass $class, bool $runtimeChecks = true): ReflectionHashmap
146    {
147        $list = [];
148        foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
149            if ($property->isReadOnly()) {
150                continue;
151            }
152            if ($this->appliesToContext($property, $runtimeChecks)) {
153                $list[$property->getName()] = $property;
154            }
155        }
156        foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
157            if (preg_match('/^(set).+$/i', $method->name) && $this->appliesToContext($method, $runtimeChecks) && !$method->isStatic() && !$method->isAbstract()) {
158                $list[lcfirst(substr($method->name, 3))] = $method;
159            }
160        }
161        return new ReflectionHashmap($list);
162    }
163
164    /**
165     * @param ReflectionClass<object> $class
166     */
167    public function getApplicableMethods(ReflectionClass $class, bool $runtimeChecks = true): ReflectionHashmap
168    {
169        $list = [];
170        $filter = function (ReflectionMethod $method) {
171            return !preg_match('/^(__|create|set|get|has|is).+$/i', $method->name);
172        };
173        if ($runtimeChecks && $this->hasContext(ContextConstants::RESOURCE)) {
174            $resource = $this->getContext(ContextConstants::RESOURCE);
175            if ($resource instanceof EntityWithStatesInterface) {
176                $allowedMethods = $resource->provideAllowedMethods()->toArray();
177                $allowedMethodsMap = array_combine($allowedMethods, $allowedMethods);
178                $filter = function (ReflectionMethod $method) use (&$allowedMethodsMap) {
179                    return isset($allowedMethodsMap[$method->name]);
180                };
181            }
182        }
183        foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
184            if ($this->appliesToContext($method, $runtimeChecks) && $filter($method)) {
185                $list[$method->name] = $method;
186            }
187        }
188        return new ReflectionHashmap($list);
189    }
190
191    /**
192     * @param ReflectionClass<object>|ReflectionMethod|ReflectionProperty|ReflectionType|ReflectionEnumUnitCase $method
193     */
194    public function appliesToContext(ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionType|ReflectionEnumUnitCase $method, bool $runtimeChecks = true, ?Throwable $errorToThrow = null): bool
195    {
196        if ($method->getAttributes(Internal::class)) {
197            return false;
198        }
199        $attributesToCheck = $runtimeChecks ? [RuntimeCheck::class, ...self::ATTRIBUTES] : self::ATTRIBUTES;
200        foreach ($attributesToCheck as $attribute) {
201            foreach ($method->getAttributes($attribute) as $reflAttribute) {
202                if (!$reflAttribute->newInstance()->applies($this)) {
203                    if ($errorToThrow) {
204                        throw $errorToThrow;
205                    }
206                    return false;
207                }
208            }
209        }
210        if ($method instanceof ReflectionMethod && $runtimeChecks) {
211            foreach (EntityUtils::getContextParameters($method) as $parameter) {
212                if ($parameter->isDefaultValueAvailable()) {
213                    continue;
214                }
215                $key = $this->getContextKey($this, $parameter);
216                if ($key === null) {
217                    if ($errorToThrow) {
218                        throw new LogicException(
219                            'Parameter ' . $parameter->name . ' has an invalid context key',
220                            0,
221                            $errorToThrow
222                        );
223                    }
224                    return false;
225                }
226                if (!isset($this->context[$key]) && !isset($this->predefined[$key])) {
227                    if ($errorToThrow) {
228                        throw new IndexNotFoundException($key);
229                    }
230                    return false;
231                }
232            }
233        }
234        return true;
235    }
236
237    public function checkAuthorization(): void
238    {
239        try {
240            if (!$this->isAuthorized(runtimeChecks: true, throwError: true)) {
241                throw new ActionNotAllowedException();
242            }
243        } catch (ActionNotAllowedException) {
244            throw new ActionNotAllowedException();
245        } catch (Throwable $error) {
246            throw new ActionNotAllowedException($error);
247        }
248    }
249
250    public function isAuthorized(bool $runtimeChecks, bool $throwError = false): bool
251    {
252        $actionClass = $this->getContext(ContextConstants::APIE_ACTION, $throwError);
253        if (!$actionClass) {
254            return true;
255        }
256        return $actionClass::isAuthorized($this, $runtimeChecks, $throwError);
257    }
258
259    public function dispatchEvent(object $event): object
260    {
261        $dispatcher = $this->context[EventDispatcherInterface::class] ?? null;
262        if ($dispatcher instanceof EventDispatcherInterface) {
263            return $dispatcher->dispatch($event);
264        }
265        return $event;
266    }
267}