diff --git a/app/CatchAllRouteServiceProvider.php b/app/CatchAllRouteServiceProvider.php new file mode 100644 index 0000000000..fd9ab4292b --- /dev/null +++ b/app/CatchAllRouteServiceProvider.php @@ -0,0 +1,81 @@ +match( + '/{path}', + function (Request $request, string $path) use ($app, &$pathHasBeenRewrittenForSilex, &$originalRequest) { + if (!$pathHasBeenRewrittenForSilex) { + // If the path has not been rewritten before, rewrite it and dispatch the request again to the Silex + // router. + $rewrittenPath = (new LegacyPathRewriter())->rewritePath($path); + $pathHasBeenRewrittenForSilex = true; + $originalRequest = $request; + + // Create a new Request object with the rewritten path, because it's basically impossible to overwrite + // the path of an existing Request object even with initialize() or duplicate(). Approach copied from + // https://github.com/graze/silex-trailing-slash-handler/blob/1.x/src/TrailingSlashControllerProvider.php + $rewrittenRequest = Request::create( + $rewrittenPath, + $request->getMethod(), + [], + $request->cookies->all(), + $request->files->all(), + $request->server->all(), + $request->getContent() + ); + $rewrittenRequest = $rewrittenRequest->duplicate( + $request->query->all(), + $request->request->all() + ); + $rewrittenRequest->headers->replace($app['request']->headers->all()); + + // Handle the request with the rewritten path. + // If the Silex app still cannot match the new path to a route, this catch-all route will be matched + // again and that time we will dispatch it to the new PSR router because + // $pathHasBeenRewrittenForSilex will be set to true. + return $app->handle($rewrittenRequest, HttpKernelInterface::SUB_REQUEST); + } + + // If the request is still not matched with a route after it has been rewritten and dispatched on the + // Silex router a second time, convert it to a PSR request and dispatch it on the PSR router instead. + /** @var Router $psrRouter */ + $psrRouter = $app[Router::class]; + $psrRequest = (new DiactorosFactory())->createRequest($originalRequest); + $psrRequest = (new LegacyPathRewriter())->rewriteRequest($psrRequest); + $psrResponse = $psrRouter->handle($psrRequest); + return (new HttpFoundationFactory())->createResponse($psrResponse); + } + )->assert('path', '^.+$'); + } +} diff --git a/app/Error/WebErrorHandlerProvider.php b/app/Error/WebErrorHandlerProvider.php index 35b92084b2..72c7887561 100644 --- a/app/Error/WebErrorHandlerProvider.php +++ b/app/Error/WebErrorHandlerProvider.php @@ -16,6 +16,8 @@ use CultuurNet\UDB3\Security\CommandAuthorizationException; use Error; use Exception; +use League\Route\Http\Exception\MethodNotAllowedException; +use League\Route\Http\Exception\NotFoundException; use Respect\Validation\Exceptions\GroupedValidationException; use Silex\Application; use Silex\ServiceProviderInterface; @@ -89,6 +91,18 @@ private static function convertThrowableToApiProblem(Request $request, Throwable ) ); + case $e instanceof NotFoundException: + return ApiProblem::urlNotFound(); + + case $e instanceof MethodNotAllowedException: + $details = null; + $headers = $e->getHeaders(); + $allowed = $headers['Allow'] ?? null; + if ($allowed !== null) { + $details = 'Allowed: ' . $allowed; + } + return ApiProblem::methodNotAllowed($details); + // Do a best effort to convert "not found" exceptions into an ApiProblem with preferably a detail mentioning // what kind of resource and with what id could not be found. Since the exceptions themselves do not contain // enough info to detect this, we need to get this info from the current request. However this is not diff --git a/app/Http/PsrRouterServiceProvider.php b/app/Http/PsrRouterServiceProvider.php new file mode 100644 index 0000000000..f1d61ac0c7 --- /dev/null +++ b/app/Http/PsrRouterServiceProvider.php @@ -0,0 +1,32 @@ +setStrategy(new CustomLeagueRouterStrategy()); + + $router->get('/{offerType:events|places}/{offerId}/', [$app[GetDetailRequestHandler::class], 'handle']); + + return $router; + } + ); + } + + public function boot(Application $app): void + { + } +} diff --git a/app/LegacyRoutesServiceProvider.php b/app/LegacyRoutesServiceProvider.php deleted file mode 100644 index f645f6ca9d..0000000000 --- a/app/LegacyRoutesServiceProvider.php +++ /dev/null @@ -1,90 +0,0 @@ -match( - '/{path}', - function (Request $originalRequest, string $path) use ($app, &$pathHasBeenRewritten) { - if ($pathHasBeenRewritten) { - return new ApiProblemJsonResponse(ApiProblem::urlNotFound()); - } - - $rewrites = [ - // Pluralize /event and /place - '/^(event|place)($|\/.*)/' => '${1}s${2}', - - // Convert known legacy camelCase resource/collection names to kebab-case - '/bookingAvailability/' => 'booking-availability', - '/bookingInfo/' => 'booking-info', - '/cardSystems/' => 'card-systems', - '/contactPoint/' => 'contact-point', - '/distributionKey/' => 'distribution-key', - '/majorInfo/' => 'major-info', - '/priceInfo/' => 'price-info', - '/subEvents/' => 'sub-events', - '/typicalAgeRange/' => 'typical-age-range', - - // Convert old "calsum" path to "calendar-summary" - '/\/calsum/' => '/calendar-summary', - - // Convert old "news_articles" path to "news-articles" - '/news_articles/' => 'news-articles', - - // Add trailing slash if missing - '/^(.*)(? '${1}/', - ]; - $rewrittenPath = preg_replace(array_keys($rewrites), array_values($rewrites), $path); - - $pathHasBeenRewritten = true; - - // Create a new Request object with the rewritten path, because it's basically impossible to overwrite - // the path of an existing Request object even with initialize() or duplicate(). Approach copied from - // https://github.com/graze/silex-trailing-slash-handler/blob/1.x/src/TrailingSlashControllerProvider.php - $request = Request::create( - $rewrittenPath, - $originalRequest->getMethod(), - [], - $originalRequest->cookies->all(), - $originalRequest->files->all(), - $originalRequest->server->all(), - $originalRequest->getContent() - ); - $request = $request->duplicate( - $originalRequest->query->all(), - $originalRequest->request->all() - ); - $request->headers->replace($app['request']->headers->all()); - - // Handle the request with the rewritten path. - return $app->handle($request, HttpKernelInterface::SUB_REQUEST); - } - )->assert('path', '^.+$'); - } -} diff --git a/app/Offer/OfferControllerProvider.php b/app/Offer/OfferControllerProvider.php index a04dda0ed1..bb4d9c1078 100644 --- a/app/Offer/OfferControllerProvider.php +++ b/app/Offer/OfferControllerProvider.php @@ -34,7 +34,6 @@ public function connect(Application $app): ControllerCollection /** @var ControllerCollection $controllers */ $controllers = $app['controllers_factory']; - $controllers->get('/{offerType}/{offerId}/', GetDetailRequestHandler::class); $controllers->delete('/{offerType}/{offerId}/', DeleteRequestHandler::class); $controllers->put('/{offerType}/{offerId}/name/{language}/', UpdateTitleRequestHandler::class); diff --git a/composer.json b/composer.json index b95c9d27f1..17118fc622 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "league/flysystem": "^2.2.3", "league/flysystem-aws-s3-v3": "^2.1", "league/period": "^3.3", + "league/route": "^5.1", "league/uri": "^6.3", "league/uri-components": "^2.4", "marvin_b8/psr-7-service-provider": "^1.0", diff --git a/composer.lock b/composer.lock index 8287bc6b92..94a2da85e0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3f99b2736ddcaab57c3a49c7cb561d73", + "content-hash": "10e1021e42625df8920dc2562009b76b", "packages": [ { "name": "auth0/auth0-php", @@ -2681,6 +2681,95 @@ ], "time": "2017-11-17T11:28:33+00:00" }, + { + "name": "league/route", + "version": "5.1.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/route.git", + "reference": "adf9b961dc5ffdbcffb2b8d7963c7978f2794c92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/route/zipball/adf9b961dc5ffdbcffb2b8d7963c7978f2794c92", + "reference": "adf9b961dc5ffdbcffb2b8d7963c7978f2794c92", + "shasum": "" + }, + "require": { + "nikic/fast-route": "^1.3", + "opis/closure": "^3.5.5", + "php": "^7.2 || ^8.0", + "psr/container": "^1.0|^2.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0.1", + "psr/http-server-handler": "^1.0.1", + "psr/http-server-middleware": "^1.0.1", + "psr/simple-cache": "^1.0" + }, + "replace": { + "orno/http": "~1.0", + "orno/route": "~1.0" + }, + "require-dev": { + "laminas/laminas-diactoros": "^2.3", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpunit/phpunit": "^8.5", + "roave/security-advisories": "dev-master", + "scrutinizer/ocular": "^1.8", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev", + "dev-5.x": "5.x-dev", + "dev-4.x": "4.x-dev", + "dev-3.x": "3.x-dev", + "dev-2.x": "2.x-dev", + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Route\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Phil Bennett", + "email": "philipobenito@gmail.com", + "role": "Developer" + } + ], + "description": "Fast routing and dispatch component including PSR-15 middleware, built on top of FastRoute.", + "homepage": "https://github.com/thephpleague/route", + "keywords": [ + "dispatcher", + "league", + "psr-15", + "psr-7", + "psr15", + "psr7", + "route", + "router" + ], + "support": { + "issues": "https://github.com/thephpleague/route/issues", + "source": "https://github.com/thephpleague/route/tree/5.1.2" + }, + "funding": [ + { + "url": "https://github.com/philipobenito", + "type": "github" + } + ], + "time": "2021-07-30T08:33:09+00:00" + }, { "name": "league/uri", "version": "6.5.0", @@ -3549,6 +3638,56 @@ ], "time": "2019-10-14T05:51:36+00:00" }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, { "name": "ocramius/proxy-manager", "version": "2.1.1", @@ -3618,6 +3757,71 @@ ], "time": "2017-05-04T11:12:50+00:00" }, + { + "name": "opis/closure", + "version": "3.6.3", + "source": { + "type": "git", + "url": "https://github.com/opis/closure.git", + "reference": "3d81e4309d2a927abbe66df935f4bb60082805ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/closure/zipball/3d81e4309d2a927abbe66df935f4bb60082805ad", + "reference": "3d81e4309d2a927abbe66df935f4bb60082805ad", + "shasum": "" + }, + "require": { + "php": "^5.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "jeremeamia/superclosure": "^2.0", + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.6.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php" + ], + "psr-4": { + "Opis\\Closure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "A library that can be used to serialize closures (anonymous functions) and arbitrary objects.", + "homepage": "https://opis.io/closure", + "keywords": [ + "anonymous functions", + "closure", + "function", + "serializable", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/opis/closure/issues", + "source": "https://github.com/opis/closure/tree/3.6.3" + }, + "time": "2022-01-27T09:35:39+00:00" + }, { "name": "opis/json-schema", "version": "2.1.0", diff --git a/src/Http/ApiProblem/ApiProblem.php b/src/Http/ApiProblem/ApiProblem.php index beff2eb75b..d62ad842ca 100644 --- a/src/Http/ApiProblem/ApiProblem.php +++ b/src/Http/ApiProblem/ApiProblem.php @@ -222,6 +222,16 @@ public static function notAcceptable(string $detail = null): self ); } + public static function methodNotAllowed(string $detail = null): self + { + return self::create( + 'https://api.publiq.be/probs/method/not-allowed', + 'Method not allowed', + 405, + $detail + ); + } + public static function urlNotFound(string $detail = null): self { return self::create( diff --git a/src/Http/CustomLeagueRouterStrategy.php b/src/Http/CustomLeagueRouterStrategy.php new file mode 100644 index 0000000000..ebaf1c3f3c --- /dev/null +++ b/src/Http/CustomLeagueRouterStrategy.php @@ -0,0 +1,96 @@ +throwThrowableMiddleware($exception); + } + + public function getNotFoundDecorator(NotFoundException $exception): MiddlewareInterface + { + return $this->throwThrowableMiddleware($exception); + } + + public function getThrowableHandler(): MiddlewareInterface + { + return new class() implements MiddlewareInterface { + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { + try { + return $handler->handle($request); + } catch (Throwable $e) { + throw $e; + } + } + }; + } + + public function invokeRouteCallable(Route $route, ServerRequestInterface $request): ResponseInterface + { + $controller = $route->getCallable($this->getContainer()); + $response = $controller($request, $route->getVars()); + return $this->decorateResponse($response); + } + + protected function throwThrowableMiddleware(Throwable $error): MiddlewareInterface + { + return new class($error) implements MiddlewareInterface { + protected $error; + + public function __construct(Throwable $error) + { + $this->error = $error; + } + + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { + throw $this->error; + } + }; + } +} diff --git a/src/Http/LegacyPathRewriter.php b/src/Http/LegacyPathRewriter.php new file mode 100644 index 0000000000..dbe1c3941f --- /dev/null +++ b/src/Http/LegacyPathRewriter.php @@ -0,0 +1,49 @@ + '${1}${2}s${3}', + + // Convert known legacy camelCase resource/collection names to kebab-case + '/bookingAvailability/' => 'booking-availability', + '/bookingInfo/' => 'booking-info', + '/cardSystems/' => 'card-systems', + '/contactPoint/' => 'contact-point', + '/distributionKey/' => 'distribution-key', + '/majorInfo/' => 'major-info', + '/priceInfo/' => 'price-info', + '/subEvents/' => 'sub-events', + '/typicalAgeRange/' => 'typical-age-range', + + // Convert old "calsum" path to "calendar-summary" + '/\/calsum/' => '/calendar-summary', + + // Convert old "news_articles" path to "news-articles" + '/news_articles/' => 'news-articles', + + // Add trailing slash if missing + '/^(.*)(? '${1}/', + ]; + + public function rewritePath(string $path): string + { + return preg_replace(array_keys(self::REWRITES), array_values(self::REWRITES), $path); + } + + public function rewriteRequest(ServerRequestInterface $request): ServerRequestInterface + { + $uri = $request->getUri(); + $path = $uri->getPath(); + $rewrittenPath = $this->rewritePath($path); + $rewrittenUri = $uri->withPath($rewrittenPath); + return $request->withUri($rewrittenUri); + } +} diff --git a/src/Http/Request/RouteParameters.php b/src/Http/Request/RouteParameters.php index b10725bae9..e160bcf1c0 100644 --- a/src/Http/Request/RouteParameters.php +++ b/src/Http/Request/RouteParameters.php @@ -14,25 +14,31 @@ final class RouteParameters { - private array $routeParameters; + private array $attributes; public function __construct(ServerRequestInterface $request) { - $attributes = $request->getAttributes(); - $this->routeParameters = $attributes['_route_params'] ?? []; + $this->attributes = $request->getAttributes(); } public function get(string $parameterName): string { - if (!isset($this->routeParameters[$parameterName])) { - throw new RuntimeException('Route parameter ' . $parameterName . ' not found in given ServerRequestInterface!'); + // The League router puts the parameters directly in the request attributes. + if (isset($this->attributes[$parameterName])) { + return (string) $this->attributes[$parameterName]; } - return (string) $this->routeParameters[$parameterName]; + // The Silex router puts the parameters in a "_route_params" nested array. + if (isset($this->attributes['_route_params'][$parameterName])) { + return (string) $this->attributes['_route_params'][$parameterName]; + } + throw new RuntimeException('Route parameter ' . $parameterName . ' not found in given ServerRequestInterface!'); } public function has(string $parameterName): bool { - return isset($this->routeParameters[$parameterName]); + // The League router puts the parameters directly in the request attributes. + // The Silex router puts the parameters in a "_route_params" nested array. + return isset($this->attributes[$parameterName]) || isset($this->attributes['_route_params'][$parameterName]); } public function getEventId(): string diff --git a/tests/Http/LegacyPathRewriterTest.php b/tests/Http/LegacyPathRewriterTest.php new file mode 100644 index 0000000000..f9a50ddea5 --- /dev/null +++ b/tests/Http/LegacyPathRewriterTest.php @@ -0,0 +1,136 @@ +rewritePath($originalPath); + $this->assertEquals($expectedRewrite, $actualRewrite); + } + + public function rewritesDataProvider(): array + { + return [ + 'event_detail_singular_to_pluralized_and_trailing_slash' => [ + 'original' => '/event/ccb8f44b-d316-41f0-aeec-2b4149be6cd3', + 'rewrite' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + ], + 'place_detail_singular_to_pluralized_and_trailing_slash' => [ + 'original' => '/place/ccb8f44b-d316-41f0-aeec-2b4149be6cd3', + 'rewrite' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + ], + 'event_detail_untouched' => [ + 'original' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + 'rewrite' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + ], + 'place_detail_untouched' => [ + 'original' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + 'rewrite' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + ], + 'event_detail_without_prefixed_slash_singular_to_pluralized_and_trailing_slash' => [ + 'original' => 'event/ccb8f44b-d316-41f0-aeec-2b4149be6cd3', + 'rewrite' => 'events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + ], + 'place_detail_without_prefixed_slash_singular_to_pluralized_and_trailing_slash' => [ + 'original' => 'place/ccb8f44b-d316-41f0-aeec-2b4149be6cd3', + 'rewrite' => 'places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + ], + 'event_detail_without_prefixed_slash_untouched' => [ + 'original' => 'events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + 'rewrite' => 'events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + ], + 'place_detail_without_prefixed_slash_untouched' => [ + 'original' => 'places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + 'rewrite' => 'places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/', + ], + 'event_update_name_singular_to_pluralized_and_trailing_slash' => [ + 'original' => '/event/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/name/nl', + 'rewrite' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/name/nl/', + ], + 'place_update_name_singular_to_pluralized_and_trailing_slash' => [ + 'original' => '/place/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/name/nl', + 'rewrite' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/name/nl/', + ], + 'event_update_name_untouched' => [ + 'original' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/name/nl/', + 'rewrite' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/name/nl/', + ], + 'place_update_name_untouched' => [ + 'original' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/name/nl/', + 'rewrite' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/name/nl/', + ], + 'event_singular_calsum_to_pluralized_calendar_summary_and_trailing_slash' => [ + 'original' => '/event/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/calsum', + 'rewrite' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/calendar-summary/', + ], + 'place_singular_calsum_to_pluralized_calendar_summary_and_trailing_slash' => [ + 'original' => '/place/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/calsum', + 'rewrite' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/calendar-summary/', + ], + 'event_calsum_to_calendar_summary_and_trailing_slash' => [ + 'original' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/calsum', + 'rewrite' => '/events/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/calendar-summary/', + ], + 'place_calsum_to_calendar_summary_and_trailing_slash' => [ + 'original' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/calsum', + 'rewrite' => '/places/ccb8f44b-d316-41f0-aeec-2b4149be6cd3/calendar-summary/', + ], + 'news_articles_with_underscore_to_hyphen_and_trailing_slash' => [ + 'original' => '/news_articles', + 'rewrite' => '/news-articles/', + ], + 'news_articles_detail_with_underscore_to_kebab_case_and_trailing_slash' => [ + 'original' => '/news_articles/8a5fcfae-e698-437a-87a5-32cd4ac61076', + 'rewrite' => '/news-articles/8a5fcfae-e698-437a-87a5-32cd4ac61076/', + ], + 'event_update_booking_availability_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/bookingAvailability', + 'rewrite' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/booking-availability/', + ], + 'event_update_booking_info_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/bookingInfo', + 'rewrite' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/booking-info/', + ], + 'event_update_contact_point_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/contactPoint', + 'rewrite' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/contact-point/', + ], + 'event_update_major_info_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/majorInfo', + 'rewrite' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/major-info/', + ], + 'event_update_price_info_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/priceInfo', + 'rewrite' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/price-info/', + ], + 'event_update_sub_events_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/subEvents', + 'rewrite' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/sub-events/', + ], + 'event_update_typical_age_range_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/typicalAgeRange', + 'rewrite' => '/events/8a5fcfae-e698-437a-87a5-32cd4ac61076/typical-age-range/', + ], + 'event_uitpas_card_systems_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/uitpas/events/08a70475-4ffe-44b9-b0b9-256c82e7d747/cardSystems', + 'rewrite' => '/uitpas/events/08a70475-4ffe-44b9-b0b9-256c82e7d747/card-systems/', + ], + 'event_uitpas_distribution_keys_camel_case_to_kebab_case_and_trailing_slash' => [ + 'original' => '/uitpas/events/08a70475-4ffe-44b9-b0b9-256c82e7d747/cardSystems/1/distributionKey', + 'rewrite' => '/uitpas/events/08a70475-4ffe-44b9-b0b9-256c82e7d747/card-systems/1/distribution-key/', + ], + ]; + } +} diff --git a/tests/Http/Request/Psr7RequestBuilder.php b/tests/Http/Request/Psr7RequestBuilder.php index 3abccdf474..5cb0d976c9 100644 --- a/tests/Http/Request/Psr7RequestBuilder.php +++ b/tests/Http/Request/Psr7RequestBuilder.php @@ -99,19 +99,22 @@ public function withRouteParameter(string $parameterName, string $parameterValue public function build(string $method): ServerRequestInterface { - return ( - new Request( - $method, - $this->uri ?? self::getUriFactory()->createUri(), - $this->headers ?? new Headers(), - [], - [], - $this->body ?? self::getStreamFactory()->createStream(), - $this->files, - ) - ) - ->withAttribute('_route_params', $this->routeParameters) - ->withParsedBody($this->parsedBody); + $request = new Request( + $method, + $this->uri ?? self::getUriFactory()->createUri(), + $this->headers ?? new Headers(), + [], + [], + $this->body ?? self::getStreamFactory()->createStream(), + $this->files, + ); + + foreach ($this->routeParameters as $routeParameter => $value) { + $request = $request->withAttribute($routeParameter, $value); + } + + $request = $request->withParsedBody($this->parsedBody); + return $request; } private static function getUriFactory(): UriFactory diff --git a/tests/Http/Request/RouteParametersTest.php b/tests/Http/Request/RouteParametersTest.php index cc4635a24c..b83a6c5f36 100644 --- a/tests/Http/Request/RouteParametersTest.php +++ b/tests/Http/Request/RouteParametersTest.php @@ -18,10 +18,10 @@ class RouteParametersTest extends TestCase /** * @test */ - public function it_should_return_an_existing_route_parameter_from_the_request_as_string(): void + public function it_should_return_an_existing_route_parameter_from_the_psr_request_as_string(): void { $request = (new Psr7RequestBuilder())->build('PUT'); - $request = $request->withAttribute('_route_params', ['foo' => 'bar']); + $request = $request->withAttribute('foo', 'bar'); $routeParameters = new RouteParameters($request); $this->assertEquals('bar', $routeParameters->get('foo')); @@ -30,14 +30,13 @@ public function it_should_return_an_existing_route_parameter_from_the_request_as /** * @test */ - public function it_should_throw_a_runtime_exception_if_a_parameter_is_requested_that_is_not_set(): void + public function it_should_return_an_existing_route_parameter_from_the_silex_request_as_string(): void { $request = (new Psr7RequestBuilder())->build('PUT'); - $request = $request->withAttribute('_route_params', []); + $request = $request->withAttribute('_route_params', ['foo' => 'bar']); $routeParameters = new RouteParameters($request); - $this->expectException(RuntimeException::class); - $routeParameters->get('foo'); + $this->assertEquals('bar', $routeParameters->get('foo')); } /** diff --git a/web/index.php b/web/index.php index e176777dcc..039a25c4d7 100644 --- a/web/index.php +++ b/web/index.php @@ -12,13 +12,14 @@ use CultuurNet\UDB3\Role\ValueObjects\Permission; use CultuurNet\UDB3\Silex\ApiName; use CultuurNet\UDB3\Silex\Curators\CuratorsControllerProvider; +use CultuurNet\UDB3\Silex\Http\PsrRouterServiceProvider; use CultuurNet\UDB3\Silex\Udb3ControllerCollection; use CultuurNet\UDB3\Silex\Error\WebErrorHandlerProvider; use CultuurNet\UDB3\Silex\Error\ErrorLogger; use CultuurNet\UDB3\Silex\Event\EventControllerProvider; use CultuurNet\UDB3\Silex\Http\RequestHandlerControllerServiceProvider; use CultuurNet\UDB3\Silex\Import\ImportControllerProvider; -use CultuurNet\UDB3\Silex\LegacyRoutesServiceProvider; +use CultuurNet\UDB3\Silex\CatchAllRouteServiceProvider; use CultuurNet\UDB3\Silex\Offer\DeprecatedOfferControllerProvider; use CultuurNet\UDB3\Silex\Offer\OfferControllerProvider; use CultuurNet\UDB3\Silex\Place\PlaceControllerProvider; @@ -57,6 +58,13 @@ */ $app->register(new RequestHandlerControllerServiceProvider()); +/** + * Register a PSR-7 / PSR-15 compatible router. + * Will be used in CatchAllRouteServiceProvider to route unmatched requests from Silex to the PSR router, until we can + * completely by-pass the Silex router. + */ +$app->register(new PsrRouterServiceProvider()); + /** * Firewall configuration. * @@ -247,11 +255,15 @@ function () { // Match with any OPTIONS request with any URL and return a 204 No Content. Actual CORS headers will be added by an // ->after() middleware, which adds CORS headers to every request (so non-preflighted requests like simple GETs also get // the needed CORS headers). +// Note that the new PSR router in PsrRouterServiceProvider already supports OPTIONS requests for all routes registered +// in the new router, so this can be removed completely once all route definitions and handlers are moved to the new +// router. $app->options('/{path}', fn () => new NoContentResponse())->assert('path', '^.+$'); // Add CORS headers to every request. We explicitly allow everything, because we don't use cookies and our API is not on // an internal network, so CORS requests are never a security issue in our case. This greatly reduces the risk of CORS // bugs in our frontend and other integrations. +// @todo III-4235 Move to Middleware in new PSR router when all routes are registered on the new router. $app->after( function (Request $request, Response $response) { // Allow any known method regardless of the URL. @@ -272,7 +284,7 @@ function (Request $request, Response $response) { } ); -$app->register(new LegacyRoutesServiceProvider()); +$app->register(new CatchAllRouteServiceProvider()); JsonSchemaLocator::setSchemaDirectory(__DIR__ . '/../vendor/publiq/udb3-json-schemas');