Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.98% covered (warning)
73.98%
182 / 246
54.55% covered (warning)
54.55%
12 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
EngineApiRouteGroup
73.88% covered (warning)
73.88%
181 / 245
54.55% covered (warning)
54.55%
12 / 22
525.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 route
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 routeEngineEndpoints
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 routeWidgetEndpoints
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
30.83
 routePicklistAndLanguageEndpoints
61.11% covered (warning)
61.11%
22 / 36
0.00% covered (danger)
0.00%
0 / 1
40.23
 routeDataExchangeEndpoints
57.89% covered (warning)
57.89%
11 / 19
0.00% covered (danger)
0.00%
0 / 1
17.46
 routeModuleRecordEndpoints
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
1 / 1
30
 routeGanttAndKanbanEndpoints
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 routeGanttEndpoints
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 dispatchGanttEntity
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 routeKanbanEndpoints
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 routeDocumentEndpoints
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 routeDocumentGeneralEndpoints
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 routeDocumentFilesEndpoints
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 routeDocumentFilesListOrDelete
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 routeDocumentVersionsEndpoints
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 routeInventoryFieldsEndpoints
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 routeModuleInventoryFields
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
15.55
 routeInventoryFieldItem
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
15.55
 routeInventoryItemsEndpoints
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 routeInventoryItemsActionEndpoints
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 routeInventoryItemCrudEndpoints
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Api\Presentation\Group;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\DataExchange\Presentation\Api\DataExchangeApiController;
12use App\Core\Engine\Domain\Model\PermissionContext;
13use App\Core\Engine\Presentation\Api\CentralEngineApiController;
14use App\Core\Engine\Presentation\Api\GridWidgetApiController;
15use App\Core\Engine\Presentation\Api\GridWidgetManagementApiController;
16use App\Core\Engine\Presentation\Api\InventoryFieldsApiController;
17use App\Core\Engine\Presentation\Api\KanbanApiController;
18use App\Core\Engine\Presentation\Api\MediaUploadApiControllerInterface;
19use App\Core\Engine\Presentation\Api\PicklistValuesApiController;
20use App\Core\Translation\Presentation\Api\LanguageTranslationsApiController;
21use App\Modules\Documents\Presentation\Api\DocumentApiController;
22use App\Modules\Inventory\Presentation\Api\InventoryItemsApiController;
23use App\Modules\Projects\Presentation\Api\ProjectGanttApiController;
24use App\Modules\Structure\Presentation\Api\OwnershipApiController;
25use Psr\Http\Message\ResponseFactoryInterface;
26use Psr\Http\Message\ResponseInterface;
27use Psr\Http\Message\ServerRequestInterface;
28
29/**
30 * Handles REST API routing for Central Engine, Gantt, Kanban, Documents, and Inventory.
31 */
32final readonly class EngineApiRouteGroup
33{
34    private ResponseFactoryInterface $factory;
35
36    public function __construct(
37        private ?CentralEngineApiController $engineApi = null,
38        private ?GridWidgetApiController $gridWidgetApi = null,
39        private ?PicklistValuesApiController $picklistValuesApi = null,
40        ?ResponseFactoryInterface $factory = null,
41        private ?OwnershipApiController $ownershipApi = null,
42        private ?MediaUploadApiControllerInterface $mediaUploadApi = null,
43        private ?KanbanApiController $kanbanApi = null,
44        private ?ProjectGanttApiController $ganttApi = null,
45        private ?DocumentApiController $documentApi = null,
46        private ?InventoryFieldsApiController $inventoryFieldsApi = null,
47        private ?InventoryItemsApiController $inventoryItemsApi = null,
48        private ?LanguageTranslationsApiController $languageTranslationsApi = null,
49        private ?DataExchangeApiController $dataExchangeApi = null,
50        private ?GridWidgetManagementApiController $widgetManagementApi = null,
51    ) {
52        $this->factory = $factory ?? new \Nyholm\Psr7\Factory\Psr17Factory();
53    }
54
55    /**
56     * Routes engine and related feature requests.
57     */
58    public function route(string $path, ServerRequestInterface $req, PermissionContext $ctx): ?ResponseInterface
59    {
60        return $this->routeEngineEndpoints($path, $req, $ctx)
61            ?? $this->routeGanttAndKanbanEndpoints($path, $req)
62            ?? $this->routeDocumentEndpoints($path, $req, $ctx)
63            ?? $this->routeInventoryFieldsEndpoints($path, $req)
64            ?? $this->routeInventoryItemsEndpoints($path, $req);
65    }
66
67    private function routeEngineEndpoints(
68        string $path,
69        ServerRequestInterface $req,
70        PermissionContext $ctx
71    ): ?ResponseInterface {
72        if ($path === '/api/v1/engine/ownership/autocomplete') {
73            return $this->ownershipApi?->autocomplete($req) ?? $this->factory->createResponse(404);
74        }
75
76        if ($path === '/api/v1/engine/media/upload' && $req->getMethod() === 'POST') {
77            return $this->mediaUploadApi?->actionUpload($req) ?? $this->factory->createResponse(404);
78        }
79
80        return $this->routeWidgetEndpoints($path, $req, $ctx)
81            ?? $this->routePicklistAndLanguageEndpoints($path, $req, $ctx)
82            ?? $this->routeDataExchangeEndpoints($path, $req, $ctx)
83            ?? $this->routeModuleRecordEndpoints($path, $req, $ctx);
84    }
85
86    private function routeWidgetEndpoints(
87        string $path,
88        ServerRequestInterface $req,
89        PermissionContext $ctx
90    ): ?ResponseInterface {
91        return match (true) {
92            $path === '/api/v1/engine/widgets/available-sources' && $req->getMethod() === 'GET'
93                => $this->widgetManagementApi?->actionAvailableSources($ctx) ?? $this->factory->createResponse(404),
94            $path === '/api/v1/engine/widgets/catalog' && $req->getMethod() === 'GET'
95                => $this->widgetManagementApi?->actionCatalogWidgets($ctx) ?? $this->factory->createResponse(404),
96            $path === '/api/v1/engine/widgets/fields-and-filters' && $req->getMethod() === 'GET'
97                => $this->widgetManagementApi?->actionFieldsAndFilters($req, $ctx)
98                    ?? $this->factory->createResponse(404),
99            $path === '/api/v1/engine/widgets/preview' && $req->getMethod() === 'POST'
100                => $this->widgetManagementApi?->actionPreview($req, $ctx) ?? $this->factory->createResponse(404),
101            $path === '/api/v1/engine/widgets/create' && $req->getMethod() === 'POST'
102                => $this->widgetManagementApi?->actionCreate($req, $ctx) ?? $this->factory->createResponse(404),
103            (bool) preg_match('#^/api/v1/engine/widgets/(\d+)/data$#', $path, $m) && $req->getMethod() === 'GET'
104                => $this->widgetManagementApi?->actionData((int) $m[1], $ctx) ?? $this->factory->createResponse(404),
105            (bool) preg_match('#^/api/v1/engine/widgets/(\d+)$#', $path, $m) && $req->getMethod() === 'DELETE'
106                => $this->widgetManagementApi?->actionDelete((int) $m[1], $ctx) ?? $this->factory->createResponse(404),
107            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/grid$#', $path, $m)
108                => $this->gridWidgetApi?->actionGridData($req, $m[1], $ctx) ?? $this->factory->createResponse(404),
109            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/widget/positions$#', $path, $m)
110                => $this->gridWidgetApi?->actionSavePositions($req) ?? $this->factory->createResponse(404),
111            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/widget/(\d+)/render$#', $path, $m)
112                => $this->gridWidgetApi?->actionRenderWidget($req, $m[1], (int) $m[2], $ctx)
113                    ?? $this->factory->createResponse(404),
114            default => null,
115        };
116    }
117
118    private function routePicklistAndLanguageEndpoints(
119        string $path,
120        ServerRequestInterface $req,
121        PermissionContext $ctx
122    ): ?ResponseInterface {
123        return match (true) {
124            (bool) preg_match('#^/api/v1/engine/system_picklists/(\d+)/values/reorder$#', $path, $m)
125                => $this->picklistValuesApi?->reorder($req, (int) $m[1]) ?? $this->factory->createResponse(404),
126            (bool) preg_match('#^/api/v1/engine/system_picklists/(\d+)/values/(\d+)$#', $path, $m)
127                => match ($req->getMethod()) {
128                    'PUT'    => $this->picklistValuesApi?->update($req, (int) $m[1], (int) $m[2])
129                        ?? $this->factory->createResponse(404),
130                    'DELETE' => $this->picklistValuesApi?->delete($req, (int) $m[1], (int) $m[2])
131                        ?? $this->factory->createResponse(404),
132                    default  => $this->factory->createResponse(405),
133                },
134            (bool) preg_match('#^/api/v1/engine/system_picklists/(\d+)/values$#', $path, $m)
135                => match ($req->getMethod()) {
136                    'GET'   => $this->picklistValuesApi?->list((int) $m[1]) ?? $this->factory->createResponse(404),
137                    'POST'  => $this->picklistValuesApi?->create($req, (int) $m[1], $ctx)
138                        ?? $this->factory->createResponse(404),
139                    default => $this->factory->createResponse(405),
140                },
141            (bool) preg_match('#^/api/v1/(?:engine/system_|system/)languages/(\d+)/translations/(\d+)$#', $path, $m)
142                => match ($req->getMethod()) {
143                    'PUT'    => $this->languageTranslationsApi?->update($req, (int) $m[1], (int) $m[2], $ctx)
144                        ?? $this->factory->createResponse(404),
145                    'DELETE' => $this->languageTranslationsApi?->delete((int) $m[1], (int) $m[2])
146                        ?? $this->factory->createResponse(404),
147                    default  => $this->factory->createResponse(405),
148                },
149            (bool) preg_match('#^/api/v1/(?:engine/system_|system/)languages/(\d+)/translations$#', $path, $m)
150                => match ($req->getMethod()) {
151                    'GET'    => $this->languageTranslationsApi?->list($req, (int) $m[1])
152                        ?? $this->factory->createResponse(404),
153                    'POST'   => $this->languageTranslationsApi?->create($req, (int) $m[1], $ctx)
154                        ?? $this->factory->createResponse(404),
155                    default  => $this->factory->createResponse(405),
156                },
157            default => null,
158        };
159    }
160
161    private function routeDataExchangeEndpoints(
162        string $path,
163        ServerRequestInterface $req,
164        PermissionContext $ctx
165    ): ?ResponseInterface {
166        return match (true) {
167            (bool) (preg_match('#^/api/v1/engine/([a-zA-Z0-9_-]+)/export$#', $path, $m)
168                && $req->getMethod() === 'POST')
169                => $this->dataExchangeApi?->actionExport($req, $m[1], $ctx)
170                    ?? $this->factory->createResponse(404),
171            (bool) (preg_match('#^/api/v1/engine/([a-zA-Z0-9_-]+)/import/analyze$#', $path, $m)
172                && $req->getMethod() === 'POST')
173                => $this->dataExchangeApi?->actionAnalyze($req, $m[1])
174                    ?? $this->factory->createResponse(404),
175            (bool) (preg_match('#^/api/v1/engine/([a-zA-Z0-9_-]+)/import/execute$#', $path, $m)
176                && $req->getMethod() === 'POST')
177                => $this->dataExchangeApi?->actionExecuteImport($req, $m[1], $ctx)
178                    ?? $this->factory->createResponse(404),
179            (bool) (preg_match('#^/api/v1/engine/([a-zA-Z0-9_-]+)/import/jobs/(\d+)$#', $path, $m)
180                && $req->getMethod() === 'GET')
181                => $this->dataExchangeApi?->actionJobStatus($m[1], (int) $m[2])
182                    ?? $this->factory->createResponse(404),
183            default => null,
184        };
185    }
186
187    private function routeModuleRecordEndpoints(
188        string $path,
189        ServerRequestInterface $req,
190        PermissionContext $ctx
191    ): ?ResponseInterface {
192        return match (true) {
193            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/list$#', $path, $m)
194                => $this->engineApi?->actionList($req, $m[1], $ctx) ?? $this->factory->createResponse(404),
195            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/calendar-events$#', $path, $m)
196                => $this->engineApi?->actionCalendarEvents($req, $m[1], $ctx) ?? $this->factory->createResponse(404),
197            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/calendar-users$#', $path, $m)
198                => $this->engineApi?->actionCalendarUsers($req, $m[1], $ctx) ?? $this->factory->createResponse(404),
199            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/bulk-actions$#', $path, $m)
200                => $this->engineApi?->actionBulkActionsList($m[1], $ctx) ?? $this->factory->createResponse(404),
201            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/bulk-editable-fields$#', $path, $m)
202                => $this->engineApi?->actionBulkEditableFields($m[1], $ctx) ?? $this->factory->createResponse(404),
203            (bool) (preg_match('#^/api/v1/engine/([a-z0-9_]+)/bulk-action$#', $path, $m)
204                && $req->getMethod() === 'POST')
205                => $this->engineApi?->actionBulkAction($req, $m[1], $ctx) ?? $this->factory->createResponse(404),
206            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/relation-options$#', $path, $m)
207                => $this->engineApi?->actionRelationOptions($req, $m[1], $ctx) ?? $this->factory->createResponse(404),
208            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/fields$#', $path, $m)
209                => $this->engineApi?->actionFields($m[1], $ctx) ?? $this->factory->createResponse(404),
210            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/schema$#', $path, $m)
211                => $this->engineApi?->actionSchema($m[1], $ctx, $req) ?? $this->factory->createResponse(404),
212            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/(\d+)/navigation$#', $path, $m)
213                => $this->engineApi?->actionNavigation($req, $m[1], (int) $m[2], $ctx)
214                    ?? $this->factory->createResponse(404),
215            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/(\d+)/timeline$#', $path, $m)
216                => $this->engineApi?->actionTimeline($m[1], (int) $m[2], $ctx, $req)
217                    ?? $this->factory->createResponse(404),
218            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/(\d+)/calendar-events$#', $path, $m)
219                => $this->engineApi?->actionRelatedCalendarEvents($req, $m[1], (int) $m[2], $ctx)
220                    ?? $this->factory->createResponse(404),
221            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/(\d+)/work-time-records$#', $path, $m)
222                => $this->engineApi?->actionRelatedWorkTime($req, $m[1], (int) $m[2], $ctx)
223                    ?? $this->factory->createResponse(404),
224            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/(\d+)/hierarchy$#', $path, $m)
225                => $this->engineApi?->actionHierarchy($m[1], (int) $m[2], $ctx) ?? $this->factory->createResponse(404),
226            (bool) (preg_match('#^/api/v1/engine/([a-z0-9_]+)/(\d+)/favorite$#', $path, $m)
227                && $req->getMethod() === 'POST')
228                => $this->engineApi?->actionToggleFavorite($m[1], (int) $m[2], $ctx)
229                    ?? $this->factory->createResponse(404),
230            (bool) (preg_match('#^/api/v1/engine/([a-z0-9_]+)/(\d+)/pin$#', $path, $m)
231                && $req->getMethod() === 'POST')
232                => $this->engineApi?->actionTogglePin($m[1], (int) $m[2], $ctx) ?? $this->factory->createResponse(404),
233            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)/(\d+)$#', $path, $m)
234                => match ($req->getMethod()) {
235                    'GET'    => $this->engineApi?->actionRead($m[1], (int) $m[2], $ctx)
236                        ?? $this->factory->createResponse(404),
237                    'PUT'    => $this->engineApi?->actionUpdate($req, $m[1], (int) $m[2], $ctx)
238                        ?? $this->factory->createResponse(404),
239                    'DELETE' => $this->engineApi?->actionDelete($m[1], (int) $m[2], $ctx)
240                        ?? $this->factory->createResponse(404),
241                    default  => $this->factory->createResponse(405),
242                },
243            (bool) preg_match('#^/api/v1/engine/([a-z0-9_]+)$#', $path, $m)
244                => match ($req->getMethod()) {
245                    'GET'    => $this->engineApi?->actionList($req, $m[1], $ctx)
246                        ?? $this->factory->createResponse(404),
247                    'POST'   => $this->engineApi?->actionCreate($req, $m[1], $ctx)
248                        ?? $this->factory->createResponse(404),
249                    default  => $this->factory->createResponse(405),
250                },
251            default => null,
252        };
253    }
254
255    private function routeGanttAndKanbanEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
256    {
257        return $this->routeGanttEndpoints($path, $req)
258            ?? $this->routeKanbanEndpoints($path, $req);
259    }
260
261    private function routeGanttEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
262    {
263        if ($this->ganttApi === null || $req->getMethod() !== 'GET') {
264            return null;
265        }
266
267        if ($path === '/api/v1/projects/gantt') {
268            return $this->ganttApi->actionAllProjectsGantt();
269        }
270
271        return $this->dispatchGanttEntity($path);
272    }
273
274    /**
275     * Dispatches Gantt chart requests for projects or stages.
276     */
277    private function dispatchGanttEntity(string $path): ?ResponseInterface
278    {
279        if (!preg_match('#^/api/v1/(projects|project-stages)/(\d+)/gantt$#', $path, $m)) {
280            return null;
281        }
282
283        return $m[1] === 'projects'
284            ? $this->ganttApi->actionProjectGantt((int) $m[2])
285            : $this->ganttApi->actionStageGantt((int) $m[2]);
286    }
287
288    private function routeKanbanEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
289    {
290        if ($this->kanbanApi === null) {
291            return null;
292        }
293
294        if (preg_match('#^/api/v1/([a-zA-Z0-9_-]+)/kanban$#', $path, $m) && $req->getMethod() === 'GET') {
295            return $this->kanbanApi->actionKanbanData($m[1]);
296        }
297
298        return (preg_match('#^/api/v1/([a-zA-Z0-9_-]+)/(\d+)/status$#', $path, $m)
299            && in_array($req->getMethod(), ['PATCH', 'PUT', 'POST'], true))
300            ? $this->kanbanApi->actionUpdateStatus($req, $m[1], (int) $m[2])
301            : null;
302    }
303
304    private function routeDocumentEndpoints(
305        string $path,
306        ServerRequestInterface $req,
307        PermissionContext $ctx
308    ): ?ResponseInterface {
309        if ($this->documentApi === null || !str_starts_with($path, '/api/v1/documents')) {
310            return null;
311        }
312
313        return $this->routeDocumentGeneralEndpoints($path, $req, $ctx)
314            ?? $this->routeDocumentFilesEndpoints($path, $req, $ctx)
315            ?? $this->routeDocumentVersionsEndpoints($path, $req, $ctx);
316    }
317
318    private function routeDocumentGeneralEndpoints(
319        string $path,
320        ServerRequestInterface $req,
321        PermissionContext $ctx
322    ): ?ResponseInterface {
323        if ($path === '/api/v1/documents/settings' && $req->getMethod() === 'GET') {
324            return $this->documentApi->actionSettings();
325        }
326
327        if ($path === '/api/v1/documents/bulk-upload' && $req->getMethod() === 'POST') {
328            return $this->documentApi->actionBulkUpload($req, $ctx);
329        }
330
331        return null;
332    }
333
334    private function routeDocumentFilesEndpoints(
335        string $path,
336        ServerRequestInterface $req,
337        PermissionContext $ctx
338    ): ?ResponseInterface {
339        if (preg_match('#^/api/v1/documents/(\d+)/upload-files$#', $path, $m) && $req->getMethod() === 'POST') {
340            return $this->documentApi->actionUploadFiles($req, (int) $m[1], $ctx);
341        }
342
343        return $this->routeDocumentFilesListOrDelete($path, $req);
344    }
345
346    private function routeDocumentFilesListOrDelete(string $path, ServerRequestInterface $req): ?ResponseInterface
347    {
348        if (preg_match('#^/api/v1/documents/(\d+)/files$#', $path, $m) && $req->getMethod() === 'GET') {
349            return $this->documentApi->actionListFiles((int) $m[1]);
350        }
351
352        if (preg_match('#^/api/v1/documents/(\d+)/files/(\d+)$#', $path, $m) && $req->getMethod() === 'DELETE') {
353            return $this->documentApi->actionDeleteFile((int) $m[1], (int) $m[2]);
354        }
355
356        return null;
357    }
358
359    private function routeDocumentVersionsEndpoints(
360        string $path,
361        ServerRequestInterface $req,
362        PermissionContext $ctx
363    ): ?ResponseInterface {
364        if (preg_match('#^/api/v1/documents/(\d+)/versions$#', $path, $m)) {
365            return match ($req->getMethod()) {
366                'GET'   => $this->documentApi->actionListVersions((int) $m[1]),
367                'POST'  => $this->documentApi->actionCreateVersion($req, (int) $m[1], $ctx),
368                default => $this->factory->createResponse(405),
369            };
370        }
371
372        if (preg_match('#^/api/v1/documents/(\d+)/versions/(\d+)/rollback$#', $path, $m)
373            && $req->getMethod() === 'POST') {
374            return $this->documentApi->actionRollbackVersion((int) $m[1], (int) $m[2], $ctx);
375        }
376
377        return null;
378    }
379
380    private function routeInventoryFieldsEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
381    {
382        if ($this->inventoryFieldsApi === null) {
383            return null;
384        }
385
386        return $this->routeModuleInventoryFields($path, $req)
387            ?? $this->routeInventoryFieldItem($path, $req);
388    }
389
390    private function routeModuleInventoryFields(string $path, ServerRequestInterface $req): ?ResponseInterface
391    {
392        if (preg_match('#^/api/v1/modules/(\d+)/inventory-fields$#', $path, $m)) {
393            $moduleId = (int) $m[1];
394            return match ($req->getMethod()) {
395                'GET'   => $this->inventoryFieldsApi->list($moduleId),
396                'POST'  => $this->inventoryFieldsApi->create($req, $moduleId),
397                default => null,
398            };
399        }
400
401        return null;
402    }
403
404    private function routeInventoryFieldItem(string $path, ServerRequestInterface $req): ?ResponseInterface
405    {
406        if (preg_match('#^/api/v1/modules/inventory-fields/(\d+)$#', $path, $m)) {
407            $fieldId = (int) $m[1];
408            return match ($req->getMethod()) {
409                'PUT', 'PATCH' => $this->inventoryFieldsApi->update($req, $fieldId),
410                'DELETE'       => $this->inventoryFieldsApi->delete($fieldId),
411                default        => null,
412            };
413        }
414
415        return null;
416    }
417
418    private function routeInventoryItemsEndpoints(string $path, ServerRequestInterface $req): ?ResponseInterface
419    {
420        if ($this->inventoryItemsApi === null || !str_starts_with($path, '/api/v1/inventory/')) {
421            return null;
422        }
423
424        return $this->routeInventoryItemsActionEndpoints($path, $req)
425            ?? $this->routeInventoryItemCrudEndpoints($path, $req);
426    }
427
428    private function routeInventoryItemsActionEndpoints(
429        string $path,
430        ServerRequestInterface $req
431    ): ?ResponseInterface {
432        if (preg_match('#^/api/v1/inventory/([a-z0-9_]+)/(\d+)/calculate$#', $path, $m)) {
433            return $req->getMethod() === 'POST'
434                ? $this->inventoryItemsApi->calculate($req, $m[1], (int) $m[2])
435                : $this->factory->createResponse(405);
436        }
437
438        if (preg_match('#^/api/v1/inventory/([a-z0-9_]+)/(\d+)/save-items$#', $path, $m)) {
439            return $req->getMethod() === 'POST'
440                ? $this->inventoryItemsApi->save($req, $m[1], (int) $m[2])
441                : $this->factory->createResponse(405);
442        }
443
444        return null;
445    }
446
447    private function routeInventoryItemCrudEndpoints(
448        string $path,
449        ServerRequestInterface $req
450    ): ?ResponseInterface {
451        if (preg_match('#^/api/v1/inventory/([a-z0-9_]+)/(\d+)/items$#', $path, $m)) {
452            return $req->getMethod() === 'GET'
453                ? $this->inventoryItemsApi->list($m[1], (int) $m[2])
454                : $this->factory->createResponse(405);
455        }
456
457        if (preg_match('#^/api/v1/inventory/([a-z0-9_]+)/(\d+)/items/(\d+)$#', $path, $m)) {
458            return $req->getMethod() === 'DELETE'
459                ? $this->inventoryItemsApi->delete($m[1], (int) $m[2], (int) $m[3])
460                : $this->factory->createResponse(405);
461        }
462
463        return null;
464    }
465}