Completed
Push — 1.x ( de29eb...29ffe6 )
by Akihito
21s queued 10s
created

Linker::invokeRecursive()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 16
ccs 8
cts 8
cp 1
rs 9.7333
c 0
b 0
f 0
cc 3
nc 3
nop 1
crap 3
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 Doctrine\Common\Annotations\Reader;
13
use 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 get_class;
22
use function is_array;
23
use function ucfirst;
24
25
/**
26
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
27
 */
28
final class Linker implements LinkerInterface
29
{
30
    /** @var Reader */
31
    private $reader;
32
33
    /** @var InvokerInterface */
34
    private $invoker;
35
36
    /** @var FactoryInterface */
37
    private $factory;
38
39
    /**
40 54
     * memory cache for linker
41
     *
42
     * @var array<string, mixed>
43
     */
44
    private $cache = [];
45 54
46 54
    public function __construct(
47 54
        Reader $reader,
48 54
        InvokerInterface $invoker,
49
        FactoryInterface $factory
50
    ) {
51
        $this->reader = $reader;
52
        $this->invoker = $invoker;
53
        $this->factory = $factory;
54
    }
55
56 10
    /**
57
     * {@inheritdoc}
58 10
     */
59 10
    public function invoke(AbstractRequest $request)
60 10
    {
61
        $this->cache = [];
62 10
63 8
        return $this->invokeRecursive($request);
64
    }
65
66 8
    /**
67
     * @throws LinkQueryException
68
     * @throws LinkRelException
69
     */
70
    private function invokeRecursive(AbstractRequest $request): ResourceObject
71
    {
72 8
        $this->invoker->invoke($request);
73
        $current = clone $request->resourceObject;
74 8
        if ($current->code >= Code::BAD_REQUEST) {
75
            return $current;
76 8
        }
77 2
78
        foreach ($request->links as $link) {
79 2
            /** @var array<mixed> $nextBody */
80
            $nextBody = $this->annotationLink($link, $current, $request)->body;
81
            $current = $this->nextLink($link, $current, $nextBody);
82 6
        }
83 2
84
        return $current;
85 2
    }
86
87
    /**
88
     * How next linked resource treated (add ? replace ?)
89 4
     *
90
     * @param mixed|ResourceObject $nextResource
91
     */
92
    private function nextLink(LinkType $link, ResourceObject $ro, $nextResource): ResourceObject
93
    {
94
        /** @var array<mixed> $nextBody */
95
        $nextBody = $nextResource instanceof ResourceObject ? $nextResource->body : $nextResource;
96
97
        if ($link->type === LinkType::SELF_LINK) {
98
            $ro->body = $nextBody;
99
100
            return $ro;
101 10
        }
102
103 10
        if ($link->type === LinkType::NEW_LINK) {
104 1
            assert(is_array($ro->body) || $ro->body === null);
105
            $ro->body[$link->key] = $nextBody;
106 9
107 9
            return $ro;
108 9
        }
109 4
110
        // crawl
111
        return $ro;
112
    }
113 5
114
    /**
115
     * Annotation link
116
     *
117
     * @throws MethodException
118
     * @throws LinkRelException
119
     * @throws Exception\LinkQueryException
120
     */
121
    private function annotationLink(LinkType $link, ResourceObject $current, AbstractRequest $request): ResourceObject
122
    {
123
        if (! is_array($current->body)) {
124
            throw new Exception\LinkQueryException('Only array is allowed for link in ' . get_class($current), 500);
125
        }
126 5
127
        $classMethod = 'on' . ucfirst($request->method);
128
        /** @var list<Link> $annotations */
0 ignored issues
show
Documentation introduced by
The doc-type list<Link> could not be parsed: Expected "|" or "end of type", but got "<" at position 4. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
129 5
        $annotations = $this->reader->getMethodAnnotations(new ReflectionMethod(get_class($current), $classMethod));
130 5
        if ($link->type === LinkType::CRAWL_LINK) {
131 1
            return $this->annotationCrawl($annotations, $link, $current);
132
        }
133 4
134 4
        /* @noinspection ExceptionsAnnotatingAndHandlingInspection */
135
        return $this->annotationRel($annotations, $link, $current);
136 4
    }
137
138 4
    /**
139
     * Annotation link (new, self)
140
     *
141 1
     * @param Link[] $annotations
142
     *
143
     * @throws UriException
144
     * @throws MethodException
145
     * @throws Exception\LinkQueryException
146
     * @throws Exception\LinkRelException
147
     */
148
    private function annotationRel(array $annotations, LinkType $link, ResourceObject $current): ResourceObject
149 4
    {
150
        /* @noinspection LoopWhichDoesNotLoopInspection */
151 4
        foreach ($annotations as $annotation) {
152 4
            if ($annotation->rel !== $link->key) {
153 4
                continue;
154
            }
155 4
156
            $uri = uri_template($annotation->href, (array) $current->body);
157 4
            $rel = $this->factory->newInstance($uri);
158 4
            /* @noinspection UnnecessaryParenthesesInspection */
159
            $query = (new Uri($uri))->query;
160 4
            $request = new Request($this->invoker, $rel, Request::GET, $query);
161
162
            return $this->invoker->invoke($request);
163
        }
164
165
        throw new LinkRelException("rel:{$link->key} class:" . get_class($current), 500);
166
    }
167
168
    /**
169 4
     * Link annotation crawl
170
     *
171 4
     * @param array<object> $annotations
172
     *
173 4
     * @throws MethodException
174 3
     */
175
    private function annotationCrawl(array $annotations, LinkType $link, ResourceObject $current): ResourceObject
176 4
    {
177 4
        $isList = $this->isList($current->body);
178
        /** @var array<array<string, mixed>> $bodyList */
179 4
        $bodyList = $isList ? (array) $current->body : [$current->body];
180 4
        foreach ($bodyList as &$body) {
181 4
            $this->crawl($annotations, $link, $body);
182 3
        }
183
184 3
        unset($body);
185
        $current->body = $isList ? $bodyList : $bodyList[0];
186 4
187
        return $current;
188 4
    }
189
190 4
    /**
191
     * @param array<object>        $annotations
192 4
     * @param array<string, mixed> $body
193 4
     *
194 4
     * @throws LinkQueryException
195 4
     * @throws MethodException
196 4
     * @throws LinkRelException
197 4
     * @throws UriException
198
     *
199 4
     * @param-out array $body
200
     */
201
    private function crawl(array $annotations, LinkType $link, array &$body): void
202 4
    {
203
        foreach ($annotations as $annotation) {
204 4
            if (! $annotation instanceof Link || $annotation->crawl !== $link->key) {
205 4
                continue;
206
            }
207
208 4
            $uri = uri_template($annotation->href, $body);
209 4
            $rel = $this->factory->newInstance($uri);
210 4
            /* @noinspection UnnecessaryParenthesesInspection */
211
            $query = (new Uri($uri))->query;
212
            $request = new Request($this->invoker, $rel, Request::GET, $query, [$link], $this);
213
            $hash = $request->hash();
214 4
            if (array_key_exists($hash, $this->cache)) {
215
                /** @var array<array<string, scalar|array>>  $cachedResponse */
216
                $cachedResponse = $this->cache[$hash];
217 4
                $body[$annotation->rel] = $cachedResponse;
218
                continue;
219 4
            }
220
221
            $this->cache[$hash] = $body[$annotation->rel] = $this->getResponseBody($request);
222 4
        }
223
    }
224 4
225
    /**
226
     * @return array<mixed>
227
     */
228
    private function getResponseBody(Request $request): ?array
229
    {
230
        $body = $this->invokeRecursive($request)->body;
231
        assert(is_array($body) || $body === null);
232
233
        return $body;
234
    }
235
236
    /**
237
     * @param mixed $value
238
     */
239
    private function isList($value): bool
240
    {
241
        assert(is_array($value));
242
        /** @var array<array> $list */
243
        $list = $value;
244
        /** @var array<mixed> $firstRow */
245
        $firstRow = array_pop($list);
246
        $keys = array_keys((array) $firstRow);
247
        $isMultiColumnMultiRowList = $this->isMultiColumnMultiRowList($keys, $list);
248
        $isMultiColumnList = $this->isMultiColumnList($value, $firstRow);
249
        $isSingleColumnList = $this->isSingleColumnList($value, $keys, $list);
250
251
        return $isSingleColumnList || $isMultiColumnMultiRowList || $isMultiColumnList;
252
    }
253
254
    /**
255
     * @param array<int, int|string> $keys
256
     * @param array<array<mixed>>    $list
257
     */
258
    private function isMultiColumnMultiRowList(array $keys, array $list): bool
259
    {
260
        if ($keys === [0 => 0]) {
261
            return false;
262
        }
263
264
        foreach ($list as $item) {
265
            if ($keys !== array_keys((array) $item)) {
266
                return false;
267
            }
268
        }
269
270
        return true;
271
    }
272
273
    /**
274
     * @param array<int|string, mixed> $value
275
     * @param mixed                    $firstRow
276
     */
277
    private function isMultiColumnList(array $value, $firstRow): bool
278
    {
279
        return is_array($firstRow) && array_filter(array_keys($value), 'is_numeric') === array_keys($value);
280
    }
281
282
    /**
283
     * @param array<int|string, mixed> $value
284
     * @param list<array-key>          $keys
0 ignored issues
show
Documentation introduced by
The doc-type list<array-key> could not be parsed: Expected "|" or "end of type", but got "<" at position 4. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
285
     * @param array<mixed, mixed>      $list
286
     */
287
    private function isSingleColumnList(array $value, array $keys, array $list): bool
288
    {
289
        return (count($value) === 1) && $keys === array_keys($list);
290
    }
291
}
292