Issues (141)

src/Linker.php (1 issue)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace BEAR\Resource;
6
7
use BEAR\Resource\Annotation\Link;
8
use BEAR\Resource\Exception\LinkQueryException;
9
use BEAR\Resource\Exception\LinkRelException;
10
use BEAR\Resource\Exception\MethodException;
11
use BEAR\Resource\Exception\UriException;
12
use Override;
13
use Ray\Aop\ReflectionMethod;
14
15
use function array_filter;
16
use function array_key_exists;
17
use function array_keys;
18
use function array_pop;
19
use function assert;
20
use function count;
21
use function is_array;
22
use function is_numeric;
23
use function ucfirst;
24
use function uri_template;
25
26
/**
27
 * @psalm-import-type Body from Types
28
 * @psalm-import-type BodyOrStringList from Types
29
 * @psalm-import-type ObjectList from Types
30
 * @psalm-import-type Query from Types
31
 * @psalm-import-type QueryList from Types
32
 */
33
final class Linker implements LinkerInterface
34
{
35
    /**
36
     * memory cache for linker
37
     *
38
     * @var Query
39
     */
40 54
    private array $cache = [];
41
42
    public function __construct(
43
        private readonly InvokerInterface $invoker,
44
        private readonly FactoryInterface $factory,
45 54
    ) {
46 54
    }
47 54
48 54
    /**
49
     * {@inheritDoc}
50
     */
51
    #[Override]
52
    public function invoke(AbstractRequest $request)
53
    {
54
        $this->cache = [];
55
56 10
        return $this->invokeRecursive($request);
57
    }
58 10
59 10
    /**
60 10
     * @throws LinkQueryException
61
     * @throws LinkRelException
62 10
     */
63 8
    private function invokeRecursive(AbstractRequest $request): ResourceObject
64
    {
65
        $this->invoker->invoke($request);
66 8
        $current = clone $request->resourceObject;
67
        if ($current->code >= Code::BAD_REQUEST) {
68
            return $current;
69
        }
70
71
        foreach ($request->links as $link) {
72 8
            /** @var Body $nextBody */
73
            $nextBody = $this->annotationLink($link, $current, $request)->body;
74 8
            $current = $this->nextLink($link, $current, $nextBody);
75
        }
76 8
77 2
        return $current;
78
    }
79 2
80
    /**
81
     * @param Body $nextResource
82 6
     *
83 2
     * @return ResourceObject
84
     */
85 2
    private function nextLink(LinkType $link, ResourceObject $ro, array $nextResource): ResourceObject
86
    {
87
        /** @psalm-suppress MixedAssignment */
88
        $nextBody = $nextResource;
89 4
90
        if ($link->type === LinkType::SELF_LINK) {
91
            $ro->body = $nextBody;
92
93
            return $ro;
94
        }
95
96
        if ($link->type === LinkType::NEW_LINK) {
97
            assert(is_array($ro->body) || $ro->body === null);
98
            $ro->body[$link->key] = $nextBody;
99
100
            return $ro;
101 10
        }
102
103 10
        // crawl
104 1
        return $ro;
105
    }
106 9
107 9
    /**
108 9
     * Annotation link
109 4
     *
110
     * @throws MethodException
111
     * @throws LinkRelException
112
     * @throws Exception\LinkQueryException
113 5
     */
114
    private function annotationLink(LinkType $link, ResourceObject $current, AbstractRequest $request): ResourceObject
115
    {
116
        if (! is_array($current->body)) {
117
            throw new Exception\LinkQueryException('Only array is allowed for link in ' . $current::class, 500);
118
        }
119
120
        $classMethod = 'on' . ucfirst($request->method);
121
        /** @var list<Link> $annotations */
122
        $annotations = (new ReflectionMethod($current::class, $classMethod))->getAnnotations();
123
        if ($link->type === LinkType::CRAWL_LINK) {
124
            return $this->annotationCrawl($annotations, $link, $current);
125
        }
126 5
127
        /* @noinspection ExceptionsAnnotatingAndHandlingInspection */
128
        return $this->annotationRel($annotations, $link, $current);
129 5
    }
130 5
131 1
    /**
132
     * Annotation link (new, self)
133 4
     *
134 4
     * @param Link[] $annotations
135
     *
136 4
     * @throws UriException
137
     * @throws MethodException
138 4
     * @throws Exception\LinkQueryException
139
     * @throws Exception\LinkRelException
140
     */
141 1
    private function annotationRel(array $annotations, LinkType $link, ResourceObject $current): ResourceObject
142
    {
143
        /* @noinspection LoopWhichDoesNotLoopInspection */
144
        foreach ($annotations as $annotation) {
145
            if ($annotation->rel !== $link->key) {
146
                continue;
147
            }
148
149 4
            $uri = uri_template($annotation->href, (array) $current->body);
150
            $rel = $this->factory->newInstance($uri);
151 4
            /* @noinspection UnnecessaryParenthesesInspection */
152 4
            $query = (new Uri($uri))->query;
153 4
            $request = new Request($this->invoker, $rel, Request::GET, $query);
154
155 4
            return $this->invoker->invoke($request);
156
        }
157 4
158 4
        throw new LinkRelException("rel:{$link->key} class:" . $current::class, 500);
159
    }
160 4
161
    /**
162
     * Link annotation crawl
163
     *
164
     * @param ObjectList $annotations
165
     *
166
     * @throws MethodException
167
     */
168
    private function annotationCrawl(array $annotations, LinkType $link, ResourceObject $current): ResourceObject
169 4
    {
170
        $isList = $this->isList($current->body);
171 4
        /** @var QueryList $bodyList */
172
        $bodyList = $isList ? (array) $current->body : [$current->body];
173 4
        foreach ($bodyList as &$body) {
174 3
            $this->crawl($annotations, $link, $body);
175
        }
176 4
177 4
        unset($body);
178
        /** @psalm-suppress PossiblyUndefinedArrayOffset */
179 4
        $current->body = $isList ? $bodyList : $bodyList[0];
180 4
181 4
        return $current;
182 3
    }
183
184 3
    /**
185
     * @param ObjectList $annotations
186 4
     * @param Body       $body
187
     *
188 4
     * @throws LinkQueryException
189
     * @throws MethodException
190 4
     * @throws LinkRelException
191
     * @throws UriException
192 4
     *
193 4
     * @param-out Body $body
194 4
     */
195 4
    private function crawl(array $annotations, LinkType $link, array &$body): void
196 4
    {
197 4
        foreach ($annotations as $annotation) {
198
            if (! $annotation instanceof Link || $annotation->crawl !== $link->key) {
199 4
                continue;
200
            }
201
202 4
            $uri = uri_template($annotation->href, $body);
203
            $rel = $this->factory->newInstance($uri);
204 4
            /* @noinspection UnnecessaryParenthesesInspection */
205 4
            $query = (new Uri($uri))->query;
206
            $request = new Request($this->invoker, $rel, Request::GET, $query, [$link], $this);
207
            $hash = $request->hash();
208 4
            if (array_key_exists($hash, $this->cache)) {
209 4
                /** @var Body $cachedResponse */
210 4
                $cachedResponse = $this->cache[$hash];
211
                $body[$annotation->rel] = $cachedResponse;
212
                continue;
213
            }
214 4
215
            $this->cache[$hash] = $body[$annotation->rel] = $this->getResponseBody($request);
216
        }
217 4
    }
218
219 4
    /** @return Body|null */
220
    private function getResponseBody(Request $request): array|null
221
    {
222 4
        $body = $this->invokeRecursive($request)->body;
223
        assert(is_array($body) || $body === null);
224 4
225
        return $body;
226
    }
227
228
    private function isList(mixed $value): bool
229
    {
230
        assert(is_array($value));
231
        /** @var BodyOrStringList $list */
232
        $list = $value;
233
        /** @var Body $firstRow */
234
        $firstRow = array_pop($list);
0 ignored issues
show
$list of type BEAR\Resource\BodyOrStringList is incompatible with the type array expected by parameter $array of array_pop(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

234
        $firstRow = array_pop(/** @scrutinizer ignore-type */ $list);
Loading history...
235
        /** @var Query|string $firstRow */
236
        $keys = array_keys((array) $firstRow);
237
        $isMultiColumnMultiRowList = $this->isMultiColumnMultiRowList($keys, $list);
238
        $isMultiColumnList = $this->isMultiColumnList($value, $firstRow);
239
        $isSingleColumnList = $this->isSingleColumnList($value, $keys, $list);
240
241
        return $isSingleColumnList || $isMultiColumnMultiRowList || $isMultiColumnList;
242
    }
243
244
    /**
245
     * @param list<array-key>  $keys
246
     * @param BodyOrStringList $list
247
     */
248
    private function isMultiColumnMultiRowList(array $keys, array $list): bool
249
    {
250
        if ($keys === [0 => 0]) {
251
            return false;
252
        }
253
254
        foreach ($list as $item) {
255
            if ($keys !== array_keys((array) $item)) {
256
                return false;
257
            }
258
        }
259
260
        return true;
261
    }
262
263
    /**
264
     * @param Body $value
265
     * @psalm-param Query|scalar $firstRow
266
     */
267
    private function isMultiColumnList(array $value, mixed $firstRow): bool
268
    {
269
        return is_array($firstRow) && array_filter(array_keys($value), is_numeric(...)) === array_keys($value);
270
    }
271
272
    /**
273
     * @param Body            $value
274
     * @param list<array-key> $keys
275
     * @param Body            $list
276
     */
277
    private function isSingleColumnList(array $value, array $keys, array $list): bool
278
    {
279
        return (count($value) === 1) && $keys === array_keys($list);
280
    }
281
}
282