• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

contributte / api-router / 9228273605

24 May 2024 06:17PM UTC coverage: 88.72% (+0.01%) from 88.71%
9228273605

push

github

f3l1x
PHP: require PHP 8.2+

291 of 328 relevant lines covered (88.72%)

0.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.8
/src/ApiRoute.php
1
<?php declare(strict_types = 1);
2

3
namespace Contributte\ApiRouter;
4

5
use Nette\Http\IRequest;
6
use Nette\Http\UrlScript;
7
use Nette\Routing\Router;
8
use Nette\SmartObject;
9
use Nette\Utils\Strings;
10

11
/**
12
 * @Annotation
13
 * @Target({"CLASS", "METHOD"})
14
 */
15
class ApiRoute extends ApiRouteSpec implements Router
16
{
17

18
        use SmartObject;
19

20
        /** @var callable[] */
21
        public array $onMatch = [];
22

23
        private ?string $presenter = null;
24

25
        /** @var array<string, bool> */
26
        private array $actions = [
27
                'POST' => false,
28
                'GET' => false,
29
                'PUT' => false,
30
                'DELETE' => false,
31
                'OPTIONS' => false,
32
                'PATCH' => false,
33
                'HEAD' => false,
34
        ];
35

36
        /** @var array<string, string> */
37
        private array $defaultActions = [
38
                'POST' => 'create',
39
                'GET' => 'read',
40
                'PUT' => 'update',
41
                'DELETE' => 'delete',
42
                'OPTIONS' => 'options',
43
                'PATCH' => 'patch',
44
                'HEAD' => 'head',
45
        ];
46

47
        /** @var array<string, string> */
48
        private array $formats = [
49
                'json' => 'application/json',
50
                'xml' => 'application/xml',
51
        ];
52

53
        /** @var array<mixed> */
54
        private array $placeholderOrder = [];
55

56
        private bool $autoBasePath = true;
57

58
        /**
59
         * @param array<mixed> $data
60
         */
61
        public function __construct(mixed $path, ?string $presenter = null, array $data = [])
1✔
62
        {
63
                /**
64
                 * Interface for setting route via annotation or directly
65
                 */
66
                if (!is_array($path)) {
1✔
67
                        $data['value'] = $path;
1✔
68
                        $data['presenter'] = $presenter;
1✔
69

70
                        if (!isset($data['methods']) || !$data['methods']) {
1✔
71
                                $this->actions = $this->defaultActions;
1✔
72
                        } else {
73
                                foreach ($data['methods'] as $method => $action) {
1✔
74
                                        if (is_string($method)) {
1✔
75
                                                $this->setAction($action, $method);
1✔
76
                                        } else {
77
                                                $m = $action;
×
78

79
                                                if (isset($this->defaultActions[$m])) {
×
80
                                                        $this->setAction($this->defaultActions[$m], $m);
×
81
                                                }
82
                                        }
83
                                }
84

85
                                unset($data['methods']);
1✔
86
                        }
87
                } else {
88
                        $data = $path;
1✔
89
                }
90

91
                /**
92
                 * Set Path
93
                 */
94
                $this->setPath($data['value']);
1✔
95
                unset($data['value']);
1✔
96

97
                parent::__construct($data);
1✔
98
        }
1✔
99

100
        /**
101
         * @phpstan-param array{
102
         *     path: string,
103
         *     presenter: string|null,
104
         *     parameters: array<mixed>,
105
         *     actions: array<string, string>,
106
         *     formats: array<string, string>,
107
         *     placeholderOrder: array<mixed>,
108
         *     disable: bool,
109
         *     autoBasePath: bool
110
         * } $data
111
         */
112
        public static function fromArray(array $data): self
1✔
113
        {
114
                $route = new self($data['path'], $data['presenter'], []);
1✔
115
                $route->parameters = $data['parameters'];
1✔
116
                $route->actions = $data['actions'];
1✔
117
                $route->formats = $data['formats'];
1✔
118
                $route->placeholderOrder = $data['placeholderOrder'];
1✔
119
                $route->disable = $data['disable'];
1✔
120
                $route->autoBasePath = $data['autoBasePath'];
1✔
121

122
                return $route;
1✔
123
        }
124

125
        public function getMask(): string
126
        {
127
                return $this->path;
×
128
        }
129

130
        public function setPresenter(?string $presenter): void
1✔
131
        {
132
                $this->presenter = $presenter;
1✔
133
        }
1✔
134

135
        public function getPresenter(): ?string
136
        {
137
                return $this->presenter;
1✔
138
        }
139

140
        public function setAction(string $action, ?string $method = null): void
1✔
141
        {
142
                if ($method === null) {
1✔
143
                        $method = array_search($action, $this->defaultActions, true);
1✔
144
                }
145

146
                if (!isset($this->defaultActions[$method])) {
1✔
147
                        return;
1✔
148
                }
149

150
                $this->actions[$method] = $action;
1✔
151
        }
1✔
152

153
        public function getAction(string $method): ?string
154
        {
155
                return $this->actions[$method] ?? null;
×
156
        }
157

158
        /**
159
         * Get all parameters from url mask
160
         *
161
         * @return array<mixed>
162
         */
163
        public function getPlacehodlerParameters(): array
164
        {
165
                if ($this->placeholderOrder) {
1✔
166
                        return array_filter($this->placeholderOrder);
×
167
                }
168

169
                $return = [];
1✔
170

171
                // @phpcs:ignore
172
                preg_replace_callback('/<(\w+)>/', function ($item) use (&$return): void {
1✔
173
                        $return[] = end($item);
1✔
174
                }, $this->path);
1✔
175

176
                return $return;
1✔
177
        }
178

179
        /**
180
         * Get required parameters from url mask
181
         *
182
         * @return array<mixed>
183
         */
184
        public function getRequiredParams(): array
185
        {
186
                $regex = '/\[[^\[]+?\]/';
1✔
187
                $path = $this->getPath();
1✔
188

189
                while (preg_match($regex, $path)) {
1✔
190
                        $path = preg_replace($regex, '', $path);
1✔
191
                }
192

193
                $required = [];
1✔
194

195
                // @phpcs:ignore
196
                preg_replace_callback('/<(\w+)>/', function ($item) use (&$required): void {
1✔
197
                        $required[] = end($item);
1✔
198
                }, $path);
1✔
199

200
                return $required;
1✔
201
        }
202

203
        public function resolveFormat(IRequest $httpRequest): void
1✔
204
        {
205
                if ($this->getFormat()) {
1✔
206
                        return;
1✔
207
                }
208

209
                $header = $httpRequest->getHeader('Accept');
×
210

211
                foreach ($this->formats as $format => $format_full) {
×
212
                        $format_full = Strings::replace($format_full, '/\//', '\/');
×
213

214
                        if (Strings::match($header, '/' . $format_full . '/')) {
×
215
                                $this->setFormat($format);
×
216
                        }
217
                }
218

219
                $this->setFormat('json');
×
220
        }
221

222
        public function getFormatFull(): string
223
        {
224
                return $this->formats[$this->getFormat()];
×
225
        }
226

227
        /**
228
         * @param array<string, string> $methods
229
         */
230
        public function setMethods(array $methods): void
231
        {
232
                foreach ($methods as $method => $action) {
×
233
                        if (is_string($method)) {
×
234
                                $this->setAction($action, $method);
×
235
                        } else {
236
                                $m = $action;
×
237

238
                                if (isset($this->defaultActions[$m])) {
×
239
                                        $this->setAction($this->defaultActions[$m], $m);
×
240
                                }
241
                        }
242
                }
243
        }
244

245
        /**
246
         * @return array<string, string>
247
         */
248
        public function getMethods(): array
249
        {
250
                return array_keys(array_filter($this->actions));
1✔
251
        }
252

253
        public function resolveMethod(IRequest $request): string
1✔
254
        {
255
                if ($request->getHeader('X-HTTP-Method-Override')) {
1✔
256
                        return Strings::upper($request->getHeader('X-HTTP-Method-Override'));
1✔
257
                }
258

259
                if ($request->getQuery('__apiRouteMethod')) {
1✔
260
                        $method = Strings::upper($request->getQuery('__apiRouteMethod'));
1✔
261

262
                        if (isset($this->actions[$method])) {
1✔
263
                                return $method;
1✔
264
                        }
265
                }
266

267
                return Strings::upper($request->getMethod());
1✔
268
        }
269

270
        public function setAutoBasePath(bool $autoBasePath): void
1✔
271
        {
272
                $this->autoBasePath = $autoBasePath;
1✔
273
        }
1✔
274

275
        /********************************************************************************
276
         *                              Interface IRouter *
277
         ********************************************************************************/
278

279
        /**
280
         * Maps HTTP request to an array.
281
         *
282
         * @return array<mixed>|null
283
         */
284
        public function match(IRequest $httpRequest): ?array
1✔
285
        {
286
                /**
287
                 * ApiRoute can be easily disabled
288
                 */
289
                if ($this->disable) {
1✔
290
                        return null;
×
291
                }
292

293
                $url = $httpRequest->getUrl();
1✔
294

295
                if ($this->autoBasePath) {
1✔
296
                        // Resolve base path
297
                        $basePath = $url->getBasePath();
1✔
298

299
                        if (strncmp($url->getPath(), $basePath, strlen($basePath)) !== 0) {
1✔
300
                                return null;
×
301
                        }
302

303
                        $path = substr($url->getPath(), strlen($basePath));
1✔
304

305
                        // Ensure start with /
306
                        $path = '/' . ltrim($path, '/');
1✔
307
                } else {
308
                        $path = $url->getPath();
1✔
309
                }
310

311
                // Build path mask
312
                // @phpcs:ignore
313
                $order = &$this->placeholderOrder;
1✔
314
                $parameters = $this->parameters;
1✔
315

316
                // @phpcs:ignore
317
                $mask = preg_replace_callback('/(<(\w+)>)|\[|\]/', function ($item) use (&$order, $parameters) {
1✔
318
                        if ($item[0] === '[' || $item[0] === ']') {
1✔
319
                                if ($item[0] === '[') {
1✔
320
                                        $order[] = null;
1✔
321
                                }
322

323
                                return $item[0];
1✔
324
                        }
325

326
                        [, , $placeholder] = $item;
1✔
327

328
                        $parameter = $parameters[$placeholder] ?? [];
1✔
329

330
                        $regex = $parameter['requirement'] ?? '\w+';
1✔
331
                        $has_default = array_key_exists('default', $parameter);
1✔
332
                        $regex = preg_replace('~\(~', '(?:', $regex);
1✔
333

334
                        if ($has_default) {
1✔
335
                                $order[] = $placeholder;
336

337
                                return sprintf('(%s)?', $regex);
338
                        }
339

340
                        $order[] = $placeholder;
1✔
341

342
                        return sprintf('(%s)', $regex);
1✔
343
                }, $this->path);
1✔
344

345
                $mask = '^' . str_replace(['[', ']'], ['(', ')?'], $mask) . '$';
1✔
346

347
                /**
348
                 * Prepare paths for regex match (escape slashes)
349
                 */
350
                if (!preg_match_all($this->prepareForMatch($mask), $path, $matches)) {
1✔
351
                        return null;
1✔
352
                }
353

354
                /**
355
                 * Did some action to the request method exists?
356
                 */
357
                $this->resolveFormat($httpRequest);
1✔
358
                $method = $this->resolveMethod($httpRequest);
1✔
359
                $action = $this->actions[$method] ?? null;
1✔
360

361
                if (!$action) {
1✔
362
                        return null;
1✔
363
                }
364

365
                /**
366
                 * Basic params
367
                 */
368
                $params = $httpRequest->getQuery();
1✔
369
                $required_params = $this->getRequiredParams();
1✔
370

371
                /**
372
                 * Route mask parameters
373
                 */
374
                array_shift($matches);
1✔
375

376
                foreach ($this->placeholderOrder as $key => $name) {
1✔
377
                        if ($name !== null && isset($matches[$key])) {
1✔
378
                                $params[$name] = reset($matches[$key]) ?: null;
1✔
379

380
                                /**
381
                                 * Required parameters
382
                                 */
383
                                if (!$params[$name] && in_array($name, $required_params, true)) {
1✔
384
                                        return null;
×
385
                                }
386
                        }
387
                }
388

389
                $xs = array_merge([
1✔
390
                        'presenter' => $this->presenter,
1✔
391
                        'action' => $action,
1✔
392
                        'method' => $method,
1✔
393
                        'post' => $httpRequest->getPost(),
1✔
394
                        'files' => $httpRequest->getFiles(),
1✔
395
                ], $params);
396

397
                /**
398
                 * Trigger event - route matches
399
                 */
400
                $this->onMatch($this, $xs);
1✔
401

402
                return $xs;
1✔
403
        }
404

405
        /**
406
         * Constructs absolute URL from array.
407
         *
408
         * @param array<mixed> $params
409
         */
410
        public function constructUrl(array $params, UrlScript $url): ?string
1✔
411
        {
412
                if ($this->presenter !== $params['presenter']) {
1✔
413
                        return null;
1✔
414
                }
415

416
                $base_url = $url->getBaseUrl();
1✔
417

418
                $action = $params['action'];
1✔
419
                unset($params['presenter']);
1✔
420
                unset($params['action']);
1✔
421
                $parameters = $params;
1✔
422
                $path = ltrim($this->getPath(), '/');
1✔
423

424
                if (array_search($action, $this->actions, true) === false) {
1✔
425
                        return null;
×
426
                }
427

428
                foreach ($parameters as $name => $value) {
1✔
429
                        if (strpos($path, '<' . $name . '>') !== false && $value !== null) {
1✔
430
                                $path = str_replace('<' . $name . '>', (string) $value, $path);
1✔
431

432
                                unset($parameters[$name]);
1✔
433
                        }
434
                }
435

436
                $path = preg_replace_callback('/\[.+?\]/', function ($item) {
1✔
437
                        if (strpos(end($item), '<')) {
1✔
438
                                return '';
439
                        }
440

441
                        return end($item);
1✔
442
                }, $path);
1✔
443

444
                /**
445
                 * There are still some required parameters in url mask
446
                 */
447
                if (preg_match('/<\w+>/', $path)) {
1✔
448
                        return null;
×
449
                }
450

451
                $path = str_replace(['[', ']'], '', $path);
1✔
452

453
                $query = http_build_query($parameters);
1✔
454

455
                return $base_url . $path . ($query ? '?' . $query : '');
1✔
456
        }
457

458
        /**
459
         * @return array{
460
         *     path: string,
461
         *     presenter: string|null,
462
         *     parameters: array<mixed>,
463
         *     actions: array<string, string>,
464
         *     formats: array<string, string>,
465
         *     placeholderOrder: array<mixed>,
466
         *     disable: bool,
467
         *     autoBasePath: bool
468
         * }
469
         */
470
        public function toArray(): array
471
        {
472
                return [
473
                        'path' => $this->path,
1✔
474
                        'presenter' => $this->presenter,
1✔
475
                        'parameters' => $this->parameters,
1✔
476
                        'actions' => $this->actions,
1✔
477
                        'formats' => $this->formats,
1✔
478
                        'placeholderOrder' => $this->placeholderOrder,
1✔
479
                        'disable' => $this->disable,
1✔
480
                        'autoBasePath' => $this->autoBasePath,
1✔
481
                ];
482
        }
483

484
        private function prepareForMatch(string $string): string
1✔
485
        {
486
                return sprintf('/%s/', str_replace('/', '\/', $string));
1✔
487
        }
488

489
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc