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

contributte / api-router / 19974859358

05 Dec 2025 08:16PM UTC coverage: 88.272% (-0.4%) from 88.72%
19974859358

push

github

f3l1x
CI: add PHP 8.5

286 of 324 relevant lines covered (88.27%)

0.88 hits per line

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

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

3
namespace Contributte\ApiRouter;
4

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

12
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
13
class ApiRoute extends ApiRouteSpec implements Router
14
{
15

16
        use SmartObject;
17

18
        /** @var callable[] */
19
        public array $onMatch = [];
20

21
        private ?string $presenter = null;
22

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

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

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

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

54
        private bool $autoBasePath = true;
55

56
        /**
57
         * @param array<mixed> $parameters
58
         * @param array<string, string> $methods
59
         * @param array<mixed>|null $example
60
         * @param array<string> $tags
61
         * @param array<int> $response_codes
62
         */
63
        public function __construct(
1✔
64
                string $path,
65
                ?string $presenter = null,
66
                array $parameters = [],
67
                array $methods = [],
68
                ?string $description = null,
69
                ?string $method = null,
70
                int $priority = 0,
71
                string $format = 'json',
72
                ?array $example = null,
73
                ?string $section = null,
74
                array $tags = [],
75
                array $response_codes = [],
76
                bool $disable = false,
77
        )
78
        {
79
                if ($methods === []) {
1✔
80
                        $this->actions = $this->defaultActions;
1✔
81
                } else {
82
                        foreach ($methods as $httpMethod => $action) {
1✔
83
                                if (is_string($httpMethod)) {
1✔
84
                                        $this->setAction($action, $httpMethod);
1✔
85
                                } else {
86
                                        $m = $action;
×
87

88
                                        if (isset($this->defaultActions[$m])) {
×
89
                                                $this->setAction($this->defaultActions[$m], $m);
×
90
                                        }
91
                                }
92
                        }
93
                }
94

95
                $this->setPath($path);
1✔
96
                $this->presenter = $presenter;
1✔
97

98
                $data = array_filter([
1✔
99
                        'parameters' => $parameters,
1✔
100
                        'description' => $description,
1✔
101
                        'method' => $method,
1✔
102
                        'priority' => $priority,
1✔
103
                        'format' => $format,
1✔
104
                        'example' => $example,
1✔
105
                        'section' => $section,
1✔
106
                        'tags' => $tags,
1✔
107
                        'response_codes' => $response_codes,
1✔
108
                        'disable' => $disable,
1✔
109
                ], fn ($v, $k) => match ($k) {
1✔
110
                        'priority' => $v !== 0,
1✔
111
                        'format' => $v !== 'json',
1✔
112
                        'disable' => $v !== false,
1✔
113
                        default => $v !== null && $v !== [],
1✔
114
                }, ARRAY_FILTER_USE_BOTH);
1✔
115

116
                parent::__construct($data);
1✔
117
        }
1✔
118

119
        /**
120
         * @phpstan-param array{
121
         *     path: string,
122
         *     presenter: string|null,
123
         *     parameters: array<mixed>,
124
         *     actions: array<string, string>,
125
         *     formats: array<string, string>,
126
         *     placeholderOrder: array<mixed>,
127
         *     disable: bool,
128
         *     autoBasePath: bool
129
         * } $data
130
         */
131
        public static function fromArray(array $data): self
1✔
132
        {
133
                $route = new self($data['path'], $data['presenter'], []);
1✔
134
                $route->parameters = $data['parameters'];
1✔
135
                $route->actions = $data['actions'];
1✔
136
                $route->formats = $data['formats'];
1✔
137
                $route->placeholderOrder = $data['placeholderOrder'];
1✔
138
                $route->disable = $data['disable'];
1✔
139
                $route->autoBasePath = $data['autoBasePath'];
1✔
140

141
                return $route;
1✔
142
        }
143

144
        public function getMask(): string
145
        {
146
                return $this->path;
×
147
        }
148

149
        public function setPresenter(?string $presenter): void
1✔
150
        {
151
                $this->presenter = $presenter;
1✔
152
        }
1✔
153

154
        public function getPresenter(): ?string
155
        {
156
                return $this->presenter;
1✔
157
        }
158

159
        public function setAction(string $action, ?string $method = null): void
1✔
160
        {
161
                if ($method === null) {
1✔
162
                        $method = array_search($action, $this->defaultActions, true);
1✔
163
                }
164

165
                if (!isset($this->defaultActions[$method])) {
1✔
166
                        return;
1✔
167
                }
168

169
                $this->actions[$method] = $action;
1✔
170
        }
1✔
171

172
        public function getAction(string $method): ?string
173
        {
174
                return $this->actions[$method] ?? null;
×
175
        }
176

177
        /**
178
         * Get all parameters from url mask
179
         *
180
         * @return array<mixed>
181
         */
182
        public function getPlacehodlerParameters(): array
183
        {
184
                if ($this->placeholderOrder) {
1✔
185
                        return array_filter($this->placeholderOrder);
×
186
                }
187

188
                $return = [];
1✔
189

190
                // @phpcs:ignore
191
                preg_replace_callback('/<(\w+)>/', function ($item) use (&$return): void {
1✔
192
                        $return[] = end($item);
1✔
193
                }, $this->path);
1✔
194

195
                return $return;
1✔
196
        }
197

198
        /**
199
         * Get required parameters from url mask
200
         *
201
         * @return array<mixed>
202
         */
203
        public function getRequiredParams(): array
204
        {
205
                $regex = '/\[[^\[]+?\]/';
1✔
206
                $path = $this->getPath();
1✔
207

208
                while (preg_match($regex, $path)) {
1✔
209
                        $path = preg_replace($regex, '', $path);
1✔
210
                }
211

212
                $required = [];
1✔
213

214
                // @phpcs:ignore
215
                preg_replace_callback('/<(\w+)>/', function ($item) use (&$required): void {
1✔
216
                        $required[] = end($item);
1✔
217
                }, $path);
1✔
218

219
                return $required;
1✔
220
        }
221

222
        public function resolveFormat(IRequest $httpRequest): void
1✔
223
        {
224
                if ($this->getFormat()) {
1✔
225
                        return;
1✔
226
                }
227

228
                $header = $httpRequest->getHeader('Accept');
×
229

230
                foreach ($this->formats as $format => $format_full) {
×
231
                        $format_full = Strings::replace($format_full, '/\//', '\/');
×
232

233
                        if (Strings::match($header, '/' . $format_full . '/')) {
×
234
                                $this->setFormat($format);
×
235
                        }
236
                }
237

238
                $this->setFormat('json');
×
239
        }
240

241
        public function getFormatFull(): string
242
        {
243
                return $this->formats[$this->getFormat()];
×
244
        }
245

246
        /**
247
         * @param array<string, string> $methods
248
         */
249
        public function setMethods(array $methods): void
250
        {
251
                foreach ($methods as $method => $action) {
×
252
                        if (is_string($method)) {
×
253
                                $this->setAction($action, $method);
×
254
                        } else {
255
                                $m = $action;
×
256

257
                                if (isset($this->defaultActions[$m])) {
×
258
                                        $this->setAction($this->defaultActions[$m], $m);
×
259
                                }
260
                        }
261
                }
262
        }
263

264
        /**
265
         * @return array<string, string>
266
         */
267
        public function getMethods(): array
268
        {
269
                return array_keys(array_filter($this->actions));
1✔
270
        }
271

272
        public function resolveMethod(IRequest $request): string
1✔
273
        {
274
                if ($request->getHeader('X-HTTP-Method-Override')) {
1✔
275
                        return Strings::upper($request->getHeader('X-HTTP-Method-Override'));
1✔
276
                }
277

278
                if ($request->getQuery('__apiRouteMethod')) {
1✔
279
                        $method = Strings::upper($request->getQuery('__apiRouteMethod'));
1✔
280

281
                        if (isset($this->actions[$method])) {
1✔
282
                                return $method;
1✔
283
                        }
284
                }
285

286
                return Strings::upper($request->getMethod());
1✔
287
        }
288

289
        public function setAutoBasePath(bool $autoBasePath): void
1✔
290
        {
291
                $this->autoBasePath = $autoBasePath;
1✔
292
        }
1✔
293

294
        /********************************************************************************
295
         *                              Interface IRouter *
296
         ********************************************************************************/
297

298
        /**
299
         * Maps HTTP request to an array.
300
         *
301
         * @return array<mixed>|null
302
         */
303
        public function match(IRequest $httpRequest): ?array
1✔
304
        {
305
                /**
306
                 * ApiRoute can be easily disabled
307
                 */
308
                if ($this->disable) {
1✔
309
                        return null;
×
310
                }
311

312
                $url = $httpRequest->getUrl();
1✔
313

314
                if ($this->autoBasePath) {
1✔
315
                        // Resolve base path
316
                        $basePath = $url->getBasePath();
1✔
317

318
                        if (strncmp($url->getPath(), $basePath, strlen($basePath)) !== 0) {
1✔
319
                                return null;
×
320
                        }
321

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

324
                        // Ensure start with /
325
                        $path = '/' . ltrim($path, '/');
1✔
326
                } else {
327
                        $path = $url->getPath();
1✔
328
                }
329

330
                // Build path mask
331
                // @phpcs:ignore
332
                $order = &$this->placeholderOrder;
1✔
333
                $parameters = $this->parameters;
1✔
334

335
                // @phpcs:ignore
336
                $mask = preg_replace_callback('/(<(\w+)>)|\[|\]/', function ($item) use (&$order, $parameters) {
1✔
337
                        if ($item[0] === '[' || $item[0] === ']') {
1✔
338
                                if ($item[0] === '[') {
1✔
339
                                        $order[] = null;
1✔
340
                                }
341

342
                                return $item[0];
1✔
343
                        }
344

345
                        [, , $placeholder] = $item;
1✔
346

347
                        $parameter = $parameters[$placeholder] ?? [];
1✔
348

349
                        $regex = $parameter['requirement'] ?? '\w+';
1✔
350
                        $has_default = array_key_exists('default', $parameter);
1✔
351
                        $regex = preg_replace('~\(~', '(?:', $regex);
1✔
352

353
                        if ($has_default) {
1✔
354
                                $order[] = $placeholder;
355

356
                                return sprintf('(%s)?', $regex);
357
                        }
358

359
                        $order[] = $placeholder;
1✔
360

361
                        return sprintf('(%s)', $regex);
1✔
362
                }, $this->path);
1✔
363

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

366
                /**
367
                 * Prepare paths for regex match (escape slashes)
368
                 */
369
                if (!preg_match_all($this->prepareForMatch($mask), $path, $matches)) {
1✔
370
                        return null;
1✔
371
                }
372

373
                /**
374
                 * Did some action to the request method exists?
375
                 */
376
                $this->resolveFormat($httpRequest);
1✔
377
                $method = $this->resolveMethod($httpRequest);
1✔
378
                $action = $this->actions[$method] ?? null;
1✔
379

380
                if (!$action) {
1✔
381
                        return null;
1✔
382
                }
383

384
                /**
385
                 * Basic params
386
                 */
387
                $params = $httpRequest->getQuery();
1✔
388
                $required_params = $this->getRequiredParams();
1✔
389

390
                /**
391
                 * Route mask parameters
392
                 */
393
                array_shift($matches);
1✔
394

395
                foreach ($this->placeholderOrder as $key => $name) {
1✔
396
                        if ($name !== null && isset($matches[$key])) {
1✔
397
                                $params[$name] = reset($matches[$key]) ?: null;
1✔
398

399
                                /**
400
                                 * Required parameters
401
                                 */
402
                                if (!$params[$name] && in_array($name, $required_params, true)) {
1✔
403
                                        return null;
×
404
                                }
405
                        }
406
                }
407

408
                $xs = array_merge([
1✔
409
                        'presenter' => $this->presenter,
1✔
410
                        'action' => $action,
1✔
411
                        'method' => $method,
1✔
412
                        'post' => $httpRequest->getPost(),
1✔
413
                        'files' => $httpRequest->getFiles(),
1✔
414
                ], $params);
415

416
                /**
417
                 * Trigger event - route matches
418
                 */
419
                $this->onMatch($this, $xs);
1✔
420

421
                return $xs;
1✔
422
        }
423

424
        /**
425
         * Constructs absolute URL from array.
426
         *
427
         * @param array<mixed> $params
428
         */
429
        public function constructUrl(array $params, UrlScript $url): ?string
1✔
430
        {
431
                if ($this->presenter !== $params['presenter']) {
1✔
432
                        return null;
1✔
433
                }
434

435
                $base_url = $url->getBaseUrl();
1✔
436

437
                $action = $params['action'];
1✔
438
                unset($params['presenter']);
1✔
439
                unset($params['action']);
1✔
440
                $parameters = $params;
1✔
441
                $path = ltrim($this->getPath(), '/');
1✔
442

443
                if (array_search($action, $this->actions, true) === false) {
1✔
444
                        return null;
×
445
                }
446

447
                foreach ($parameters as $name => $value) {
1✔
448
                        if (strpos($path, '<' . $name . '>') !== false && $value !== null) {
1✔
449
                                $path = str_replace('<' . $name . '>', (string) $value, $path);
1✔
450

451
                                unset($parameters[$name]);
1✔
452
                        }
453
                }
454

455
                $path = preg_replace_callback('/\[.+?\]/', function ($item) {
1✔
456
                        if (strpos(end($item), '<')) {
1✔
457
                                return '';
458
                        }
459

460
                        return end($item);
1✔
461
                }, $path);
1✔
462

463
                /**
464
                 * There are still some required parameters in url mask
465
                 */
466
                if (preg_match('/<\w+>/', $path)) {
1✔
467
                        return null;
×
468
                }
469

470
                $path = str_replace(['[', ']'], '', $path);
1✔
471

472
                $query = http_build_query($parameters);
1✔
473

474
                return $base_url . $path . ($query ? '?' . $query : '');
1✔
475
        }
476

477
        /**
478
         * @return array{
479
         *     path: string,
480
         *     presenter: string|null,
481
         *     parameters: array<mixed>,
482
         *     actions: array<string, string>,
483
         *     formats: array<string, string>,
484
         *     placeholderOrder: array<mixed>,
485
         *     disable: bool,
486
         *     autoBasePath: bool
487
         * }
488
         */
489
        public function toArray(): array
490
        {
491
                return [
492
                        'path' => $this->path,
1✔
493
                        'presenter' => $this->presenter,
1✔
494
                        'parameters' => $this->parameters,
1✔
495
                        'actions' => $this->actions,
1✔
496
                        'formats' => $this->formats,
1✔
497
                        'placeholderOrder' => $this->placeholderOrder,
1✔
498
                        'disable' => $this->disable,
1✔
499
                        'autoBasePath' => $this->autoBasePath,
1✔
500
                ];
501
        }
502

503
        private function prepareForMatch(string $string): string
1✔
504
        {
505
                return sprintf('/%s/', str_replace('/', '\/', $string));
1✔
506
        }
507

508
}
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