Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.01% covered (success)
97.01%
162 / 167
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiDispatcher
96.99% covered (success)
96.99%
161 / 166
63.64% covered (warning)
63.64%
7 / 11
44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
1
 dispatch
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
2.00
 runPipeline
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 routePublicEndpoint
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 resolveAuthPublicHandler
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
15
 routeAuthenticatedEndpoint
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 dispatchAuthenticatedRoute
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 isAllowedInCurrentProfile
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 isAdminOnlyEndpoint
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 normalizeApiPath
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 jsonError
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
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;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Api\Middleware\ApiLoggingMiddleware;
12use App\Core\Api\Middleware\ApiPayloadGuardMiddleware;
13use App\Core\Api\Middleware\ApiTokenMiddleware;
14use App\Core\Api\Middleware\CorsMiddleware;
15use App\Core\Api\Middleware\IdempotencyMiddleware;
16use App\Core\Api\Middleware\RequestIdMiddleware;
17use App\Core\Api\Presentation\Group\CommerceApiRouteGroup;
18use App\Core\Api\Presentation\Group\EngineApiRouteGroup;
19use App\Core\Api\Presentation\Group\SystemApiRouteGroup;
20use App\Core\DataExchange\Presentation\Api\DataExchangeApiController;
21use App\Core\Access\Presentation\Api\AccessApiController;
22use App\Core\Audit\Presentation\Api\AuditApiController;
23use App\Core\Cron\Presentation\Api\CronApiController;
24use App\Core\Engine\Application\Security\PermissionContextFactory;
25use App\Core\Engine\Domain\Model\PermissionContext;
26use App\Core\Engine\Presentation\Api\CentralEngineApiController;
27use App\Core\Engine\Presentation\Api\GridWidgetApiController;
28use App\Core\Engine\Presentation\Api\GridWidgetManagementApiController;
29use App\Core\Engine\Presentation\Api\PicklistValuesApiController;
30use App\Core\Engine\Presentation\Api\KanbanApiController;
31use App\Core\Engine\Presentation\Api\MediaUploadApiControllerInterface;
32use App\Core\Engine\Presentation\Api\ModuleFieldsApiController;
33use App\Core\Instance\Presentation\Api\InstanceContextApiController;
34use App\Modules\Documents\Presentation\Api\DocumentApiController;
35use App\Core\Layout\Presentation\Api\LayoutApiController;
36use App\Core\ModuleBuilder\Presentation\Api\ModuleBuilderApiController;
37use App\Modules\Comments\Presentation\Api\CommentsApiController;
38use App\Core\Preference\Presentation\Api\UserPreferencesApiController;
39use App\Core\Security\Middleware\CsrfMiddleware;
40use App\Core\Security\Middleware\RateLimiterMiddleware;
41use App\Core\Settings\Presentation\Api\SettingsApiController;
42use App\Core\Translation\Presentation\Api\LanguageTranslationsApiController;
43use App\Core\Translation\Presentation\Api\TranslationApiController;
44use App\Modules\About\Presentation\Api\AboutLicensesApiController;
45use App\Modules\About\Presentation\Api\AboutRequirementsApiController;
46use App\Modules\Mail\Presentation\Api\MailApiController;
47use App\Modules\Map\Presentation\Api\MapApiController;
48use App\Modules\Automation\Presentation\Api\WorkflowApiController;
49use App\Modules\Dav\Presentation\Api\DavDeviceApiController;
50use App\Modules\Mail\Presentation\Api\WebmailApiController;
51use App\Modules\Mail\Presentation\Api\WebmailEventsApiController;
52use App\Modules\Menu\Presentation\Api\MenuApiController;
53use App\Modules\Projects\Presentation\Api\ProjectGanttApiController;
54use App\Modules\User\Presentation\Api\AuthApiController;
55use App\Modules\User\Presentation\Api\LocaleApiController;
56use App\Modules\User\Presentation\Api\MfaApiController;
57use App\Modules\User\Presentation\Api\UserProfileApiController;
58use App\Modules\Pdf\Presentation\Api\PdfApiController;
59use App\Modules\Profiles\Presentation\Api\ProfilePermissionApiController;
60use App\Modules\Structure\Presentation\Api\OwnershipApiController;
61use App\Modules\Structure\Presentation\Api\StructureApiController;
62use App\Core\Search\Presentation\Api\GlobalSearchApiController;
63use App\Core\Search\Presentation\Api\SearchSettingsApiController;
64use App\Modules\Dashboard\Presentation\Api\DashboardApiControllerInterface;
65use App\Modules\Tax\Presentation\Api\TaxCalculateApiController;
66use App\Modules\Tax\Presentation\Api\TaxGroupsApiController;
67use App\Modules\Tax\Presentation\Api\TaxRatesApiController;
68use App\Modules\Tax\Presentation\Api\TaxRulesApiController;
69use App\Modules\Discount\Presentation\Api\DiscountRatesApiController;
70use App\Modules\Currencies\Presentation\Api\CurrenciesApiController;
71use App\Core\Engine\Presentation\Api\InventoryFieldsApiController;
72use App\Modules\Inventory\Presentation\Api\InventoryItemsApiController;
73use Psr\Http\Message\ResponseFactoryInterface;
74use Psr\Http\Message\ResponseInterface;
75use Psr\Http\Message\ServerRequestInterface;
76use Psr\Http\Server\RequestHandlerInterface;
77
78/**
79 * Dispatches versioned REST API v1 routes through security and logging middleware pipeline.
80 *
81 * @package App\Core\Api\Presentation
82 */
83final readonly class ApiDispatcher
84{
85    private const string PATH_SETTINGS = '/api/v1/settings';
86
87    private EngineApiRouteGroup $engineRouteGroup;
88    private CommerceApiRouteGroup $commerceRouteGroup;
89    private SystemApiRouteGroup $systemRouteGroup;
90
91    /**
92     * ApiDispatcher constructor.
93     */
94    public function __construct(
95        private AuthApiController $authApi,
96        private LocaleApiController $localeApi,
97        private MenuApiController $menuApi,
98        private AboutLicensesApiController $aboutApi,
99        private AboutRequirementsApiController $aboutRequirementsApi,
100        private CentralEngineApiController $engineApi,
101        private GridWidgetApiController $gridWidgetApi,
102        private PicklistValuesApiController $picklistValuesApi,
103        private TranslationApiController $transApi,
104        private SettingsApiController $settingsApi,
105        private LayoutApiController $layoutApi,
106        private CronApiController $cronApi,
107        private MailApiController $mailApi,
108        private AuditApiController $auditApi,
109        private PermissionContextFactory $contextFactory,
110        private RequestIdMiddleware $requestIdMiddleware,
111        private CorsMiddleware $corsMiddleware,
112        private ApiPayloadGuardMiddleware $apiPayloadGuardMiddleware,
113        private RateLimiterMiddleware $loginLimiter,
114        private RateLimiterMiddleware $apiLimiter,
115        private IdempotencyMiddleware $idempotency,
116        private CsrfMiddleware $csrf,
117        private ApiTokenMiddleware $tokenMiddleware,
118        private ResponseFactoryInterface $factory,
119        private ?InstanceContextApiController $instanceContextApi = null,
120        private ?ApiLoggingMiddleware $apiLoggingMiddleware = null,
121        private ?UserPreferencesApiController $preferencesApi = null,
122        private ?UserProfileApiController $userProfileApi = null,
123        private ?DavDeviceApiController $davDeviceApi = null,
124        private ?WebmailApiController $webmailApi = null,
125        private ?WebmailEventsApiController $webmailEventsApi = null,
126        private ?WorkflowApiController $workflowApi = null,
127        private ?MfaApiController $mfaApi = null,
128        private ?ProjectGanttApiController $ganttApi = null,
129        private ?KanbanApiController $kanbanApi = null,
130        private ?DocumentApiController $documentApi = null,
131        private ?MapApiController $mapApi = null,
132        private ?PdfApiController $pdfApi = null,
133        private string $profile = 'admin',
134        private ?OwnershipApiController $ownershipApi = null,
135        private ?StructureApiController $structureApi = null,
136        private ?AccessApiController $accessApi = null,
137        private ?ProfilePermissionApiController $profileApi = null,
138        private ?MediaUploadApiControllerInterface $mediaUploadApi = null,
139        private ?GlobalSearchApiController $searchApi = null,
140        private ?SearchSettingsApiController $searchSettingsApi = null,
141        private ?CommentsApiController $commentsApi = null,
142        private ?DashboardApiControllerInterface $dashboardApi = null,
143        private ?TaxRatesApiController $taxRatesApi = null,
144        private ?TaxGroupsApiController $taxGroupsApi = null,
145        private ?TaxRulesApiController $taxRulesApi = null,
146        private ?TaxCalculateApiController $taxCalculateApi = null,
147        private ?DiscountRatesApiController $discountsApi = null,
148        private ?InventoryFieldsApiController $inventoryFieldsApi = null,
149        private ?CurrenciesApiController $currenciesApi = null,
150        private ?InventoryItemsApiController $inventoryItemsApi = null,
151        private ?LanguageTranslationsApiController $languageTranslationsApi = null,
152        private ?DataExchangeApiController $dataExchangeApi = null,
153        private ?ModuleBuilderApiController $moduleBuilderApi = null,
154        private ?GridWidgetManagementApiController $gridWidgetManagementApi = null,
155    ) {
156        $this->engineRouteGroup = new EngineApiRouteGroup(
157            $this->engineApi,
158            $this->gridWidgetApi,
159            $this->picklistValuesApi,
160            $this->factory,
161            $this->ownershipApi,
162            $this->mediaUploadApi,
163            $this->kanbanApi,
164            $this->ganttApi,
165            $this->documentApi,
166            $this->inventoryFieldsApi,
167            $this->inventoryItemsApi,
168            $this->languageTranslationsApi,
169            $this->dataExchangeApi,
170            $this->gridWidgetManagementApi
171        );
172
173        $this->commerceRouteGroup = new CommerceApiRouteGroup(
174            $this->taxRatesApi,
175            $this->taxGroupsApi,
176            $this->taxRulesApi,
177            $this->taxCalculateApi,
178            $this->discountsApi,
179            $this->currenciesApi
180        );
181
182        $this->systemRouteGroup = new SystemApiRouteGroup(
183            $this->authApi,
184            $this->menuApi,
185            $this->aboutApi,
186            $this->aboutRequirementsApi,
187            $this->transApi,
188            $this->settingsApi,
189            $this->layoutApi,
190            $this->cronApi,
191            $this->mailApi,
192            $this->auditApi,
193            $this->factory,
194            $this->instanceContextApi,
195            $this->preferencesApi,
196            $this->userProfileApi,
197            $this->davDeviceApi,
198            $this->webmailApi,
199            $this->webmailEventsApi,
200            $this->workflowApi,
201            $this->mfaApi,
202            $this->mapApi,
203            $this->pdfApi,
204            $this->structureApi,
205            $this->accessApi,
206            $this->profileApi,
207            $this->searchApi,
208            $this->searchSettingsApi,
209            $this->commentsApi,
210            $this->dashboardApi,
211            $this->moduleBuilderApi
212        );
213    }
214
215    /**
216     * Executes API security middleware chain and dispatches requested route.
217     *
218     * @param string                 $path    Request path.
219     * @param ServerRequestInterface $request PSR-7 Server request.
220     * @return ResponseInterface PSR-7 response.
221     */
222    public function dispatch(string $path, ServerRequestInterface $request): ResponseInterface
223    {
224        $guardHandler = new class($this, $path) implements RequestHandlerInterface {
225            public function __construct(
226                private ApiDispatcher $dispatcher,
227                private string $path
228            ) {
229            }
230
231            public function handle(ServerRequestInterface $r): ResponseInterface
232            {
233                return $this->dispatcher->runPipeline($this->path, $r);
234            }
235        };
236
237        $loggingHandler = $this->apiLoggingMiddleware !== null
238            ? new class($this->apiLoggingMiddleware, $guardHandler) implements RequestHandlerInterface {
239                public function __construct(
240                    private ApiLoggingMiddleware $logger,
241                    private RequestHandlerInterface $next
242                ) {
243                }
244
245                public function handle(ServerRequestInterface $r): ResponseInterface
246                {
247                    return $this->logger->process($r, $this->next);
248                }
249            }
250            : $guardHandler;
251
252        $corsHandler = new class($this->apiPayloadGuardMiddleware, $loggingHandler) implements RequestHandlerInterface {
253            public function __construct(
254                private ApiPayloadGuardMiddleware $guard,
255                private RequestHandlerInterface $next
256            ) {
257            }
258
259            public function handle(ServerRequestInterface $r): ResponseInterface
260            {
261                return $this->guard->process($r, $this->next);
262            }
263        };
264
265        $requestIdHandler = new class($this->corsMiddleware, $corsHandler) implements RequestHandlerInterface {
266            public function __construct(
267                private CorsMiddleware $cors,
268                private RequestHandlerInterface $next
269            ) {
270            }
271
272            public function handle(ServerRequestInterface $r): ResponseInterface
273            {
274                return $this->cors->process($r, $this->next);
275            }
276        };
277
278        return $this->requestIdMiddleware->process($request, $requestIdHandler);
279    }
280
281    /**
282     * Runs rate limiter, CSRF or token authentication pipeline before reaching route handler.
283     *
284     * @param string                 $path Request path.
285     * @param ServerRequestInterface $req  Incoming request.
286     * @return ResponseInterface Handled response.
287     */
288    public function runPipeline(string $path, ServerRequestInterface $req): ResponseInterface
289    {
290        $publicRes = $this->routePublicEndpoint($path, $req);
291        if ($publicRes !== null) {
292            return $publicRes;
293        }
294
295        $innerHandler = new class($this, $path) implements RequestHandlerInterface {
296            public function __construct(
297                private ApiDispatcher $dispatcher,
298                private string $path
299            ) {
300            }
301
302            public function handle(ServerRequestInterface $r): ResponseInterface
303            {
304                return $this->dispatcher->routeAuthenticatedEndpoint($this->path, $r);
305            }
306        };
307
308        $tokenHandler = new class($this->tokenMiddleware, $innerHandler) implements RequestHandlerInterface {
309            public function __construct(
310                private ApiTokenMiddleware $token,
311                private RequestHandlerInterface $next
312            ) {
313            }
314
315            public function handle(ServerRequestInterface $r): ResponseInterface
316            {
317                return $this->token->process($r, $this->next);
318            }
319        };
320
321        $idempotencyHandler = new class($this->idempotency, $tokenHandler) implements RequestHandlerInterface {
322            public function __construct(
323                private IdempotencyMiddleware $idempotency,
324                private RequestHandlerInterface $next
325            ) {
326            }
327
328            public function handle(ServerRequestInterface $r): ResponseInterface
329            {
330                return $this->idempotency->process($r, $this->next);
331            }
332        };
333
334        return $this->apiLimiter->process($req, $idempotencyHandler);
335    }
336
337    /**
338     * Routes public/unauthenticated endpoints with dedicated rate limiters or CSRF.
339     *
340     * @param string                 $path Request path.
341     * @param ServerRequestInterface $req  Incoming request.
342     * @return ResponseInterface|null Handled response or null if not a public endpoint.
343     */
344    private function routePublicEndpoint(string $path, ServerRequestInterface $req): ?ResponseInterface
345    {
346        $normPath = $this->normalizeApiPath($path);
347        $authHandler = $this->resolveAuthPublicHandler($normPath, $req);
348        if ($authHandler !== null) {
349            return $this->loginLimiter->process($req, $authHandler);
350        }
351
352        if ($normPath === '/api/v1/user/locale' && $req->getMethod() === 'POST') {
353            $localeHandler = new class($this->localeApi) implements RequestHandlerInterface {
354                public function __construct(private LocaleApiController $api)
355                {
356                }
357
358                public function handle(ServerRequestInterface $r): ResponseInterface
359                {
360                    return $this->api->setLocale($r);
361                }
362            };
363            return $this->csrf->process($req, $localeHandler);
364        }
365
366        return null;
367    }
368
369    /**
370     * Resolves authentication endpoint handler if matching path and method.
371     *
372     * @param string                 $path Endpoint path.
373     * @param ServerRequestInterface $req  Incoming request.
374     * @return RequestHandlerInterface|null Handled request or null if non-matching.
375     */
376    private function resolveAuthPublicHandler(string $path, ServerRequestInterface $req): ?RequestHandlerInterface
377    {
378        $action = match ($path) {
379            '/api/v1/auth/login'          => 'login',
380            '/api/v1/auth/mfa-verify'     => $req->getMethod() === 'POST' ? 'mfaVerify' : null,
381            '/api/v1/auth/forgot-password' => $req->getMethod() === 'POST' ? 'forgotPassword' : null,
382            '/api/v1/auth/reset-password'  => $req->getMethod() === 'POST' ? 'resetPassword' : null,
383            default                       => null,
384        };
385
386        if ($action === null) {
387            return null;
388        }
389
390        return new class($this->authApi, $action) implements RequestHandlerInterface {
391            public function __construct(
392                private AuthApiController $api,
393                private string $action
394            ) {
395            }
396
397            public function handle(ServerRequestInterface $r): ResponseInterface
398            {
399                return match ($this->action) {
400                    'login'          => $this->api->login($r),
401                    'mfaVerify'      => $this->api->mfaVerify($r),
402                    'forgotPassword' => $this->api->forgotPassword($r),
403                    'resetPassword'  => $this->api->resetPassword($r),
404                    default          => throw new \LogicException('Unknown action: ' . $this->action),
405                };
406            }
407        };
408    }
409
410    /**
411     * Routes authenticated endpoints to appropriate domain controllers.
412     *
413     * @param string                 $path Request path.
414     * @param ServerRequestInterface $req  Incoming request.
415     * @param PermissionContext      $ctx  Authenticated permission context.
416     * @return ResponseInterface Dispatched response.
417     */
418    public function routeAuthenticatedEndpoint(
419        string $path,
420        ServerRequestInterface $req,
421        ?PermissionContext $ctx = null
422    ): ResponseInterface {
423        if (!$this->isAllowedInCurrentProfile($path)) {
424            return $this->jsonError(
425                $this->factory,
426                'This API endpoint is restricted to the administrative application profile.',
427                403
428            );
429        }
430
431        $context = $ctx ?? $this->contextFactory->createFromRequest($req);
432
433        if ($req->getAttribute('auth_type') === 'session') {
434            $method = strtoupper($req->getMethod());
435            if (!in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
436                $csrfHandler = new class($this, $path, $context) implements RequestHandlerInterface {
437                    public function __construct(
438                        private ApiDispatcher $dispatcher,
439                        private string $path,
440                        private PermissionContext $context
441                    ) {
442                    }
443
444                    public function handle(ServerRequestInterface $r): ResponseInterface
445                    {
446                        return $this->dispatcher->dispatchAuthenticatedRoute($this->path, $r, $this->context);
447                    }
448                };
449                return $this->csrf->process($req, $csrfHandler);
450            }
451        }
452
453        return $this->dispatchAuthenticatedRoute($path, $req, $context);
454    }
455
456    /**
457     * Dispatches authenticated request to matching domain endpoints.
458     *
459     * @param string                 $path    Request path.
460     * @param ServerRequestInterface $req     Incoming request.
461     * @param PermissionContext      $context Authenticated permission context.
462     * @return ResponseInterface Dispatched response.
463     */
464    public function dispatchAuthenticatedRoute(
465        string $path,
466        ServerRequestInterface $req,
467        PermissionContext $context
468    ): ResponseInterface {
469        $normPath = $this->normalizeApiPath($path);
470        $res = $this->engineRouteGroup->route($normPath, $req, $context)
471            ?? $this->commerceRouteGroup->route($normPath, $req)
472            ?? $this->systemRouteGroup->route($normPath, $req, $context);
473
474        return $res ?? $this->factory->createResponse(404);
475    }
476
477    /**
478     * Determines whether the given path is allowed in the current application profile.
479     *
480     * @param string $path Endpoint URI path.
481     * @return bool True if accessible in the current profile.
482     */
483    public function isAllowedInCurrentProfile(string $path): bool
484    {
485        if (str_starts_with($path, '/api/v1/admin/') && $this->profile === 'client') {
486            return false;
487        }
488
489        if ($this->profile !== 'client') {
490            return true;
491        }
492
493        return !$this->isAdminOnlyEndpoint($path);
494    }
495
496    /**
497     * Checks if the endpoint path is strictly restricted to administrative consoles.
498     *
499     * @param string $path Endpoint URI path.
500     * @return bool True if admin-only.
501     */
502    public function isAdminOnlyEndpoint(string $path): bool
503    {
504        $norm = $this->normalizeApiPath($path);
505
506        return str_starts_with($norm, '/api/v1/system/context')
507            || str_starts_with($norm, '/api/v1/instance')
508            || str_starts_with($norm, '/api/v1/about')
509            || str_starts_with($norm, '/api/v1/audit')
510            || str_starts_with($norm, '/api/v1/cron')
511            || str_starts_with($norm, '/api/v1/layout')
512            || str_starts_with($norm, self::PATH_SETTINGS);
513    }
514
515    /**
516     * Normalizes versioned API sub-namespace prefixes to standard /api/v1 routes.
517     *
518     * @param string $path Input URI path.
519     * @return string Normalized path.
520     */
521    private function normalizeApiPath(string $path): string
522    {
523        if (str_starts_with($path, '/api/v1/admin/')) {
524            return '/api/v1/' . substr($path, 14);
525        }
526        if (str_starts_with($path, '/api/v1/client/')) {
527            return '/api/v1/' . substr($path, 15);
528        }
529
530        return $path;
531    }
532
533
534
535    /**
536     * Helper to render standardized JSON error responses.
537     *
538     * @param ResponseFactoryInterface $factory Response factory.
539     * @param string                  $message Error message.
540     * @param int                     $status  HTTP status code.
541     * @return ResponseInterface
542     */
543    private function jsonError(ResponseFactoryInterface $factory, string $message, int $status): ResponseInterface
544    {
545        $response = $factory->createResponse($status);
546        $payload = json_encode([
547            'success' => false,
548            'error'   => $message,
549        ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
550        $response->getBody()->write($payload);
551
552        return $response->withHeader('Content-Type', 'application/json');
553    }
554}