feat(core): endpointUrl() helper — query-safe URLs for module routes
Introduce a helper that returns the registered route TARGET for a given
source path, so URLs built for endpoints that carry query parameters do
not rely on MintyPHP's applyRoutes() rewrite layer — that layer compares
the full request URI (including `?query`) against source paths and
silently misses modules where path ≠ target.
- core/Support/helpers/app.php: endpointUrl($path) consults
ModuleRegistry::getRoutes(), returns lurl(target) when the path is a
registered module source path, otherwise falls through to lurl($path).
Safe fallback when the container or registry is unavailable.
- modules/audit/pages/audit/system-audit/index(default).phtml: replace
the ad-hoc target-path workaround with endpointUrl('admin/system-audit/
export'). Callers can now write the natural source path without
knowing about the rewrite trap.
- Apply the same helper to modules/helpdesk/.../domains/index and
pages/admin/users/index for consistency — a no-op where path already
equals target, but establishes the convention: every export/data
endpoint URL in page configs goes through endpointUrl().
- tests/Support/Helpers/EndpointUrlTest: 5 cases covering source→target
resolution, idempotence when path == target, fall-through for
unregistered paths, leading-slash normalization, and graceful
degradation when the registry is missing. Uses
AppContainerIsolationTrait per the contract test in
tests/Architecture/AppContainerIsolationContractTest.
Gates: PHPUnit 1894 OK, PHPStan 0 errors, module:sync ok.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,42 @@ function lurl(string $path = ''): string
|
||||
return localeBase() . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware URL for an endpoint, resolved through the module route map.
|
||||
*
|
||||
* Same as lurl(), but if the given path is a registered module route
|
||||
* source path it returns a URL pointing at the route TARGET path instead.
|
||||
*
|
||||
* Why this exists: MintyPHP's Router::applyRoutes() compares the full
|
||||
* request URI (including `?query`) against registered source paths, so a
|
||||
* browser navigation to `/admin/foo?x=1` misses the rewrite for a module
|
||||
* route `admin/foo → some/other/foo` and falls back to file-based routing
|
||||
* — which cannot find `admin/foo`. Using the target path up-front sidesteps
|
||||
* the rewrite entirely and works regardless of query string.
|
||||
*
|
||||
* Use endpointUrl() for URLs that will carry query params (data endpoints,
|
||||
* export downloads, AJAX calls). Plain lurl() is still fine for nav links
|
||||
* without a query string.
|
||||
*/
|
||||
function endpointUrl(string $path = ''): string
|
||||
{
|
||||
$path = ltrim($path, '/');
|
||||
try {
|
||||
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||
} catch (\Throwable) {
|
||||
return lurl($path);
|
||||
}
|
||||
foreach ($registry->getRoutes() as $route) {
|
||||
if (($route['path'] ?? '') === $path) {
|
||||
$target = (string) ($route['target'] ?? '');
|
||||
if ($target !== '') {
|
||||
return lurl($target);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lurl($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* App title from settings with APP_NAME fallback.
|
||||
*/
|
||||
|
||||
@@ -46,11 +46,7 @@ $pageConfig = [
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
'filterChipMeta' => $filterChipMeta,
|
||||
'gridLang' => $gridLang,
|
||||
// Uses the page target path, not the registered route path: the Router's
|
||||
// applyRoutes() rewrite does not strip query strings before matching, so
|
||||
// "admin/system-audit/export?format=csv" would miss the rewrite and 404.
|
||||
// Matches the convention already used for `dataUrl: 'audit/system-audit/data'`.
|
||||
'exportUrl' => lurl('audit/system-audit/export'),
|
||||
'exportUrl' => endpointUrl('admin/system-audit/export'),
|
||||
'labels' => [
|
||||
'created' => t('Created'),
|
||||
'status' => t('Status'),
|
||||
|
||||
@@ -75,7 +75,7 @@ require templatePath('partials/app-list-filters.phtml');
|
||||
'dataUrl' => lurl('helpdesk/domains-data'),
|
||||
'domainBaseUrl' => lurl('helpdesk/domain/'),
|
||||
'securityLevelDataUrl' => lurl('helpdesk/domains/security-level-data'),
|
||||
'exportUrl' => lurl('helpdesk/domains/export'),
|
||||
'exportUrl' => endpointUrl('helpdesk/domains/export'),
|
||||
'gridLang' => gridLang(),
|
||||
'gridSearch' => $searchConfig,
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
|
||||
@@ -110,7 +110,7 @@ $pageConfig = [
|
||||
'gridSearch' => $searchConfig,
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
'filterChipMeta' => $filterChipMeta,
|
||||
'exportUrl' => lurl('admin/users/export'),
|
||||
'exportUrl' => endpointUrl('admin/users/export'),
|
||||
'currentUserUuid' => (string) $currentUserUuid,
|
||||
'canUpdateUsers' => (bool) $canUpdateUsers,
|
||||
'canUpdateSelf' => (bool) $canUpdateSelf,
|
||||
|
||||
80
tests/Support/Helpers/EndpointUrlTest.php
Normal file
80
tests/Support/Helpers/EndpointUrlTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Support\Helpers;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EndpointUrlTest extends TestCase
|
||||
{
|
||||
use AppContainerIsolationTrait;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->restoreAppContainer();
|
||||
}
|
||||
|
||||
public function testResolvesSourcePathToTarget(): void
|
||||
{
|
||||
$this->installRegistryWithRoutes([
|
||||
['path' => 'admin/system-audit/export', 'target' => 'audit/system-audit/export', 'module_id' => 'audit'],
|
||||
]);
|
||||
|
||||
$this->assertStringEndsWith('audit/system-audit/export', endpointUrl('admin/system-audit/export'));
|
||||
}
|
||||
|
||||
public function testIdempotentWhenPathEqualsTarget(): void
|
||||
{
|
||||
$this->installRegistryWithRoutes([
|
||||
['path' => 'helpdesk/domains/export', 'target' => 'helpdesk/domains/export', 'module_id' => 'helpdesk'],
|
||||
]);
|
||||
|
||||
$this->assertStringEndsWith('helpdesk/domains/export', endpointUrl('helpdesk/domains/export'));
|
||||
}
|
||||
|
||||
public function testFallsThroughToLurlForUnregisteredPath(): void
|
||||
{
|
||||
$this->installRegistryWithRoutes([]);
|
||||
|
||||
$this->assertSame(lurl('admin/users/export'), endpointUrl('admin/users/export'));
|
||||
}
|
||||
|
||||
public function testIgnoresLeadingSlash(): void
|
||||
{
|
||||
$this->installRegistryWithRoutes([
|
||||
['path' => 'admin/system-audit/export', 'target' => 'audit/system-audit/export', 'module_id' => 'audit'],
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
endpointUrl('admin/system-audit/export'),
|
||||
endpointUrl('/admin/system-audit/export')
|
||||
);
|
||||
}
|
||||
|
||||
public function testDegradesGracefullyWhenRegistryMissing(): void
|
||||
{
|
||||
$this->pushAppContainer(new AppContainer());
|
||||
|
||||
// No ModuleRegistry registered — endpointUrl() must fall back, not throw.
|
||||
$this->assertSame(lurl('some/path'), endpointUrl('some/path'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{path: string, target: string, module_id?: string}> $routes
|
||||
*/
|
||||
private function installRegistryWithRoutes(array $routes): void
|
||||
{
|
||||
// ModuleRegistry is final and has no public constructor — build via
|
||||
// reflection and write the merged-routes field directly. Only used
|
||||
// by endpointUrl() via getRoutes(), so that is all we need to stage.
|
||||
$registry = (new \ReflectionClass(ModuleRegistry::class))->newInstanceWithoutConstructor();
|
||||
$routesProp = (new \ReflectionClass(ModuleRegistry::class))->getProperty('mergedRoutes');
|
||||
$routesProp->setValue($registry, $routes);
|
||||
|
||||
$container = new AppContainer();
|
||||
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
|
||||
$this->pushAppContainer($container);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user