diff --git a/Slim/App.php b/Slim/App.php index 2907d4e13..5d808461f 100644 --- a/Slim/App.php +++ b/Slim/App.php @@ -17,6 +17,7 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Slim\Interfaces\EmitterInterface; +use Slim\Interfaces\RouteInterface; use Slim\Interfaces\RouterInterface; use Slim\Interfaces\ServerRequestCreatorInterface; use Slim\Middleware\EndpointMiddleware; @@ -106,9 +107,9 @@ public function getContainer(): ContainerInterface * @param string $path The URI pattern for the route * @param callable|string $handler The route handler callable or controller method * - * @return Route The newly created route instance + * @return RouteInterface The newly created route instance */ - public function map(array $methods, string $path, callable|string $handler): Route + public function map(array $methods, string $path, callable|string $handler): RouteInterface { return $this->router->map($methods, $path, $handler); } @@ -143,7 +144,7 @@ public function setBasePath(string $basePath): self */ public function getBasePath(): string { - return $this->router->getBasePath(); + return $this->router->getBasePath() ?? ''; } /** diff --git a/Slim/Exception/HttpMethodNotAllowedException.php b/Slim/Exception/HttpMethodNotAllowedException.php index a75d9c4e5..99b47ccb5 100644 --- a/Slim/Exception/HttpMethodNotAllowedException.php +++ b/Slim/Exception/HttpMethodNotAllowedException.php @@ -10,8 +10,6 @@ namespace Slim\Exception; -use function implode; - final class HttpMethodNotAllowedException extends HttpSpecializedException { /** @@ -46,7 +44,7 @@ public function getAllowedMethods(): array public function setAllowedMethods(array $methods): self { $this->allowedMethods = $methods; - $this->message = 'Method not allowed. Must be one of: ' . implode(', ', $methods); + $this->message = 'Method not allowed.'; return $this; } diff --git a/Slim/Interfaces/RouteCollectionInterface.php b/Slim/Interfaces/RouteCollectionInterface.php index 667e487df..31bceac1a 100644 --- a/Slim/Interfaces/RouteCollectionInterface.php +++ b/Slim/Interfaces/RouteCollectionInterface.php @@ -4,7 +4,6 @@ namespace Slim\Interfaces; -use Slim\Routing\Route; use Slim\Routing\RouteGroup; /** @@ -21,9 +20,9 @@ interface RouteCollectionInterface * @param string $path Route path. * @param callable|string $handler Route handler or controller action. * - * @return Route + * @return RouteInterface */ - public function get(string $path, callable|string $handler): Route; + public function get(string $path, callable|string $handler): RouteInterface; /** * Register a POST route. @@ -31,9 +30,9 @@ public function get(string $path, callable|string $handler): Route; * @param string $path * @param callable|string $handler * - * @return Route + * @return RouteInterface */ - public function post(string $path, callable|string $handler): Route; + public function post(string $path, callable|string $handler): RouteInterface; /** * Register a PUT route. @@ -41,9 +40,9 @@ public function post(string $path, callable|string $handler): Route; * @param string $path * @param callable|string $handler * - * @return Route + * @return RouteInterface */ - public function put(string $path, callable|string $handler): Route; + public function put(string $path, callable|string $handler): RouteInterface; /** * Register a PATCH route. @@ -51,9 +50,9 @@ public function put(string $path, callable|string $handler): Route; * @param string $path * @param callable|string $handler * - * @return Route + * @return RouteInterface */ - public function patch(string $path, callable|string $handler): Route; + public function patch(string $path, callable|string $handler): RouteInterface; /** * Register a DELETE route. @@ -61,9 +60,9 @@ public function patch(string $path, callable|string $handler): Route; * @param string $path * @param callable|string $handler * - * @return Route + * @return RouteInterface */ - public function delete(string $path, callable|string $handler): Route; + public function delete(string $path, callable|string $handler): RouteInterface; /** * Register an OPTIONS route. @@ -71,9 +70,9 @@ public function delete(string $path, callable|string $handler): Route; * @param string $path * @param callable|string $handler * - * @return Route + * @return RouteInterface */ - public function options(string $path, callable|string $handler): Route; + public function options(string $path, callable|string $handler): RouteInterface; /** * Register a route for any HTTP method. @@ -81,9 +80,9 @@ public function options(string $path, callable|string $handler): Route; * @param string $path * @param callable|string $handler * - * @return Route + * @return RouteInterface */ - public function any(string $path, callable|string $handler): Route; + public function any(string $path, callable|string $handler): RouteInterface; /** * Register a route with multiple HTTP methods. @@ -92,9 +91,9 @@ public function any(string $path, callable|string $handler): Route; * @param string $path Route path. * @param callable|string $handler Route handler. * - * @return Route + * @return RouteInterface */ - public function map(array $methods, string $path, callable|string $handler): Route; + public function map(array $methods, string $path, callable|string $handler): RouteInterface; /** * Register a group of routes under a common path prefix. diff --git a/Slim/Interfaces/RouterInterface.php b/Slim/Interfaces/RouterInterface.php index f10c9eb66..e5e0a6827 100644 --- a/Slim/Interfaces/RouterInterface.php +++ b/Slim/Interfaces/RouterInterface.php @@ -5,25 +5,23 @@ use FastRoute\RouteCollector; use InvalidArgumentException; use Psr\Http\Server\MiddlewareInterface; -use Slim\Routing\Route; use Slim\Routing\RouteGroup; -use Slim\Routing\Router; interface RouterInterface { - public function get(string $path, callable|string $handler): Route; + public function get(string $path, callable|string $handler): RouteInterface; - public function post(string $path, callable|string $handler): Route; + public function post(string $path, callable|string $handler): RouteInterface; - public function put(string $path, callable|string $handler): Route; + public function put(string $path, callable|string $handler): RouteInterface; - public function patch(string $path, callable|string $handler): Route; + public function patch(string $path, callable|string $handler): RouteInterface; - public function delete(string $path, callable|string $handler): Route; + public function delete(string $path, callable|string $handler): RouteInterface; - public function options(string $path, callable|string $handler): Route; + public function options(string $path, callable|string $handler): RouteInterface; - public function any(string $pattern, callable|string $handler): Route; + public function any(string $pattern, callable|string $handler): RouteInterface; /** * @param array $methods @@ -32,7 +30,7 @@ public function any(string $pattern, callable|string $handler): Route; * * @throws InvalidArgumentException */ - public function map(array $methods, string $path, callable|string $handler): Route; + public function map(array $methods, string $path, callable|string $handler): RouteInterface; public function group(string $path, callable $handler): RouteGroup; @@ -40,14 +38,14 @@ public function getRouteCollector(): RouteCollector; public function setBasePath(string $basePath): void; - public function getBasePath(): string; + public function getBasePath(): ?string; /** * @return array */ public function getMiddleware(): array; - public function add(MiddlewareInterface|callable|string $middleware): Router; + public function add(MiddlewareInterface|callable|string $middleware): RouterInterface; - public function addMiddleware(MiddlewareInterface $middleware): Router; + public function addMiddleware(MiddlewareInterface $middleware): RouterInterface; } diff --git a/Slim/Middleware/BasePathMiddleware.php b/Slim/Middleware/BasePathMiddleware.php index f8f1e474c..1225466ca 100644 --- a/Slim/Middleware/BasePathMiddleware.php +++ b/Slim/Middleware/BasePathMiddleware.php @@ -20,53 +20,41 @@ final class BasePathMiddleware implements MiddlewareInterface { private RouterInterface $router; - private string $phpSapi; - - /** - * The constructor. - * - * @param RouterInterface $router The router - * @param string $phpSapi The type of interface between web server and PHP - * - * Supported: 'apache2handler' - * Not supported: 'cgi', 'cgi-fcgi', 'fpm-fcgi', 'litespeed', 'cli-server' - */ - public function __construct(RouterInterface $router, string $phpSapi = PHP_SAPI) + public function __construct(RouterInterface $router) { - $this->phpSapi = $phpSapi; $this->router = $router; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $basePath = ''; - - if ($this->phpSapi === 'apache2handler') { - $basePath = $this->getBasePathByRequestUri($request); + $basePath = $this->router->getBasePath(); + if ($basePath === null) { + $basePath = $this->detectBasePath($request); + $this->router->setBasePath($basePath); } - $this->router->setBasePath($basePath); - return $handler->handle($request); } /** - * Return basePath for most common webservers, such as Apache. - * @param ServerRequestInterface $request + * Return basePath for most common webservers. */ - private function getBasePathByRequestUri(ServerRequestInterface $request): string + private function detectBasePath(ServerRequestInterface $request): string { - $basePath = $request->getUri()->getPath(); - $scriptName = $request->getServerParams()['SCRIPT_NAME'] ?? ''; + $serverParams = $request->getServerParams(); + $scriptName = $serverParams['SCRIPT_NAME'] ?? + $serverParams['PHP_SELF'] ?? + $serverParams['ORIG_SCRIPT_NAME'] ?? ''; $scriptName = str_replace('\\', '/', dirname($scriptName, 2)); if ($scriptName === '/') { return ''; } + $path = $request->getUri()->getPath(); $length = strlen($scriptName); - $basePath = $length > 0 ? substr($basePath, 0, $length) : $basePath; + $path = $length > 0 ? substr($path, 0, $length) : $path; - return strlen($basePath) > 1 ? $basePath : ''; + return strlen($path) > 1 ? $path : ''; } } diff --git a/Slim/Middleware/CorsMiddleware.php b/Slim/Middleware/CorsMiddleware.php deleted file mode 100644 index 4b0a7e018..000000000 --- a/Slim/Middleware/CorsMiddleware.php +++ /dev/null @@ -1,247 +0,0 @@ -|null List of allowed origins, or null to allow all. - */ - private ?array $allowedOrigins = null; - - /** - * @var bool Whether to include Access-Control-Allow-Credentials. - */ - private bool $allowCredentials = false; - - /** - * @var array Allowed HTTP methods for CORS requests. - */ - private array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; - - /** - * @var array Allowed request headers. Use ['*'] to allow all. - */ - private array $allowedHeaders = ['*']; - - /** - * @var array Headers exposed to the browser via Access-Control-Expose-Headers. - */ - private array $exposedHeaders = []; - - /** - * @var bool Whether to cache OPTIONS responses when possible. - */ - private bool $useCache = true; - - public function __construct(ResponseFactoryInterface $responseFactory) - { - $this->responseFactory = $responseFactory; - } - - public function process( - ServerRequestInterface $request, - RequestHandlerInterface $handler, - ): ResponseInterface { - $origin = $request->getHeaderLine('Origin'); - - if ($request->getMethod() === 'OPTIONS') { - $response = $this->responseFactory->createResponse(); - } else { - $response = $handler->handle($request); - } - - // Handle origin header - if ($origin && $this->isOriginAllowed($origin)) { - $response = $response->withHeader('Access-Control-Allow-Origin', $origin); - - // Allow credentials only with specific origin - if ($this->allowCredentials) { - $response = $response->withHeader('Access-Control-Allow-Credentials', 'true'); - } - } elseif ($this->allowedOrigins === null) { - // If no specific origins are set, use wildcard - $response = $response->withHeader('Access-Control-Allow-Origin', '*'); - } - - // Add allowed methods - if (!empty($this->allowedMethods)) { - $response = $response->withHeader( - 'Access-Control-Allow-Methods', - implode(', ', $this->allowedMethods), - ); - } - - // Add allowed headers - if (!empty($this->allowedHeaders)) { - $response = $response->withHeader( - 'Access-Control-Allow-Headers', - implode(', ', $this->allowedHeaders), - ); - } - - // Add exposed headers - if (!empty($this->exposedHeaders)) { - $response = $response->withHeader( - 'Access-Control-Expose-Headers', - implode(', ', $this->exposedHeaders), - ); - } - - // Add max age header if configured - if ($this->maxAge !== null) { - $response = $response->withHeader('Access-Control-Max-Age', (string)$this->maxAge); - } - - // Add cache control headers if enabled - if ($this->useCache) { - $response = $response - ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - ->withHeader('Pragma', 'no-cache'); - } - - return $response; - } - - /** - * Set the Access-Control-Max-Age header value in seconds. - * Set to null to disable the header. - * @param ?int $maxAge - */ - public function withMaxAge(?int $maxAge): self - { - $clone = clone $this; - $clone->maxAge = $maxAge; - - return $clone; - } - - /** - * Set the allowed origins for CORS. - * - * Passing `null` allows all origins (`*`). - * Passing a list of strings restricts the allowed origins. - * - * @param array|null $origins List of allowed origins, or null to allow all. - * - * @return self - */ - public function withAllowedOrigins(?array $origins = null): self - { - $clone = clone $this; - $clone->allowedOrigins = $origins; - - return $clone; - } - - /** - * Set whether to allow credentials. - * @param bool $allow - */ - public function withAllowCredentials(bool $allow): self - { - $clone = clone $this; - $clone->allowCredentials = $allow; - - return $clone; - } - - /** - * Set the allowed HTTP methods for CORS. - * - * Each method will be normalized to uppercase. - * - * @param array $methods List of HTTP methods. - * - * @return self - */ - public function withAllowedMethods(array $methods): self - { - $clone = clone $this; - $clone->allowedMethods = array_map('strtoupper', $methods); - - return $clone; - } - - /** - * Set the allowed request headers for CORS. - * - * Use ['*'] to allow all headers. - * - * @param array $headers List of allowed request header names. - * - * @return self - */ - public function withAllowedHeaders(array $headers): self - { - $clone = clone $this; - $clone->allowedHeaders = $headers; - - return $clone; - } - - /** - * Set the headers that should be exposed to the browser via - * the Access-Control-Expose-Headers response header. - * - * @param array $headers List of header names to expose. - * - * @return self - */ - public function withExposedHeaders(array $headers): self - { - $clone = clone $this; - $clone->exposedHeaders = $headers; - - return $clone; - } - - /** - * Set whether to use cache control headers. - * @param bool $useCache - */ - public function withCache(bool $useCache): self - { - $clone = clone $this; - $clone->useCache = $useCache; - - return $clone; - } - - /** - * Check if origin is allowed. - * @param string $origin - */ - private function isOriginAllowed(string $origin): bool - { - if ($this->allowedOrigins === null) { - return true; - } - - return in_array($origin, $this->allowedOrigins, true); - } -} diff --git a/Slim/Middleware/JsonBodyParserMiddleware.php b/Slim/Middleware/JsonBodyParserMiddleware.php index 3bea6214f..57104bcb5 100644 --- a/Slim/Middleware/JsonBodyParserMiddleware.php +++ b/Slim/Middleware/JsonBodyParserMiddleware.php @@ -10,17 +10,21 @@ namespace Slim\Middleware; +use JsonException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\Exception\HttpBadRequestException; +use function in_array; +use function json_decode; + final class JsonBodyParserMiddleware implements MiddlewareInterface { private int $flags; - public function __construct(int $jsonFlags = JSON_THROW_ON_ERROR) + public function __construct(int $jsonFlags = 0) { $this->flags = $jsonFlags; } @@ -36,10 +40,15 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if ($this->isJsonMediaType($contentType)) { $body = (string)$request->getBody(); - $parsed = json_decode($body, true, 512, $this->flags); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new HttpBadRequestException($request, sprintf('Invalid JSON body: %s', json_last_error_msg())); + try { + $parsed = json_decode($body, true, 512, $this->flags | JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new HttpBadRequestException( + $request, + sprintf('Invalid JSON body: %s', $jsonException->getMessage()), + $jsonException + ); } if (is_array($parsed)) { diff --git a/Slim/Middleware/MethodOverrideMiddleware.php b/Slim/Middleware/MethodOverrideMiddleware.php index 56127327e..6a326fdd7 100644 --- a/Slim/Middleware/MethodOverrideMiddleware.php +++ b/Slim/Middleware/MethodOverrideMiddleware.php @@ -20,17 +20,26 @@ final class MethodOverrideMiddleware implements MiddlewareInterface { + private const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']; + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $methodHeader = $request->getHeaderLine('X-Http-Method-Override'); + if (strtoupper($request->getMethod()) !== 'POST') { + return $handler->handle($request); + } + + $methodHeader = strtoupper($request->getHeaderLine('X-Http-Method-Override')); - if ($methodHeader) { + if ($methodHeader && in_array($methodHeader, self::ALLOWED_METHODS, true)) { $request = $request->withMethod($methodHeader); - } elseif (strtoupper($request->getMethod()) === 'POST') { + } else { $body = $request->getParsedBody(); - if (is_array($body) && !empty($body['_METHOD']) && is_string($body['_METHOD'])) { - $request = $request->withMethod($body['_METHOD']); + if (is_array($body) && isset($body['_METHOD']) && is_string($body['_METHOD'])) { + $override = strtoupper($body['_METHOD']); + if (in_array($override, self::ALLOWED_METHODS, true)) { + $request = $request->withMethod($override); + } } if ($request->getBody()->eof()) { diff --git a/Slim/Middleware/RoutingMiddleware.php b/Slim/Middleware/RoutingMiddleware.php index ab996f7c8..5b496d056 100644 --- a/Slim/Middleware/RoutingMiddleware.php +++ b/Slim/Middleware/RoutingMiddleware.php @@ -13,6 +13,7 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use RuntimeException; +use Slim\Exception\HttpNotFoundException; use Slim\Interfaces\DispatcherInterface; use Slim\Interfaces\RouteInterface; use Slim\Interfaces\RouterInterface; @@ -30,10 +31,8 @@ final class RoutingMiddleware implements MiddlewareInterface private RouterInterface $router; - public function __construct( - DispatcherInterface $dispatcher, - RouterInterface $router - ) { + public function __construct(DispatcherInterface $dispatcher, RouterInterface $router) + { $this->dispatcher = $dispatcher; $this->router = $router; } @@ -41,12 +40,17 @@ public function __construct( public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $requestPath = $request->getUri()->getPath(); - $basePath = $this->router->getBasePath(); + $basePath = $this->router->getBasePath() ?? ''; + + if ($this->isOutsideBasePath($requestPath, $basePath)) { + throw new HttpNotFoundException($request); + } + $dispatchPath = $this->stripBasePath($requestPath, $basePath); $routingResult = $this->dispatcher->dispatch( $request->getMethod(), - rawurldecode($dispatchPath) + $dispatchPath ); $routeMatch = $this->createRouteMatch($routingResult); @@ -64,7 +68,7 @@ private function createRouteMatch(array $routingResult): RouteMatch return match ($status) { DispatcherInterface::FOUND => RouteMatch::found( - $this->assertRoute($routingResult[1] ?? null), + $this->extractRoute($routingResult[1] ?? null), $this->extractArguments($routingResult[2] ?? null), ), DispatcherInterface::METHOD_NOT_ALLOWED => RouteMatch::methodNotAllowed( @@ -75,7 +79,7 @@ private function createRouteMatch(array $routingResult): RouteMatch }; } - private function assertRoute(mixed $route): RouteInterface + private function extractRoute(mixed $route): RouteInterface { if (!$route instanceof RouteInterface) { throw new RuntimeException('Dispatcher returned an invalid route for FOUND status.'); @@ -123,6 +127,21 @@ private function stripBasePath(string $uri, string $basePath): string return $uri; } - return '/' . ltrim(rtrim(substr($uri, strlen($basePath)), '/'), '/'); + if ($uri === $basePath) { + return '/'; + } + + $path = substr($uri, strlen($basePath)); + + return '/' . ltrim($path, '/'); + } + + private function isOutsideBasePath(string $uri, string $basePath): bool + { + if ($basePath === '' || $basePath === '/') { + return false; + } + + return $uri !== $basePath && !str_starts_with($uri, $basePath . '/'); } } diff --git a/Slim/Middleware/XmlBodyParserMiddleware.php b/Slim/Middleware/XmlBodyParserMiddleware.php index 1933ee8a5..6dbc710f1 100644 --- a/Slim/Middleware/XmlBodyParserMiddleware.php +++ b/Slim/Middleware/XmlBodyParserMiddleware.php @@ -28,9 +28,17 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } if ($this->isXmlMediaType($contentType)) { - $backup = libxml_use_internal_errors(true); $body = (string)$request->getBody(); - $xml = simplexml_load_string($body); + + $options = LIBXML_NONET; + + // PHP 8.4+ provides explicit XXE hardening flag. + if (defined('LIBXML_NO_XXE')) { + $options |= LIBXML_NO_XXE; + } + + $backup = libxml_use_internal_errors(true); + $xml = simplexml_load_string($body, 'SimpleXMLElement', $options); libxml_clear_errors(); libxml_use_internal_errors($backup); diff --git a/Slim/Routing/FastRouteDispatcher.php b/Slim/Routing/FastRouteDispatcher.php index 57972ac97..ab9419531 100644 --- a/Slim/Routing/FastRouteDispatcher.php +++ b/Slim/Routing/FastRouteDispatcher.php @@ -27,7 +27,7 @@ public function __construct(RouterInterface $router) public function dispatch(string $httpMethod, string $uri): array { - return $this->getDispatcher()->dispatch($httpMethod, $uri); + return $this->getDispatcher()->dispatch($httpMethod, rawurldecode(($uri))); } private function getDispatcher(): GroupCountBased diff --git a/Slim/Routing/RouteCollectionTrait.php b/Slim/Routing/RouteCollectionTrait.php index 1168dd746..248cb044b 100644 --- a/Slim/Routing/RouteCollectionTrait.php +++ b/Slim/Routing/RouteCollectionTrait.php @@ -10,43 +10,45 @@ namespace Slim\Routing; +use Slim\Interfaces\RouteInterface; + trait RouteCollectionTrait { - abstract public function map(array $methods, string $path, callable|string $handler): Route; + abstract public function map(array $methods, string $path, callable|string $handler): RouteInterface; abstract public function group(string $path, callable $handler): RouteGroup; - public function get(string $path, callable|string $handler): Route + public function get(string $path, callable|string $handler): RouteInterface { return $this->map(['GET'], $path, $handler); } - public function post(string $path, callable|string $handler): Route + public function post(string $path, callable|string $handler): RouteInterface { return $this->map(['POST'], $path, $handler); } - public function put(string $path, callable|string $handler): Route + public function put(string $path, callable|string $handler): RouteInterface { return $this->map(['PUT'], $path, $handler); } - public function patch(string $path, callable|string $handler): Route + public function patch(string $path, callable|string $handler): RouteInterface { return $this->map(['PATCH'], $path, $handler); } - public function delete(string $path, callable|string $handler): Route + public function delete(string $path, callable|string $handler): RouteInterface { return $this->map(['DELETE'], $path, $handler); } - public function options(string $path, callable|string $handler): Route + public function options(string $path, callable|string $handler): RouteInterface { return $this->map(['OPTIONS'], $path, $handler); } - public function any(string $pattern, callable|string $handler): Route + public function any(string $pattern, callable|string $handler): RouteInterface { return $this->map(['*'], $pattern, $handler); } diff --git a/Slim/Routing/RouteGroup.php b/Slim/Routing/RouteGroup.php index 7ba227b6c..a3393c0f8 100644 --- a/Slim/Routing/RouteGroup.php +++ b/Slim/Routing/RouteGroup.php @@ -13,6 +13,7 @@ use FastRoute\RouteCollector; use Slim\Interfaces\MiddlewareCollectionInterface; use Slim\Interfaces\RouteCollectionInterface; +use Slim\Interfaces\RouteInterface; final class RouteGroup implements MiddlewareCollectionInterface, RouteCollectionInterface { @@ -67,7 +68,7 @@ public function getRouteGroup(): ?RouteGroup * @param string $path * @param callable|string $handler */ - public function map(array $methods, string $path, callable|string $handler): Route + public function map(array $methods, string $path, callable|string $handler): RouteInterface { $routePath = $this->prefix . $path; $route = new Route($methods, $routePath, $handler, $this); diff --git a/Slim/Routing/Router.php b/Slim/Routing/Router.php index c3a943cb0..eee5ed1ce 100644 --- a/Slim/Routing/Router.php +++ b/Slim/Routing/Router.php @@ -15,6 +15,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Slim\Interfaces\RouteInterface; use Slim\Interfaces\RouterInterface; final class Router implements RouterInterface, RequestHandlerInterface @@ -27,7 +28,7 @@ final class Router implements RouterInterface, RequestHandlerInterface private RouteCollector $collector; - private string $basePath = ''; + private ?string $basePath = null; public function __construct(PipelineRunner $pipelineRunner) { @@ -35,14 +36,7 @@ public function __construct(PipelineRunner $pipelineRunner) $this->pipelineRunner = $pipelineRunner; } - /** - * @param array $methods - * @param string $path - * @param callable|string $handler - * - * @return Route - */ - public function map(array $methods, string $path, callable|string $handler): Route + public function map(array $methods, string $path, callable|string $handler): RouteInterface { if (!$methods) { throw new InvalidArgumentException('HTTP methods array cannot be empty'); @@ -75,7 +69,7 @@ public function setBasePath(string $basePath): void $this->basePath = $basePath; } - public function getBasePath(): string + public function getBasePath(): ?string { return $this->basePath; } @@ -92,7 +86,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface * - Starts with a forward slash * - No trailing slash (unless root path) * - No double slashes - * @param string $path */ private function normalizePath(string $path): string { diff --git a/Slim/Routing/UrlGenerator.php b/Slim/Routing/UrlGenerator.php index dac4227b0..c38186d88 100644 --- a/Slim/Routing/UrlGenerator.php +++ b/Slim/Routing/UrlGenerator.php @@ -13,6 +13,8 @@ use Psr\Http\Message\UriInterface; use RecursiveArrayIterator; use RecursiveIteratorIterator; +use Slim\Interfaces\RouteInterface; +use Slim\Interfaces\RouterInterface; use Slim\Interfaces\UrlGeneratorInterface; use UnexpectedValueException; @@ -24,11 +26,11 @@ final class UrlGenerator implements UrlGeneratorInterface { - private Router $router; + private RouterInterface $router; private Std $routeParser; - public function __construct(Router $router) + public function __construct(RouterInterface $router) { $this->router = $router; $this->routeParser = new Std(); @@ -48,7 +50,7 @@ public function relativeUrlFor(string $routeName, array $data = [], array $query $url .= '?' . http_build_query($queryParams); } - $basePath = $this->router->getBasePath(); + $basePath = $this->router->getBasePath() ?? ''; if ($basePath) { $url = $basePath . $url; } @@ -77,7 +79,7 @@ public function fullUrlFor(UriInterface $uri, string $routeName, array $data = [ return $protocol . $path; } - private function getNamedRoute(string $name): Route + private function getNamedRoute(string $name): RouteInterface { $routes = $this->router->getRouteCollector()->getData(); @@ -86,7 +88,7 @@ private function getNamedRoute(string $name): Route ); foreach ($iterator as $route) { - if ($route instanceof Route && $name === $route->getName()) { + if ($route instanceof RouteInterface && $name === $route->getName()) { return $route; } } diff --git a/tests/Exception/HttpExceptionTest.php b/tests/Exception/HttpExceptionTest.php index 4af13dc02..2c5024c1d 100644 --- a/tests/Exception/HttpExceptionTest.php +++ b/tests/Exception/HttpExceptionTest.php @@ -22,7 +22,7 @@ final class HttpExceptionTest extends TestCase { use AppTestTrait; - public function testHttpExceptionRequestResponseGetterSetters() + public function testHttpExceptionRequestResponseGetterSetters(): void { $app = AppFactory::create(); @@ -35,7 +35,7 @@ public function testHttpExceptionRequestResponseGetterSetters() $this->assertInstanceOf(ServerRequestInterface::class, $exception->getRequest()); } - public function testHttpExceptionAttributeGettersSetters() + public function testHttpExceptionAttributeGettersSetters(): void { $app = AppFactory::create(); @@ -51,7 +51,7 @@ public function testHttpExceptionAttributeGettersSetters() $this->assertSame('Description', $exception->getDescription()); } - public function testHttpNotAllowedExceptionGetAllowedMethods() + public function testHttpNotAllowedExceptionGetAllowedMethods(): void { $app = AppFactory::create(); @@ -62,7 +62,7 @@ public function testHttpNotAllowedExceptionGetAllowedMethods() $exception = new HttpMethodNotAllowedException($request); $exception->setAllowedMethods(['GET']); $this->assertSame(['GET'], $exception->getAllowedMethods()); - $this->assertSame('Method not allowed. Must be one of: GET', $exception->getMessage()); + $this->assertSame('Method not allowed.', $exception->getMessage()); $exception = new HttpMethodNotAllowedException($request); $this->assertSame([], $exception->getAllowedMethods()); diff --git a/tests/Exception/HttpUnauthorizedExceptionTest.php b/tests/Exception/HttpUnauthorizedExceptionTest.php index 2e37451cd..580ae52ba 100644 --- a/tests/Exception/HttpUnauthorizedExceptionTest.php +++ b/tests/Exception/HttpUnauthorizedExceptionTest.php @@ -20,7 +20,7 @@ final class HttpUnauthorizedExceptionTest extends TestCase { use AppTestTrait; - public function testHttpUnauthorizedException() + public function testHttpUnauthorizedException(): void { $app = AppFactory::create(); @@ -33,7 +33,7 @@ public function testHttpUnauthorizedException() $this->assertInstanceOf(HttpUnauthorizedException::class, $exception); } - public function testHttpUnauthorizedExceptionWithMessage() + public function testHttpUnauthorizedExceptionWithMessage(): void { $app = AppFactory::create(); diff --git a/tests/Middleware/BasePathMiddlewareTest.php b/tests/Middleware/BasePathMiddlewareTest.php index 07e423a4a..b998052a4 100644 --- a/tests/Middleware/BasePathMiddlewareTest.php +++ b/tests/Middleware/BasePathMiddlewareTest.php @@ -11,11 +11,10 @@ namespace Slim\Tests\Middleware; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Slim\Exception\HttpNotFoundException; use Slim\Factory\AppFactory; -use Slim\Interfaces\RouterInterface; use Slim\Middleware\BasePathMiddleware; use Slim\Tests\Traits\AppTestTrait; @@ -25,29 +24,18 @@ final class BasePathMiddlewareTest extends TestCase public function testEmptyScriptName(): void { - $definitions = - [ - BasePathMiddleware::class => function (ContainerInterface $container) { - $router = $container->get(RouterInterface::class); - - return new BasePathMiddleware($router, 'apache2handler'); - }, - ]; - $app = AppFactory::create($definitions); + $app = AppFactory::create(); $app->add(BasePathMiddleware::class); $app->addRoutingMiddleware(); - $app->get('/', function ($request, ResponseInterface $response) { - /** @var ContainerInterface $this */ - $basePath = $this->get(RouterInterface::class)->getBasePath(); - $response->getBody()->write('basePath: ' . $basePath); + $app->get('/', function ($request, ResponseInterface $response) use ($app) { + $response->getBody()->write('basePath: ' . $app->getBasePath()); return $response; }); $serverParams = [ - 'REQUEST_URI' => '/', 'SCRIPT_NAME' => '', ]; @@ -63,28 +51,18 @@ public function testEmptyScriptName(): void public function testScriptNameWithIndexPhp(): void { - $definitions = - [ - BasePathMiddleware::class => function (ContainerInterface $container) { - $router = $container->get(RouterInterface::class); - - return new BasePathMiddleware($router, 'apache2handler'); - }, - ]; - $app = AppFactory::create($definitions); + $app = AppFactory::create(); $app->add(BasePathMiddleware::class); $app->addRoutingMiddleware(); - $app->get('/', function ($request, ResponseInterface $response) { - $basePath = $this->get(RouterInterface::class)->getBasePath(); - $response->getBody()->write('basePath: ' . $basePath); + $app->get('/', function ($request, ResponseInterface $response) use ($app) { + $response->getBody()->write('basePath: ' . $app->getBasePath()); return $response; }); $serverParams = [ - 'REQUEST_URI' => '/', // PHP internal server 'SCRIPT_NAME' => '/index.php', ]; @@ -101,28 +79,18 @@ public function testScriptNameWithIndexPhp(): void public function testScriptNameWithPublicIndexPhp(): void { - $definitions = - [ - BasePathMiddleware::class => function (ContainerInterface $container) { - $router = $container->get(RouterInterface::class); - - return new BasePathMiddleware($router, 'apache2handler'); - }, - ]; - $app = AppFactory::create($definitions); + $app = AppFactory::create(); $app->add(BasePathMiddleware::class); $app->addRoutingMiddleware(); - $app->get('/', function (ServerRequestInterface $request, ResponseInterface $response) { - $basePath = $this->get(RouterInterface::class)->getBasePath(); - $response->getBody()->write('basePath: ' . $basePath); + $app->get('/', function (ServerRequestInterface $request, ResponseInterface $response) use ($app) { + $response->getBody()->write('basePath: ' . $app->getBasePath()); return $response; }); $serverParams = [ - 'REQUEST_URI' => '/', // PHP internal server 'SCRIPT_NAME' => '/public/index.php', ]; @@ -139,28 +107,18 @@ public function testScriptNameWithPublicIndexPhp(): void public function testSubDirectoryWithSlash(): void { - $definitions = - [ - BasePathMiddleware::class => function (ContainerInterface $container) { - $router = $container->get(RouterInterface::class); - - return new BasePathMiddleware($router, 'apache2handler'); - }, - ]; - $app = AppFactory::create($definitions); + $app = AppFactory::create(); $app->add(BasePathMiddleware::class); $app->addRoutingMiddleware(); - $app->get('/', function ($request, ResponseInterface $response) { - $basePath = $this->get(RouterInterface::class)->getBasePath(); - $response->getBody()->write('basePath: ' . $basePath); + $app->get('/', function ($request, ResponseInterface $response) use ($app) { + $response->getBody()->write('basePath: ' . $app->getBasePath()); return $response; }); $serverParams = [ - 'REQUEST_URI' => '/slim-hello-world/', 'SCRIPT_NAME' => '/slim-hello-world/public/index.php', ]; $request = $this @@ -177,28 +135,18 @@ public function testSubDirectoryWithSlash(): void public function testSubDirectoryWithoutSlash(): void { - $definitions = - [ - BasePathMiddleware::class => function (ContainerInterface $container) { - $router = $container->get(RouterInterface::class); - - return new BasePathMiddleware($router, 'apache2handler'); - }, - ]; - $app = AppFactory::create($definitions); + $app = AppFactory::create(); $app->add(BasePathMiddleware::class); $app->addRoutingMiddleware(); - $app->get('/foo', function ($request, ResponseInterface $response) { - $basePath = $this->get(RouterInterface::class)->getBasePath(); - $response->getBody()->write('basePath: ' . $basePath); + $app->get('/foo', function ($request, ResponseInterface $response) use ($app) { + $response->getBody()->write('basePath: ' . $app->getBasePath()); return $response; }); $serverParams = [ - 'REQUEST_URI' => '/slim-hello-world/foo', 'SCRIPT_NAME' => '/slim-hello-world/public/index.php', ]; @@ -214,17 +162,11 @@ public function testSubDirectoryWithoutSlash(): void $this->assertSame('basePath: /slim-hello-world', (string)$response->getBody()); } - public function testSubDirectoryWithFooPath(): void + public function testStrictSubDirectoryWithFooPath(): void { - $definitions - = [ - BasePathMiddleware::class => function (ContainerInterface $container) { - $router = $container->get(RouterInterface::class); + $this->expectException(HttpNotFoundException::class); - return new BasePathMiddleware($router, 'apache2handler'); - }, - ]; - $app = AppFactory::create($definitions); + $app = AppFactory::create(); $app->add(BasePathMiddleware::class); $app->addRoutingMiddleware(); @@ -236,18 +178,12 @@ public function testSubDirectoryWithFooPath(): void }); $serverParams = [ - 'REQUEST_URI' => '/slim-hello-world/foo', 'SCRIPT_NAME' => '/slim-hello-world/public/index.php', ]; $request = $this ->getServerRequestFactory($app) ->createServerRequest('GET', '/slim-hello-world/foo/?key=value', $serverParams); - $response = $app->handle($request); - - $this->assertSame('/slim-hello-world', $app->getBasePath()); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('basePath: /slim-hello-world', (string)$response->getBody()); + $app->handle($request); } } diff --git a/tests/Middleware/CorsMiddlewareTest.php b/tests/Middleware/CorsMiddlewareTest.php deleted file mode 100644 index dd034b3e2..000000000 --- a/tests/Middleware/CorsMiddlewareTest.php +++ /dev/null @@ -1,226 +0,0 @@ -add(CorsMiddleware::class); - $app->addRoutingMiddleware(); - - // Add test route - $app->get('/test', function ($request, $response) { - $response->getBody()->write('Test response'); - - return $response; - }); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/test'); - - $response = $app->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Headers')); - $this->assertSame( - 'GET, POST, PUT, PATCH, DELETE, OPTIONS', - $response->getHeaderLine('Access-Control-Allow-Methods'), - ); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); - } - - public function testDefaultConfigurationWithOrigin(): void - { - $app = AppFactory::create(); - - // Add CORS middleware with default config - $app->add(CorsMiddleware::class); - $app->addRoutingMiddleware(); - - // Add test route - $app->get('/test', function ($request, $response) { - $response->getBody()->write('Test response'); - - return $response; - }); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/test') - ->withHeader('Origin', 'https://example.com'); - - $response = $app->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('https://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Headers')); - $this->assertSame( - 'GET, POST, PUT, PATCH, DELETE, OPTIONS', - $response->getHeaderLine('Access-Control-Allow-Methods'), - ); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); - } - - public function testPreflightRequest(): void - { - $app = AppFactory::create(); - - // Configure CORS middleware - $cors = $app->getContainer() - ->get(CorsMiddleware::class) - ->withAllowedOrigins(['https://example.com']) - ->withAllowCredentials(true) - ->withMaxAge(3600); - - $app->add($cors); - $app->addRoutingMiddleware(); - - // Add test routes - $app->get('/test', function ($request, $response) { - $response->getBody()->write('Test response'); - - return $response; - }); - - $app->options('/test', function ($request, $response) { - return $response; - }); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('OPTIONS', '/test') - ->withHeader('Origin', 'https://example.com') - ->withHeader('Access-Control-Request-Method', 'POST'); - - $response = $app->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('https://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); - $this->assertSame('3600', $response->getHeaderLine('Access-Control-Max-Age')); - } - - public function testDisallowedOrigin(): void - { - $app = AppFactory::create(); - - // Configure CORS middleware - $cors = $app->getContainer() - ->get(CorsMiddleware::class) - ->withAllowedOrigins(['https://example.com']) - ->withAllowCredentials(true); - - $app->add($cors); - $app->addRoutingMiddleware(); - - // Add test route - $app->get('/test', function ($request, $response) { - $response->getBody()->write('Test response'); - - return $response; - }); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/test') - ->withHeader('Origin', 'https://bad-domain.tld'); - - $response = $app->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); - $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); - } - - public function testCustomHeadersAndMethods(): void - { - $app = AppFactory::create(); - - // Configure CORS middleware - $cors = $app->getContainer() - ->get(CorsMiddleware::class) - ->withAllowedOrigins(['https://example.com']) - ->withAllowedHeaders(['Content-Type', 'X-Custom-Header']) - ->withExposedHeaders(['X-Custom-Response']) - ->withAllowedMethods(['GET', 'POST']) - ->withMaxAge(3600) - ->withCache(false); - - $app->add($cors); - $app->addRoutingMiddleware(); - - // Add test routes - $app->get('/test', function ($request, $response) { - $response->getBody()->write('Test response'); - - return $response; - }); - - $app->options('/test', function ($request, $response) { - return $response; - }); - - // Test preflight request - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('OPTIONS', '/test') - ->withHeader('Origin', 'https://example.com') - ->withHeader('Access-Control-Request-Method', 'POST') - ->withHeader('Access-Control-Request-Headers', 'X-Custom-Header'); - - $response = $app->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('https://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('Content-Type, X-Custom-Header', $response->getHeaderLine('Access-Control-Allow-Headers')); - $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods')); - $this->assertSame('X-Custom-Response', $response->getHeaderLine('Access-Control-Expose-Headers')); - $this->assertSame('3600', $response->getHeaderLine('Access-Control-Max-Age')); - $this->assertFalse($response->hasHeader('Cache-Control')); - } - - public function testWildcardOriginWithCredentials(): void - { - $app = AppFactory::create(); - - // Configure CORS middleware - $cors = $app->getContainer() - ->get(CorsMiddleware::class) - ->withAllowedOrigins(null) // Wildcard - ->withAllowCredentials(true); - - $app->add($cors); - $app->addRoutingMiddleware(); - - // Add test route - $app->get('/test', function ($request, $response) { - $response->getBody()->write('Test response'); - - return $response; - }); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/test') - ->withHeader('Origin', 'https://example.com'); - - $response = $app->handle($request); - - // Should use specific origin instead of wildcard when credentials are allowed - $this->assertSame('https://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); - } -} diff --git a/tests/Middleware/JsonBodyParserMiddlewareTest.php b/tests/Middleware/JsonBodyParserMiddlewareTest.php index 30ebb008f..f8c23f026 100644 --- a/tests/Middleware/JsonBodyParserMiddlewareTest.php +++ b/tests/Middleware/JsonBodyParserMiddlewareTest.php @@ -10,12 +10,12 @@ namespace Slim\Tests\Middleware; -use JsonException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Slim\Exception\HttpBadRequestException; use Slim\Middleware\JsonBodyParserMiddleware; use Slim\Psr7\Factory\ServerRequestFactory; use Slim\Psr7\Factory\StreamFactory; @@ -77,14 +77,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface #[DataProvider('invalidJsonProvider')] public function testThrowsExceptionOnInvalidJson($contentType, $body): void { - $this->expectException(JsonException::class); + $this->expectException(HttpBadRequestException::class); $this->expectExceptionMessage('Syntax error'); - $stream = (new StreamFactory())->createStream('{"foo": "bar"'); + $stream = (new StreamFactory())->createStream($body); $request = (new ServerRequestFactory()) ->createServerRequest('POST', '/') - ->withHeader('Content-Type', 'application/json') + ->withHeader('Content-Type', $contentType) ->withBody($stream); $middleware = new JsonBodyParserMiddleware(); @@ -122,6 +122,73 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->assertSame('no-parse', (string)$response->getBody()); } + public function testThrowsOnNullFlagsWithInvalidJson(): void + { + // no JSON_THROW_ON_ERROR, so json_decode returns null on error instead of throwing an exception + $middleware = new JsonBodyParserMiddleware(0); + $stream = (new StreamFactory())->createStream('{bad}'); + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', 'application/json') + ->withBody($stream); + + $this->expectException(HttpBadRequestException::class); + $middleware->process($request, new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(); + } + }); + } + + #[DataProvider('validJsonProvider')] + public function testJsonObjectAsArray($contentType, $body, $expected): void + { + $middleware = new JsonBodyParserMiddleware(JSON_OBJECT_AS_ARRAY); + $stream = (new StreamFactory())->createStream($body); + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', $contentType) + ->withBody($stream); + + $response = $middleware->process($request, new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + $data = $request->getParsedBody(); + $response = new Response(); + $response->getBody()->write(json_encode($data)); + + return $response; + } + }); + + $this->assertSame($expected, (string)$response->getBody()); + } + + #[DataProvider('validJsonProvider')] + public function testJsonForceObject($contentType, $body, $expected): void + { + $middleware = new JsonBodyParserMiddleware(JSON_FORCE_OBJECT); + $stream = (new StreamFactory())->createStream($body); + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', $contentType) + ->withBody($stream); + + $response = $middleware->process($request, new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + $data = $request->getParsedBody(); + $response = new Response(); + $response->getBody()->write(json_encode($data)); + + return $response; + } + }); + + $this->assertSame($expected, (string)$response->getBody()); + } + public static function validJsonProvider(): array { return [ diff --git a/tests/Middleware/MethodOverrideMiddlewareTest.php b/tests/Middleware/MethodOverrideMiddlewareTest.php index d9a0bb6f2..4b1528723 100644 --- a/tests/Middleware/MethodOverrideMiddlewareTest.php +++ b/tests/Middleware/MethodOverrideMiddlewareTest.php @@ -16,6 +16,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; use Psr\Http\Server\RequestHandlerInterface; +use Slim\Exception\HttpMethodNotAllowedException; use Slim\Factory\AppFactory; use Slim\Middleware\MethodOverrideMiddleware; use Slim\Tests\Traits\AppTestTrait; @@ -113,14 +114,82 @@ public function testHeaderPreferred() $request = $app->getContainer() ->get(ServerRequestFactoryInterface::class) ->createServerRequest('POST', '/') - ->withHeader('X-Http-Method-Override', 'DELETE') - ->withParsedBody((object)['_METHOD' => 'PUT']); + ->withHeader('X-Http-Method-Override', 'DELETE'); $response = $app->handle($request); $this->assertSame('Hello World', (string)$response->getBody()); } + public function testHeaderOverrideWithArbitraryValueIsIgnored(): void + { + $this->expectException(HttpMethodNotAllowedException::class); + $this->expectExceptionMessage('Method not allowed.'); + + $app = AppFactory::create(); + $app->add(MethodOverrideMiddleware::class); + $app->addRoutingMiddleware(); + + $app->delete('/', function (ServerRequestInterface $request, ResponseInterface $response) { + $response->getBody()->write($request->getMethod()); + + return $response; + }); + + $request = $app->getContainer() + ->get(ServerRequestFactoryInterface::class) + ->createServerRequest('POST', '/') + ->withHeader('X-Http-Method-Override', 'FAKEMETHOD'); + + $app->handle($request); + } + + public function testHeaderOverrideOnNonPostRequestIsIgnored(): void + { + $this->expectException(HttpMethodNotAllowedException::class); + $this->expectExceptionMessage('Method not allowed.'); + + $app = AppFactory::create(); + $app->add(MethodOverrideMiddleware::class); + $app->addRoutingMiddleware(); + + $app->delete('/', function (ServerRequestInterface $request, ResponseInterface $response) { + $response->getBody()->write($request->getMethod()); + + return $response; + }); + + $request = $app->getContainer() + ->get(ServerRequestFactoryInterface::class) + ->createServerRequest('GET', '/') + ->withHeader('X-Http-Method-Override', 'DELETE'); + + $app->handle($request); + } + + public function testHeaderOverrideWithArbitraryValueInPayload(): void + { + $this->expectException(HttpMethodNotAllowedException::class); + $this->expectExceptionMessage('Method not allowed.'); + + $app = AppFactory::create(); + $app->add(MethodOverrideMiddleware::class); + $app->addRoutingMiddleware(); + + $app->delete('/', function (ServerRequestInterface $request, ResponseInterface $response) { + $response->getBody()->write($request->getMethod()); + + return $response; + }); + + $request = $app->getContainer() + ->get(ServerRequestFactoryInterface::class) + ->createServerRequest('POST', '/') + ->withParsedBody(['_METHOD' => 'FAKEMETHOD']); + + $app->handle($request); + } + public function testNoOverride() { $app = AppFactory::create(); diff --git a/tests/Middleware/RoutingMiddlewareTest.php b/tests/Middleware/RoutingMiddlewareTest.php index 7da0ebe2e..d4b3742fe 100644 --- a/tests/Middleware/RoutingMiddlewareTest.php +++ b/tests/Middleware/RoutingMiddlewareTest.php @@ -13,15 +13,13 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\UriInterface; use Psr\Http\Server\RequestHandlerInterface; -use RuntimeException; use Slim\Exception\HttpMethodNotAllowedException; use Slim\Exception\HttpNotFoundException; use Slim\Factory\AppFactory; use Slim\Interfaces\DispatcherInterface; -use Slim\Interfaces\RouterInterface; use Slim\Interfaces\UrlGeneratorInterface; +use Slim\Middleware\BasePathMiddleware; use Slim\Middleware\EndpointMiddleware; use Slim\Middleware\JsonBodyParserMiddleware; use Slim\Middleware\RoutingMiddleware; @@ -129,7 +127,7 @@ public function testRouteIsNotStoredOnMethodNotAllowed() $app->handle($request); } - public function testRouteIsNotStoredOnNotFound() + public function testRouteIsNotStoredOnNotFound(): void { $this->expectException(HttpNotFoundException::class); @@ -172,6 +170,7 @@ public function testRoutingWithBasePath(): void $app = AppFactory::create(); $app->setBasePath('/api'); + $app->add(BasePathMiddleware::class); $app->addRoutingMiddleware(); // Define a route with arguments @@ -198,40 +197,63 @@ public function testRoutingWithBasePath(): void $this->assertSame('/api/users/123?page=2', $response->getHeaderLine('X-fullUrlFor')); } - public function testMethodNotAllowedThrowsRuntimeExceptionWhenAllowedMethodsPayloadIsInvalid(): void + public function testRoutingWithUriDoesNotStartWithBasePath(): void { - $dispatcher = $this->createMock(DispatcherInterface::class); - $dispatcher - ->method('dispatch') - ->willReturn([ - DispatcherInterface::METHOD_NOT_ALLOWED, - 'GET', - ]); - - $router = $this->createMock(RouterInterface::class); - $router - ->method('getBasePath') - ->willReturn(''); - - $middleware = new RoutingMiddleware($dispatcher, $router); - - $request = $this->createMock(ServerRequestInterface::class); - $uri = $this->createMock(UriInterface::class); - $uri - ->method('getPath') - ->willReturn('/hello/foo'); - $request - ->method('getUri') - ->willReturn($uri); - $request - ->method('getMethod') - ->willReturn('GET'); - - $handler = $this->createMock(RequestHandlerInterface::class); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Dispatcher returned invalid allowed methods.'); - - $middleware->process($request, $handler); + $this->expectException(HttpNotFoundException::class); + + $app = AppFactory::create(); + $app->setBasePath('/api'); + + $app->add(BasePathMiddleware::class); + $app->addRoutingMiddleware(); + + // Define a route with arguments + $app->get('/users', function (ServerRequestInterface $request, ResponseInterface $response) { + return $response; + }); + + $request = $this + ->getServerRequestFactory($app) + ->createServerRequest('GET', '/users'); + + $app->handle($request); + } + + public function testHttpMethodNotAllowedException(): void + { + $this->expectException(HttpMethodNotAllowedException::class); + + $app = AppFactory::create(); + $app->addRoutingMiddleware(); + + // Define a route with arguments + $app->post('/hello/foo', function (ServerRequestInterface $request, ResponseInterface $response, $args) { + return $response; + }); + + $request = $this + ->getServerRequestFactory($app) + ->createServerRequest('GET', '/hello/foo'); + + $app->handle($request); + } + + public function testPercentEncodedPath(): void + { + $app = AppFactory::create(); + $app->addRoutingMiddleware(); + + // Define a route with arguments + $app->get('/article/{articles}', function ($request, $response) { + return $response; + }); + + $request = $this + ->getServerRequestFactory($app) + ->createServerRequest('GET', '/article/1%2C2'); + + $response = $app->handle($request); + + $this->assertSame(200, $response->getStatusCode()); } } diff --git a/tests/Routing/RouteGroupTest.php b/tests/Routing/RouteGroupTest.php index fafb89874..fddcd2e07 100644 --- a/tests/Routing/RouteGroupTest.php +++ b/tests/Routing/RouteGroupTest.php @@ -12,9 +12,9 @@ use PHPUnit\Framework\TestCase; use Slim\Factory\AppFactory; +use Slim\Interfaces\RouterInterface; use Slim\Routing\Route; use Slim\Routing\RouteGroup; -use Slim\Routing\Router; class RouteGroupTest extends TestCase { @@ -105,10 +105,10 @@ public function testGroupCreatesAndRegistersNestedRouteGroup(): void $this->assertSame($routeGroup, $nestedGroup->getRouteGroup()); } - private function createRouter(): Router + private function createRouter(): RouterInterface { $app = AppFactory::create(); - return $app->getContainer()->get(Router::class); + return $app->getContainer()->get(RouterInterface::class); } } diff --git a/tests/Routing/RouteTest.php b/tests/Routing/RouteTest.php index 3449b33df..c8546edef 100644 --- a/tests/Routing/RouteTest.php +++ b/tests/Routing/RouteTest.php @@ -16,9 +16,9 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\Factory\AppFactory; +use Slim\Interfaces\RouterInterface; use Slim\Routing\Route; use Slim\Routing\RouteGroup; -use Slim\Routing\Router; class RouteTest extends TestCase { @@ -166,10 +166,10 @@ public function process( }; } - private function createRouter(): Router + private function createRouter(): RouterInterface { $app = AppFactory::create(); - return $app->getContainer()->get(Router::class); + return $app->getContainer()->get(RouterInterface::class); } }