Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.45% covered (success)
91.45%
107 / 117
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Route
91.45% covered (success)
91.45%
107 / 117
16.67% covered (danger)
16.67%
1 / 6
35.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 getExcludeIndexPattern
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 combineMiddleware
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 addAction
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 getActionCore
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
2.31
 getAction
98.39% covered (success)
98.39%
61 / 62
0.00% covered (danger)
0.00%
0 / 1
18
1<?php
2
3declare(strict_types=1);
4
5namespace PeServer\Core\Mvc;
6
7use PeServer\Core\Collection\Arr;
8use PeServer\Core\Code;
9use PeServer\Core\Http\HttpMethod;
10use PeServer\Core\Http\HttpStatus;
11use PeServer\Core\Http\RequestPath;
12use PeServer\Core\Mvc\Action;
13use PeServer\Core\Mvc\ControllerBase;
14use PeServer\Core\Mvc\Middleware\IMiddleware;
15use PeServer\Core\Mvc\Middleware\IShutdownMiddleware;
16use PeServer\Core\Mvc\RouteAction;
17use PeServer\Core\Regex;
18use PeServer\Core\Text;
19use PeServer\Core\Throws\ArgumentException;
20
21/**
22 * ルーティング情報。
23 */
24class Route
25{
26    #region define
27
28    /** ミドルウェア指定時に以前をリセットする。 */
29    public const CLEAR_MIDDLEWARE = '*';
30
31    #endregion
32
33    #region variable
34
35    /**
36     * ベースパス。
37     */
38    private readonly string $basePath;
39    /**
40     * クラス完全名。
41     *
42     * @var class-string<ControllerBase>
43     */
44    private readonly string $className;
45    /**
46     * アクション一覧。
47     *
48     * @var array<string,Action>
49     */
50    private array $actions = [];
51
52    /**
53     * ミドルウェア一覧。
54     *
55     * @var array<IMiddleware|class-string<IMiddleware>>
56     */
57    private array $baseMiddleware;
58    /**
59     * 終了ミドルウェア一覧。
60     *
61     * @var array<IShutdownMiddleware|class-string<IShutdownMiddleware>>
62     */
63    private array $baseShutdownMiddleware;
64
65    private Regex $regex;
66
67    #endregion
68
69    /**
70     * ルーティング情報にコントローラを登録
71     *
72     * @param string $path URLとしてのパス。$this->excludeIndexPattern に一致しない場合に index アクションが自動登録される
73     * @param class-string<ControllerBase> $className 使用されるクラス完全名
74     * @param array<IMiddleware|class-string<IMiddleware>> $middleware ベースとなるミドルウェア。
75     * @param array<IShutdownMiddleware|class-string<IShutdownMiddleware>> $shutdownMiddleware ベースとなる終了ミドルウェア。
76     */
77    public function __construct(string $path, string $className, array $middleware = [], array $shutdownMiddleware = [])
78    {
79        $this->regex = new Regex();
80
81        if (Text::isNullOrEmpty($path)) {
82            $this->basePath = $path;
83        } else {
84            $trimPath = Text::trim($path);
85            if ($trimPath !== Text::trim($trimPath, '/')) {
86                throw new ArgumentException('path start or end -> /');
87            }
88            $this->basePath = $trimPath;
89        }
90
91        if (Arr::containsValue($middleware, self::CLEAR_MIDDLEWARE)) {
92            throw new ArgumentException('$middleware');
93        }
94
95        $this->baseMiddleware = $middleware;
96        $this->baseShutdownMiddleware = $shutdownMiddleware;
97        $this->className = $className;
98
99        if (!$this->regex->isMatch($this->basePath, $this->getExcludeIndexPattern())) {
100            $this->addAction(Text::EMPTY, HttpMethod::gets(), 'index', $this->baseMiddleware, $this->baseShutdownMiddleware);
101        }
102    }
103
104    #region function
105
106    /**
107     * コントローラに対してインデックスを付与しないパターン。
108     *
109     * * APIとかにインデックスは不要となる
110     * * このパターンに該当しない場合、無名のアクションとして `index` メソッドが自動登録される。
111     *
112     * @return string
113     * @phpstan-return literal-string
114     */
115    protected function getExcludeIndexPattern(): string
116    {
117        return '/\A(api|ajax)/';
118    }
119
120    /**
121     * ミドルウェア組み合わせ。
122     *
123     * @template TMiddleware of IMiddleware|IShutdownMiddleware
124     *
125     * @param array<IMiddleware|IShutdownMiddleware|class-string> $baseMiddleware
126     * @phpstan-param array<TMiddleware|class-string<TMiddleware>> $baseMiddleware
127     * @param array<IMiddleware|IShutdownMiddleware|class-string>|null $middleware
128     * @phpstan-param array<TMiddleware|class-string<TMiddleware>|self::CLEAR_MIDDLEWARE>|null $middleware
129     * @return array<IMiddleware|IShutdownMiddleware|class-string>
130     * @phpstan-return array<TMiddleware|class-string<TMiddleware>>
131     */
132    private static function combineMiddleware(array $baseMiddleware, ?array $middleware = null): array
133    {
134        $customMiddleware = null;
135        if (Arr::getCount($middleware)) {
136            $customMiddleware = [];
137            foreach ($middleware as $index => $mw) { // @phpstan-ignore-line Arr::getCount
138                if ($index) {
139                    if ($mw === self::CLEAR_MIDDLEWARE) {
140                        throw new ArgumentException();
141                    }
142                    $customMiddleware[] = $mw;
143                } else {
144                    if ($mw !== self::CLEAR_MIDDLEWARE) {
145                        $customMiddleware = array_merge($customMiddleware, $baseMiddleware);
146                        $customMiddleware[] = $mw;
147                    }
148                }
149            }
150        } else {
151            $customMiddleware = $baseMiddleware;
152        }
153
154        return $customMiddleware;
155    }
156
157    /**
158     * アクション設定。
159     *
160     * @param string $actionName URLとして使用されるパス, パス先頭が : でURLパラメータとなり、パラメータ名の @ 以降は一致正規表現となる。
161     * @param HttpMethod|HttpMethod[] $httpMethod 使用するHTTPメソッド。
162     * @param string $methodName 呼び出されるコントローラメソッド。
163     * @param array<IMiddleware|string>|null $middleware 専用ミドルウェア。 第一要素が CLEAR_MIDDLEWARE であれば既存のミドルウェアを破棄する。nullの場合はコンストラクタで渡されたミドルウェアが使用される。
164     * @phpstan-param array<IMiddleware|class-string<IMiddleware>|self::CLEAR_MIDDLEWARE>|null $middleware
165     * @param array<IShutdownMiddleware|string>|null $shutdownMiddleware 専用終了ミドルウェア。 第一要素が CLEAR_MIDDLEWARE であれば既存のミドルウェアを破棄する。nullの場合はコンストラクタで渡されたミドルウェアが使用される。
166     * @phpstan-param array<IShutdownMiddleware|class-string<IShutdownMiddleware>|self::CLEAR_MIDDLEWARE>|null $shutdownMiddleware
167     * @return Route
168     */
169    public function addAction(string $actionName, HttpMethod|array $httpMethod, string $methodName, ?array $middleware = null, ?array $shutdownMiddleware = null): Route
170    {
171        if (Text::isNullOrWhiteSpace($methodName)) {
172            throw new ArgumentException('$methodName');
173        }
174
175        if (!isset($this->actions[$actionName])) {
176            $this->actions[$actionName] = new Action();
177        }
178
179        $customMiddleware = self::combineMiddleware($this->baseMiddleware, $middleware);
180        $customShutdownMiddleware = self::combineMiddleware($this->baseShutdownMiddleware, $shutdownMiddleware);
181
182        $this->actions[$actionName]->add(
183            $httpMethod,
184            $methodName,
185            $customMiddleware,
186            $customShutdownMiddleware
187        );
188
189        return $this;
190    }
191
192    /**
193     * アクション取得内部実装。
194     *
195     * @param HttpMethod $httpMethod
196     * @param Action $action
197     * @param array<non-empty-string,string> $urlParameters
198     * @return RouteAction
199     */
200    private function getActionCore(HttpMethod $httpMethod, Action $action, array $urlParameters): RouteAction
201    {
202        $actionSetting = $action->get($httpMethod);
203        if ($actionSetting === null) {
204            return new RouteAction(
205                HttpStatus::MethodNotAllowed,
206                $this->className,
207                ActionSetting::none(),
208                $urlParameters
209            );
210        }
211
212        return new RouteAction(
213            HttpStatus::None,
214            $this->className,
215            $actionSetting,
216            $urlParameters
217        );
218    }
219
220    /**
221     * メソッド・リクエストパスから登録されているアクションを取得。
222     *
223     * @param HttpMethod $httpMethod HTTPメソッド。
224     * @param RequestPath $requestPath リクエストパス
225     * @return RouteAction|null 存在する場合にクラス・メソッドのペア。存在しない場合は null
226     */
227    public function getAction(HttpMethod $httpMethod, RequestPath $requestPath): ?RouteAction
228    {
229        if (!Text::startsWith($requestPath->full, $this->basePath, false)) {
230            return new RouteAction(
231                HttpStatus::NotFound,
232                $this->className,
233                ActionSetting::none(),
234                []
235            );
236        }
237
238        //$actionPath = $requestPaths[count($requestPaths) - 1];
239        $actionPath = Text::trimStart(Text::substring($requestPath->full, Text::getLength($this->basePath)), '/');
240        $actionPaths = Text::split($actionPath, '/');
241
242        if (!isset($this->actions[$actionPath])) {
243            // URLパラメータチェック
244            foreach ($this->actions as $key => $action) {
245                // 定義内にURLパラメータが無ければ破棄
246                if (!Text::contains($key, ':', false)) {
247                    continue;
248                }
249
250                $keyPaths = Text::split($key, '/');
251                if (Arr::getCount($keyPaths) !== Arr::getCount($actionPaths)) {
252                    continue;
253                }
254
255                /** @var array<array{key:string,name:string,value:string}> */
256                $calcPaths = array_filter(array_map(function ($i, $value) use ($actionPaths) {
257                    $length = Text::getLength($value);
258                    $targetValue = urldecode($actionPaths[$i]);
259                    if ($length === 0 || $value[0] !== ':') {
260                        return ['key' => $value, 'name' => Text::EMPTY, 'value' => $targetValue];
261                    }
262                    $splitPaths = Text::split($value, '@', 2);
263                    $requestKey = Text::substring($splitPaths[0], 1);
264                    $isRegex = 1 < Arr::getCount($splitPaths);
265                    if ($isRegex) {
266                        $pattern = Code::toLiteralString("/$splitPaths[1]/");
267                        if ($this->regex->isMatch($targetValue, $pattern)) {
268                            return ['key' => $value, 'name' => $requestKey, 'value' => $targetValue];
269                        }
270                        return null;
271                    } else {
272                        return ['key' => $value, 'name' => $requestKey, 'value' => $targetValue];
273                    }
274                }, Arr::getKeys($keyPaths), Arr::getValues($keyPaths)), function ($i) {
275                    return $i !== null;
276                });
277
278                $calcPathLength = Arr::getCount($calcPaths);
279                // 非URLパラメータ項目は一致するか
280                if ($calcPathLength !== Arr::getCount($actionPaths)) {
281                    continue;
282                }
283                $success = true;
284                for ($i = 0; $i < $calcPathLength && $success; $i++) {
285                    $calcPath = $calcPaths[$i];
286                    if (Text::isNullOrEmpty($calcPath['name'])) {
287                        $success = $calcPath['key'] === $actionPaths[$i];
288                    }
289                }
290                if (!$success) {
291                    continue;
292                }
293
294                $calcKey = Text::join('/', array_column($calcPaths, 'key'));
295                if ($key !== $calcKey) {
296                    continue;
297                }
298
299                $calcParameters = array_filter($calcPaths, function ($i) {
300                    return !Text::isNullOrEmpty($i['name']);
301                });
302
303                $flatParameters = [];
304                foreach ($calcParameters as $calcParameter) {
305                    $flatParameters[$calcParameter['name']] = $calcParameter['value'];
306                }
307
308                $result = $this->getActionCore($httpMethod, $action, $flatParameters);
309                if ($result->status === HttpStatus::None) {
310                    return $result;
311                }
312            }
313
314            return new RouteAction(
315                HttpStatus::NotFound,
316                $this->className,
317                ActionSetting::none(),
318                []
319            );
320        }
321
322        return $this->getActionCore($httpMethod, $this->actions[$actionPath], []);
323    }
324
325    #endregion
326}