diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index f7c515fb..e7e24916 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -7,16 +7,20 @@ namespace Shlinkio\Shlink; use Fig\Http\Message\RequestMethodInterface; use RKA\Middleware\IpAddress; use Shlinkio\Shlink\Core\Action as CoreAction; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Rest\Action; use Shlinkio\Shlink\Rest\ConfigProvider; use Shlinkio\Shlink\Rest\Middleware; use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler; +use function sprintf; + // The order of the routes defined here matters. Changing it might cause path conflicts return (static function (): array { $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; + $multiSegment = (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false); return [ @@ -60,7 +64,7 @@ return (static function (): array { Action\Domain\DomainRedirectsAction::getRouteDef(), Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]), - ]), + ], $multiSegment), // Non-rest [ @@ -73,7 +77,7 @@ return (static function (): array { ], [ 'name' => CoreAction\PixelAction::class, - 'path' => '/{shortCode:.+}/track', + 'path' => sprintf('/{shortCode%s}/track', $multiSegment ? ':.+' : ''), 'middleware' => [ IpAddress::class, CoreAction\PixelAction::class, @@ -82,7 +86,7 @@ return (static function (): array { ], [ 'name' => CoreAction\QrCodeAction::class, - 'path' => '/{shortCode:.+}/qr-code', + 'path' => sprintf('/{shortCode%s}/qr-code', $multiSegment ? ':.+' : ''), 'middleware' => [ CoreAction\QrCodeAction::class, ], @@ -90,7 +94,7 @@ return (static function (): array { ], [ 'name' => CoreAction\RedirectAction::class, - 'path' => '/{shortCode:.+}', + 'path' => sprintf('/{shortCode%s}', $multiSegment ? ':.+' : ''), 'middleware' => [ IpAddress::class, CoreAction\RedirectAction::class, diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index a68f24f3..8f8689be 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -59,6 +59,7 @@ enum EnvVars: string case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES'; case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; case TIMEZONE = 'TIMEZONE'; + case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED'; /** @deprecated */ case VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; /** @deprecated */ diff --git a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php index 40b03b6e..8059e5ab 100644 --- a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class DeleteShortUrlAction extends AbstractRestAction { - protected const ROUTE_PATH = '/short-urls/{shortCode:.+}'; + protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE]; public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService) diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index d82aef3e..71cf8bf3 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class EditShortUrlAction extends AbstractRestAction { - protected const ROUTE_PATH = '/short-urls/{shortCode:.+}'; + protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH]; public function __construct( diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index 66719cba..aae1a895 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ResolveShortUrlAction extends AbstractRestAction { - protected const ROUTE_PATH = '/short-urls/{shortCode:.+}'; + protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; public function __construct( diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index 45254f18..5496ba35 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -18,7 +18,7 @@ class ShortUrlVisitsAction extends AbstractRestAction { use PagerfantaUtilsTrait; - protected const ROUTE_PATH = '/short-urls/{shortCode:.+}/visits'; + protected const ROUTE_PATH = '/short-urls/{shortCode}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; public function __construct(private VisitsStatsHelperInterface $visitsHelper) diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index b8d5f9cc..3304ce4d 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -8,6 +8,7 @@ use function Functional\first; use function Functional\map; use function Shlinkio\Shlink\Config\loadConfigFromGlob; use function sprintf; +use function str_replace; class ConfigProvider { @@ -20,11 +21,14 @@ class ConfigProvider return loadConfigFromGlob(__DIR__ . '/../config/{,*.}config.php'); } - public static function applyRoutesPrefix(array $routes): array + public static function applyRoutesPrefix(array $routes, bool $multiSegmentEnabled): array { $healthRoute = self::buildUnversionedHealthRouteFromExistingRoutes($routes); - $prefixedRoutes = map($routes, static function (array $route) { + $prefixedRoutes = map($routes, static function (array $route) use ($multiSegmentEnabled) { ['path' => $path] = $route; + if ($multiSegmentEnabled) { + $path = str_replace('{shortCode}', '{shortCode:.+}', $path); + } $route['path'] = sprintf('%s%s', self::ROUTES_PREFIX, $path); return $route; @@ -40,7 +44,7 @@ class ConfigProvider return null; } - $path = $healthRoute['path']; + ['path' => $path] = $healthRoute; $healthRoute['path'] = sprintf('%s%s', self::UNVERSIONED_ROUTES_PREFIX, $path); $healthRoute['name'] = self::UNVERSIONED_HEALTH_ENDPOINT_NAME; diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php index bffb0421..b09e2b3b 100644 --- a/module/Rest/test-api/Middleware/CorsTest.php +++ b/module/Rest/test-api/Middleware/CorsTest.php @@ -71,7 +71,7 @@ class CorsTest extends ApiTestCase public function providePreflightEndpoints(): iterable { -// yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE']; // TODO This won't work with multi-segment + yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE']; // TODO This won't work with multi-segment yield 'short URLs route' => ['/short-urls', 'GET,POST']; yield 'tags route' => ['/tags', 'GET,DELETE,PUT']; yield 'health route' => ['/health', 'GET']; diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index a3f7d0c9..07fa4e43 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -33,9 +33,9 @@ class ConfigProviderTest extends TestCase * @test * @dataProvider provideRoutesConfig */ - public function routesAreProperlyPrefixed(array $routes, array $expected): void + public function routesAreProperlyPrefixed(array $routes, bool $multiSegmentEnabled, array $expected): void { - self::assertEquals($expected, ConfigProvider::applyRoutesPrefix($routes)); + self::assertEquals($expected, ConfigProvider::applyRoutesPrefix($routes, $multiSegmentEnabled)); } public function provideRoutesConfig(): iterable @@ -47,6 +47,7 @@ class ConfigProviderTest extends TestCase ['path' => '/baz/foo'], ['path' => '/health'], ], + false, [ ['path' => '/rest/v{version:1|2}/foo'], ['path' => '/rest/v{version:1|2}/bar'], @@ -61,11 +62,25 @@ class ConfigProviderTest extends TestCase ['path' => '/bar'], ['path' => '/baz/foo'], ], + false, [ ['path' => '/rest/v{version:1|2}/foo'], ['path' => '/rest/v{version:1|2}/bar'], ['path' => '/rest/v{version:1|2}/baz/foo'], ], ]; + yield 'multi-segment enabled' => [ + [ + ['path' => '/foo'], + ['path' => '/bar/{shortCode}'], + ['path' => '/baz/{shortCode}/foo'], + ], + true, + [ + ['path' => '/rest/v{version:1|2}/foo'], + ['path' => '/rest/v{version:1|2}/bar/{shortCode:.+}'], + ['path' => '/rest/v{version:1|2}/baz/{shortCode:.+}/foo'], + ], + ]; } }