Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
91.45% |
107 / 117 |
|
16.67% |
1 / 6 |
CRAP | |
0.00% |
0 / 1 |
Route | |
91.45% |
107 / 117 |
|
16.67% |
1 / 6 |
35.76 | |
0.00% |
0 / 1 |
__construct | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
5.01 | |||
getExcludeIndexPattern | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
combineMiddleware | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
6.02 | |||
addAction | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
3.00 | |||
getActionCore | |
57.14% |
8 / 14 |
|
0.00% |
0 / 1 |
2.31 | |||
getAction | |
98.39% |
61 / 62 |
|
0.00% |
0 / 1 |
18 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace PeServer\Core\Mvc; |
6 | |
7 | use PeServer\Core\Collection\Arr; |
8 | use PeServer\Core\Code; |
9 | use PeServer\Core\Http\HttpMethod; |
10 | use PeServer\Core\Http\HttpStatus; |
11 | use PeServer\Core\Http\RequestPath; |
12 | use PeServer\Core\Mvc\Action; |
13 | use PeServer\Core\Mvc\ControllerBase; |
14 | use PeServer\Core\Mvc\Middleware\IMiddleware; |
15 | use PeServer\Core\Mvc\Middleware\IShutdownMiddleware; |
16 | use PeServer\Core\Mvc\RouteAction; |
17 | use PeServer\Core\Regex; |
18 | use PeServer\Core\Text; |
19 | use PeServer\Core\Throws\ArgumentException; |
20 | |
21 | /** |
22 | * ルーティング情報。 |
23 | */ |
24 | class 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 | } |