diff --git a/CHANGELOG.md b/CHANGELOG.md index 408858274..667fb8c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,19 +18,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RoutingMiddleware` handles the routing process. - `EndpointMiddleware` processes the routing results and invokes the controller/action handler. - Simplified Error handling concept. Relates to #3287. - - Separation of Exceptions handling, PHP Error handling and Exception logging into different middleware. + - Separation of Exceptions handling, PHP Error handling and Exception logging into different middleware classes. - `ExceptionLoggingMiddleware` for custom error logging. - - `ExceptionHandlingMiddleware` delegates exceptions to a custom error handler. - - `ErrorHandlingMiddleware` converts errors into `ErrorException` instances that can then be handled by the `ExceptionHandlingMiddleware` and `ExceptionLoggingMiddleware`. - - New custom error handlers using the new `ExceptionLoggingMiddleware` middleware. - - New `JsonExceptionRenderer` generates JSON error response. - - New `XmlExceptionRenderer` generates XML error response. -- New `BasePathMiddleware` for dealing with Apache subdirectories. -- New `HeadMethodMiddleware` ensures that the response body is empty for HEAD requests. -- New `JsonRenderer` utility class for rendering JSON responses. -- New `RequestResponseTypedArgs` invocation strategy for route parameters with type declarations. -- New `UrlGeneratorMiddleware` injects the `UrlGenerator` into the request attributes. -- New `CorsMiddleware` for handling CORS requests. + - `JsonExceptionMiddleware` generates JSON error response. + - `HtmlExceptionMiddleware` generates HTML error response. + - `XmlExceptionMiddleware` generates XML error response. + - `PlainTextExceptionMiddleware` handles and formats exceptions into plain text responses. + - `ErrorExceptionMiddleware` converts PHP errors into `ErrorException` instances that can then be handled by the `ExceptionHandlingMiddleware` and `ExceptionLoggingMiddleware`. +- `BasePathMiddleware` for dealing with Apache subdirectories. +- `JsonBodyParserMiddleware` for parsing JSON requests. +- `XmlBodyParserMiddleware` for parsing XML requests. +- `FormUrlEncodedBodyParserMiddleware` for parsing form requests. +- `CorsMiddleware` for handling CORS requests. +- `UrlGeneratorMiddleware` injects the `UrlGenerator` into the request attributes. +- `HeadMethodMiddleware` ensures that the response body is empty for HEAD requests. +- `RequestResponseTypedArgs` invocation strategy for route parameters with type declarations. - Support to build a custom middleware pipeline without the Slim App class. See new `ResponseFactoryMiddleware` - New media type detector - New ContainerFactoryInterface and PhpDiContainerFactory class @@ -50,8 +52,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -* Remove LIFO middleware order support. Use FIFO instead. +* LIFO middleware order support. Use FIFO instead. * Router cache file support (File IO was never sufficient. PHP OpCache is much faster) +* Removed `BodyParsingMiddlewareTest` in favor of `JsonBodyParserMiddleware`, `XmlBodyParserMiddleware` and `FormUrlEncodedBodyParserMiddleware`. * The `$app->redirect()` method because it was not aware of the basePath. Use the `UrlGenerator` instead. * The route `setArguments` and `setArgument` methods. Use a middleware for custom route arguments now. * The `RouteContext::ROUTE` const. Use `$route = $request->getAttribute(RouteContext::ROUTING_RESULTS)->getRoute();` instead. diff --git a/Slim/App.php b/Slim/App.php index 6d6cc20c7..7d9f0ba3e 100644 --- a/Slim/App.php +++ b/Slim/App.php @@ -35,7 +35,7 @@ * * @api */ -class App implements RouteCollectionInterface +class App implements RequestHandlerInterface, RouteCollectionInterface { use RouteCollectionTrait; diff --git a/Slim/Error/Renderers/ExceptionRendererTrait.php b/Slim/Error/Renderers/ExceptionRendererTrait.php deleted file mode 100644 index 3eccba694..000000000 --- a/Slim/Error/Renderers/ExceptionRendererTrait.php +++ /dev/null @@ -1,39 +0,0 @@ -getTitle(); - } - - return $this->defaultErrorTitle; - } - - private function getErrorDescription(Throwable $exception): string - { - if ($exception instanceof HttpException) { - return $exception->getDescription(); - } - - return $this->defaultErrorDescription; - } -} diff --git a/Slim/Error/Renderers/JsonExceptionRenderer.php b/Slim/Error/Renderers/JsonExceptionRenderer.php deleted file mode 100644 index c06346f78..000000000 --- a/Slim/Error/Renderers/JsonExceptionRenderer.php +++ /dev/null @@ -1,66 +0,0 @@ -jsonRenderer = $jsonRenderer; - } - - public function __invoke( - ServerRequestInterface $request, - ResponseInterface $response, - ?Throwable $exception = null, - bool $displayErrorDetails = false - ): ResponseInterface { - $error = ['message' => $this->getErrorTitle($exception)]; - - if ($displayErrorDetails) { - $error['exception'] = []; - do { - $error['exception'][] = $this->formatExceptionFragment($exception); - } while ($exception = $exception->getPrevious()); - } - - return $this->jsonRenderer->json($response, $error); - } - - private function formatExceptionFragment(Throwable $exception): array - { - $code = $exception instanceof ErrorException ? $exception->getSeverity() : $exception->getCode(); - - return [ - 'type' => get_class($exception), - 'code' => $code, - 'message' => $exception->getMessage(), - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - ]; - } -} diff --git a/Slim/Interfaces/ConfigurationInterface.php b/Slim/Interfaces/ConfigurationInterface.php deleted file mode 100644 index edf040590..000000000 --- a/Slim/Interfaces/ConfigurationInterface.php +++ /dev/null @@ -1,16 +0,0 @@ -parseAcceptHeader($request->getHeaderLine('Accept')); - - if (!$mediaTypes) { - $mediaTypes = $this->parseContentType($request->getHeaderLine('Content-Type')); - } - - return $mediaTypes; - } - - /** - * Parses the 'Accept' header to extract media types. - * - * This method splits the 'Accept' header value into its components and normalizes - * the media types by trimming whitespace and converting them to lowercase. - * - * This method doesn't consider the quality values (q-values) that can be present in the Accept header. - * If prioritization is important for your use case, you might want to consider implementing - * q-value parsing and sorting. - * - * @param string|null $accept the value of the 'Accept' header - * - * @return array an array of normalized media types from the 'Accept' header - */ - private function parseAcceptHeader(?string $accept): array - { - $acceptTypes = $accept ? explode(',', $accept) : []; - - // Normalize types - $cleanTypes = []; - foreach ($acceptTypes as $type) { - $tokens = explode(';', $type); - $name = trim(strtolower(reset($tokens))); - $cleanTypes[] = $name; - } - - return $cleanTypes; - } - - /** - * Parses the 'Content-Type' header to extract the media type. - * - * This method splits the 'Content-Type' header value to separate the media type - * from any additional parameters, normalizes it, and returns it in an array. - * - * @param string|null $contentType the value of the 'Content-Type' header - * - * @return array an array containing the normalized media type from the 'Content-Type' header - */ - private function parseContentType(?string $contentType): array - { - if ($contentType === null) { - return []; - } - - $parts = explode(';', $contentType); - $name = strtolower(trim($parts[0])); - - return $name ? [$name] : []; - } -} diff --git a/Slim/Middleware/BodyParsingMiddleware.php b/Slim/Middleware/BodyParsingMiddleware.php deleted file mode 100644 index 381825135..000000000 --- a/Slim/Middleware/BodyParsingMiddleware.php +++ /dev/null @@ -1,140 +0,0 @@ -mediaTypeDetector = $mediaTypeDetector; - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $parsedBody = $request->getParsedBody(); - - if (empty($parsedBody)) { - $parsedBody = $this->parseBody($request); - $request = $request->withParsedBody($parsedBody); - } - - return $handler->handle($request); - } - - /** - * @param string $mediaType The HTTP media type (excluding content-type params) - * @param callable $handler The callable that returns parsed contents for media type - */ - public function withBodyParser(string $mediaType, callable $handler): self - { - $clone = clone $this; - $clone->handlers[$mediaType] = $handler; - - return $clone; - } - - public function withDefaultMediaType(string $mediaType): self - { - $clone = clone $this; - $clone->defaultMediaType = $mediaType; - - return $clone; - } - - public function withDefaultBodyParsers(): self - { - $clone = clone $this; - $clone = $clone->withBodyParser(MediaType::APPLICATION_JSON, function ($input) { - $result = json_decode($input, true); - - if (!is_array($result)) { - return null; - } - - return $result; - }); - - $clone = $clone->withBodyParser(MediaType::APPLICATION_FORM_URLENCODED, function ($input) { - parse_str($input, $data); - - return $data; - }); - - $xmlCallable = function ($input) { - $backup_errors = libxml_use_internal_errors(true); - $result = simplexml_load_string($input); - - libxml_clear_errors(); - libxml_use_internal_errors($backup_errors); - - if ($result === false) { - return null; - } - - return $result; - }; - - return $clone - ->withBodyParser(MediaType::APPLICATION_XML, $xmlCallable) - ->withBodyParser(MediaType::TEXT_XML, $xmlCallable); - } - - /** - * Parse request body. - * - * @throws RuntimeException - */ - private function parseBody(ServerRequestInterface $request): array|object|null - { - // Negotiate content type - $contentTypes = $this->mediaTypeDetector->detect($request); - $contentType = $contentTypes[0] ?? $this->defaultMediaType; - - // Determine which handler to use based on media type - $handler = $this->handlers[$contentType] ?? reset($this->handlers); - - // Invoke the parser - $parsed = call_user_func( - $handler, - (string)$request->getBody() - ); - - if ($parsed === null || is_object($parsed) || is_array($parsed)) { - return $parsed; - } - - throw new RuntimeException( - 'Request body media type parser return value must be an array, an object, or null.' - ); - } -} diff --git a/Slim/Middleware/ErrorHandlingMiddleware.php b/Slim/Middleware/ErrorExceptionMiddleware.php similarity index 93% rename from Slim/Middleware/ErrorHandlingMiddleware.php rename to Slim/Middleware/ErrorExceptionMiddleware.php index cdeb6c650..9fa81e6af 100644 --- a/Slim/Middleware/ErrorHandlingMiddleware.php +++ b/Slim/Middleware/ErrorExceptionMiddleware.php @@ -13,7 +13,7 @@ /** * Converts errors into ErrorException instances. */ -final class ErrorHandlingMiddleware implements MiddlewareInterface +final class ErrorExceptionMiddleware implements MiddlewareInterface { /** * @throws ErrorException diff --git a/Slim/Middleware/ExceptionHandlingMiddleware.php b/Slim/Middleware/ExceptionHandlingMiddleware.php deleted file mode 100644 index e55f32b8f..000000000 --- a/Slim/Middleware/ExceptionHandlingMiddleware.php +++ /dev/null @@ -1,185 +0,0 @@ -resolver = $resolver; - $this->responseFactory = $responseFactory; - $this->mediaTypeDetector = $mediaTypeDetector; - } - - public static function createFromContainer(ContainerInterface $container): self - { - return new self( - $container->get(ContainerResolverInterface::class), - $container->get(ResponseFactoryInterface::class), - $container->get(MediaTypeDetector::class) - ); - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - try { - return $handler->handle($request); - } catch (Throwable $exception) { - $statusCode = $this->determineStatusCode($request, $exception); - $mediaType = $this->negotiateMediaType($request); - $response = $this->createResponse($statusCode, $mediaType, $exception); - $handler = $this->negotiateHandler($mediaType); - - // Invoke the formatter handler - return call_user_func( - $handler, - $request, - $response, - $exception, - $this->displayErrorDetails - ); - } - } - - public function withDefaultMediaType(string $defaultMediaType): self - { - $clone = clone $this; - $clone->defaultMediaType = $defaultMediaType; - - return $clone; - } - - public function withDisplayErrorDetails(bool $displayErrorDetails): self - { - $clone = clone $this; - $clone->displayErrorDetails = $displayErrorDetails; - - return $clone; - } - - public function withDefaultHandler(ExceptionRendererInterface|callable|string $handler): self - { - $clone = clone $this; - $clone->defaultHandler = $handler; - - return $clone; - } - - public function withHandler(string $mediaType, ExceptionRendererInterface|callable|string $handler): self - { - $clone = clone $this; - $clone->handlers[$mediaType] = $handler; - - return $clone; - } - - public function withoutHandlers(): self - { - $clone = clone $this; - $clone->handlers = []; - $clone->defaultHandler = null; - - return $clone; - } - - private function negotiateMediaType(ServerRequestInterface $request): string - { - $mediaTypes = $this->mediaTypeDetector->detect($request); - - return $mediaTypes[0] ?? $this->defaultMediaType; - } - - /** - * Determine which handler to use based on media type. - */ - private function negotiateHandler(string $mediaType): callable - { - $handler = $this->handlers[$mediaType] ?? $this->defaultHandler ?? reset($this->handlers); - - if (!$handler) { - throw new RuntimeException(sprintf('Exception handler for "%s" not found', $mediaType)); - } - - return $this->resolver->resolveCallable($handler); - } - - private function determineStatusCode(ServerRequestInterface $request, Throwable $exception): int - { - if ($exception instanceof HttpException) { - return $exception->getCode(); - } - - if ($request->getMethod() === 'OPTIONS') { - return 200; - } - - return 500; - } - - private function createResponse( - int $statusCode, - string $contentType, - Throwable $exception, - ): ResponseInterface { - $response = $this->responseFactory - ->createResponse($statusCode) - ->withHeader('Content-Type', $contentType); - - if ($exception instanceof HttpMethodNotAllowedException) { - $allowedMethods = implode(', ', $exception->getAllowedMethods()); - $response = $response->withHeader('Allow', $allowedMethods); - } - - return $response; - } -} diff --git a/Slim/Middleware/ExceptionMiddlewareTrait.php b/Slim/Middleware/ExceptionMiddlewareTrait.php new file mode 100644 index 000000000..54346dd42 --- /dev/null +++ b/Slim/Middleware/ExceptionMiddlewareTrait.php @@ -0,0 +1,130 @@ + 1]; + + public function __construct(ResponseFactoryInterface $responseFactory) + { + $this->responseFactory = $responseFactory; + } + + public function withErrorDetails(bool $flag): self + { + $clone = clone $this; + $clone->displayErrorDetails = $flag; + + return $clone; + } + + public function withMimeType(string $mimeType): self + { + $clone = clone $this; + $clone->mimeTypes[$mimeType] = 1; + + return $clone; + } + + private function getErrorTitle(Throwable $exception): string + { + if ($exception instanceof HttpException) { + return $exception->getTitle(); + } + + return $this->defaultErrorTitle; + } + + private function getErrorDescription(Throwable $exception): string + { + if ($exception instanceof HttpException) { + return $exception->getDescription(); + } + + return $this->defaultErrorDescription; + } + + private function detectMediaType(ServerRequestInterface $request): ?string + { + $accept = $request->getHeaderLine('Accept'); + + // Performance optimized for most cases + if ($accept === self::DEFAULT_TYPE || isset($this->mimeTypes['*/*'])) { + return self::DEFAULT_TYPE; + } + + // Parses complex Accept headers + $mimeTypes = $this->parseAcceptHeader($accept); + + foreach ($mimeTypes as $type) { + if (isset($this->mimeTypes[$type])) { + return $type; + } + } + + return null; + } + + private function createResponse(Throwable $exception, string $payload, string $contentType): ResponseInterface + { + $response = $this->responseFactory + ->createResponse(500) + ->withHeader('Content-Type', $contentType); + + $response->getBody()->write($payload); + + if ($exception instanceof HttpMethodNotAllowedException) { + $allowedMethods = implode(', ', $exception->getAllowedMethods()); + $response = $response->withHeader('Allow', $allowedMethods); + } + + return $response; + } + + /** + * Parses the 'Accept' header to extract media types. + * This method doesn't consider the quality values (q-values). + * + * @param string|null $accept The value of the 'Accept' header + * + * @return array An array of normalized media types from the 'Accept' header + */ + public function parseAcceptHeader(?string $accept): array + { + $acceptTypes = $accept ? explode(',', $accept) : []; + + $cleanTypes = []; + + foreach ($acceptTypes as $type) { + // Remove optional parameters like ";q=0.8" + $name = strtolower(trim(explode(';', $type)[0])); + $cleanTypes[] = $name; + } + + return $cleanTypes; + } +} diff --git a/Slim/Middleware/FormUrlEncodedBodyParserMiddleware.php b/Slim/Middleware/FormUrlEncodedBodyParserMiddleware.php new file mode 100644 index 000000000..af6b6d1b7 --- /dev/null +++ b/Slim/Middleware/FormUrlEncodedBodyParserMiddleware.php @@ -0,0 +1,38 @@ +getMethod(); + $contentType = $request->getHeaderLine('Content-Type'); + + if (!in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return $handler->handle($request); + } + + if ($this->isFormUrlEncodedMediaType($contentType)) { + $body = (string)$request->getBody(); + parse_str($body, $parsed); + $request = $request->withParsedBody($parsed); + } + + return $handler->handle($request); + } + + private function isFormUrlEncodedMediaType(string $contentType): bool + { + $type = strtolower(trim(explode(';', $contentType)[0])); + + return $type === 'application/x-www-form-urlencoded'; + } +} diff --git a/Slim/Middleware/HeadMethodMiddleware.php b/Slim/Middleware/HeadMethodMiddleware.php index faf15b11e..1bc9e9bb1 100644 --- a/Slim/Middleware/HeadMethodMiddleware.php +++ b/Slim/Middleware/HeadMethodMiddleware.php @@ -10,9 +10,9 @@ namespace Slim\Middleware; -use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -25,11 +25,11 @@ */ final class HeadMethodMiddleware implements MiddlewareInterface { - private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; - public function __construct(ResponseFactoryInterface $responseFactory) + public function __construct(StreamFactoryInterface $streamFactory) { - $this->responseFactory = $responseFactory; + $this->streamFactory = $streamFactory; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -45,9 +45,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface */ $method = strtoupper($request->getMethod()); if ($method === 'HEAD') { - $emptyBody = $this->responseFactory->createResponse()->getBody(); - - return $response->withBody($emptyBody); + return $response->withBody($this->streamFactory->createStream()); } return $response; diff --git a/Slim/Error/Renderers/HtmlExceptionRenderer.php b/Slim/Middleware/HtmlExceptionMiddleware.php similarity index 72% rename from Slim/Error/Renderers/HtmlExceptionRenderer.php rename to Slim/Middleware/HtmlExceptionMiddleware.php index d9ca168ce..78becbce2 100644 --- a/Slim/Error/Renderers/HtmlExceptionRenderer.php +++ b/Slim/Middleware/HtmlExceptionMiddleware.php @@ -8,40 +8,39 @@ declare(strict_types=1); -namespace Slim\Error\Renderers; +namespace Slim\Middleware; use ErrorException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Slim\Interfaces\ExceptionRendererInterface; -use Slim\Media\MediaType; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; use Throwable; -use function get_class; -use function sprintf; - -/** - * Formats exceptions into a HTML response. - */ -final class HtmlExceptionRenderer implements ExceptionRendererInterface +final class HtmlExceptionMiddleware implements MiddlewareInterface { - use ExceptionRendererTrait; + use ExceptionMiddlewareTrait; - private StreamFactoryInterface $streamFactory; + private const DEFAULT_TYPE = 'text/html'; - public function __construct(StreamFactoryInterface $streamFactory) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $this->streamFactory = $streamFactory; + try { + return $handler->handle($request); + } catch (Throwable $exception) { + $contentType = $this->detectMediaType($request); + + if ($contentType === null) { + throw $exception; + } + + return $this->createResponse($exception, $this->createPayload($exception), $contentType); + } } - public function __invoke( - ServerRequestInterface $request, - ResponseInterface $response, - ?Throwable $exception = null, - bool $displayErrorDetails = false - ): ResponseInterface { - if ($displayErrorDetails) { + private function createPayload(Throwable $exception): string + { + if ($this->displayErrorDetails) { $html = '

The application could not run because of the following error:

'; $html .= '

Details

'; $html .= $this->renderExceptionFragment($exception); @@ -49,12 +48,7 @@ public function __invoke( $html = sprintf('

%s

', $this->getErrorDescription($exception)); } - $html = $this->renderHtmlBody($this->getErrorTitle($exception), $html); - - $body = $this->streamFactory->createStream($html); - $response = $response->withBody($body); - - return $response->withHeader('Content-Type', MediaType::TEXT_HTML); + return $this->renderHtmlBody($this->getErrorTitle($exception), $html); } private function renderExceptionFragment(Throwable $exception): string @@ -88,7 +82,7 @@ private function renderExceptionFragment(Throwable $exception): string return $html; } - public function renderHtmlBody(string $title = '', string $html = ''): string + private function renderHtmlBody(string $title = '', string $html = ''): string { return sprintf( '' . diff --git a/Slim/Middleware/JsonBodyParserMiddleware.php b/Slim/Middleware/JsonBodyParserMiddleware.php new file mode 100644 index 000000000..6eef03fd0 --- /dev/null +++ b/Slim/Middleware/JsonBodyParserMiddleware.php @@ -0,0 +1,62 @@ +flags = $jsonFlags; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $method = $request->getMethod(); + $contentType = $request->getHeaderLine('Content-Type'); + + if (!in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return $handler->handle($request); + } + + 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())); + } + + if (is_array($parsed)) { + $request = $request->withParsedBody($parsed); + } + } + + return $handler->handle($request); + } + + /** + * Check whether the content type is JSON or has a +json structured suffix. + */ + private function isJsonMediaType(string $contentType): bool + { + $contentType = strtolower(trim(explode(';', $contentType)[0])); + + return $contentType === 'application/json' || str_ends_with($contentType, '+json'); + } +} diff --git a/Slim/Middleware/JsonExceptionMiddleware.php b/Slim/Middleware/JsonExceptionMiddleware.php new file mode 100644 index 000000000..5306059e9 --- /dev/null +++ b/Slim/Middleware/JsonExceptionMiddleware.php @@ -0,0 +1,83 @@ +handle($request); + } catch (Throwable $exception) { + $contentType = $this->detectMediaType($request); + + if ($contentType === null) { + throw $exception; + } + + return $this->createResponse($exception, $this->createPayload($exception), $contentType); + } + } + + /** + * Set options for JSON encoding. + * + * @see https://php.net/manual/function.json-encode.php + * @see https://php.net/manual/json.constants.php + */ + public function withJsonOptions(int $options): self + { + $clone = clone $this; + $clone->jsonOptions = $options; + + return $clone; + } + + private function createPayload(Throwable $exception): string + { + $payload = ['message' => $this->getErrorTitle($exception)]; + + if ($this->displayErrorDetails) { + $payload['exception'] = []; + do { + $payload['exception'][] = $this->formatExceptionFragment($exception); + } while ($exception = $exception->getPrevious()); + } + + return (string)json_encode($payload, $this->jsonOptions); + } + + private function formatExceptionFragment(Throwable $exception): array + { + $code = $exception instanceof ErrorException ? $exception->getSeverity() : $exception->getCode(); + + return [ + 'type' => get_class($exception), + 'code' => $code, + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]; + } +} diff --git a/Slim/Error/Renderers/PlainTextExceptionRenderer.php b/Slim/Middleware/PlainTextExceptionMiddleware.php similarity index 56% rename from Slim/Error/Renderers/PlainTextExceptionRenderer.php rename to Slim/Middleware/PlainTextExceptionMiddleware.php index 2cc625fe1..5a84cd7aa 100644 --- a/Slim/Error/Renderers/PlainTextExceptionRenderer.php +++ b/Slim/Middleware/PlainTextExceptionMiddleware.php @@ -8,42 +8,41 @@ declare(strict_types=1); -namespace Slim\Error\Renderers; +namespace Slim\Middleware; use ErrorException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Slim\Interfaces\ExceptionRendererInterface; -use Slim\Media\MediaType; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; use Throwable; -use function get_class; -use function sprintf; - -/** - * Formats exceptions into a plain text response. - */ -final class PlainTextExceptionRenderer implements ExceptionRendererInterface +final class PlainTextExceptionMiddleware implements MiddlewareInterface { - use ExceptionRendererTrait; + use ExceptionMiddlewareTrait; - private StreamFactoryInterface $streamFactory; + private const DEFAULT_TYPE = 'text/plain'; - public function __construct(StreamFactoryInterface $streamFactory) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $this->streamFactory = $streamFactory; + try { + return $handler->handle($request); + } catch (Throwable $exception) { + $contentType = $this->detectMediaType($request); + + if ($contentType === null) { + throw $exception; + } + + return $this->createResponse($exception, $this->createPayload($exception), $contentType); + } } - public function __invoke( - ServerRequestInterface $request, - ResponseInterface $response, - ?Throwable $exception = null, - bool $displayErrorDetails = false - ): ResponseInterface { + private function createPayload(Throwable $exception): string + { $text = sprintf("%s\n", $this->getErrorTitle($exception)); - if ($displayErrorDetails) { + if ($this->displayErrorDetails) { $text .= $this->formatExceptionFragment($exception); while ($exception = $exception->getPrevious()) { @@ -52,10 +51,7 @@ public function __invoke( } } - $body = $this->streamFactory->createStream($text); - $response = $response->withBody($body); - - return $response->withHeader('Content-Type', MediaType::TEXT_PLAIN); + return $text; } private function formatExceptionFragment(Throwable $exception): string diff --git a/Slim/Middleware/XmlBodyParserMiddleware.php b/Slim/Middleware/XmlBodyParserMiddleware.php new file mode 100644 index 000000000..1933ee8a5 --- /dev/null +++ b/Slim/Middleware/XmlBodyParserMiddleware.php @@ -0,0 +1,56 @@ +getMethod(); + $contentType = $request->getHeaderLine('Content-Type'); + + if (!in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return $handler->handle($request); + } + + if ($this->isXmlMediaType($contentType)) { + $backup = libxml_use_internal_errors(true); + $body = (string)$request->getBody(); + $xml = simplexml_load_string($body); + + libxml_clear_errors(); + libxml_use_internal_errors($backup); + + if ($xml === false) { + throw new HttpBadRequestException($request, 'Invalid XML body'); + } + + $request = $request->withParsedBody($xml); + } + + return $handler->handle($request); + } + + private function isXmlMediaType(string $contentType): bool + { + $contentType = strtolower(trim(explode(';', $contentType)[0])); + + return $contentType === 'application/xml' + || $contentType === 'text/xml' + || str_ends_with($contentType, '+xml'); + } +} diff --git a/Slim/Error/Renderers/XmlExceptionRenderer.php b/Slim/Middleware/XmlExceptionMiddleware.php similarity index 65% rename from Slim/Error/Renderers/XmlExceptionRenderer.php rename to Slim/Middleware/XmlExceptionMiddleware.php index 9127405b9..c520b2fdc 100644 --- a/Slim/Error/Renderers/XmlExceptionRenderer.php +++ b/Slim/Middleware/XmlExceptionMiddleware.php @@ -8,39 +8,39 @@ declare(strict_types=1); -namespace Slim\Error\Renderers; +namespace Slim\Middleware; use DOMDocument; use ErrorException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamFactoryInterface; -use Slim\Interfaces\ExceptionRendererInterface; -use Slim\Media\MediaType; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; use Throwable; -use function get_class; - -/** - * Formats exceptions into a XML response. - */ -final class XmlExceptionRenderer implements ExceptionRendererInterface +final class XmlExceptionMiddleware implements MiddlewareInterface { - use ExceptionRendererTrait; + use ExceptionMiddlewareTrait; - private StreamFactoryInterface $streamFactory; + private const DEFAULT_TYPE = 'application/xml'; - public function __construct(StreamFactoryInterface $streamFactory) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $this->streamFactory = $streamFactory; + try { + return $handler->handle($request); + } catch (Throwable $exception) { + $contentType = $this->detectMediaType($request); + + if ($contentType === null) { + throw $exception; + } + + return $this->createResponse($exception, $this->createPayload($exception), $contentType); + } } - public function __invoke( - ServerRequestInterface $request, - ResponseInterface $response, - ?Throwable $exception = null, - bool $displayErrorDetails = false, - ): ResponseInterface { + private function createPayload(Throwable $exception): string + { $dom = new DOMDocument('1.0', 'UTF-8'); $dom->formatOutput = true; @@ -51,7 +51,7 @@ public function __invoke( $errorElement->appendChild($messageElement); // If error details should be displayed - if ($displayErrorDetails) { + if ($this->displayErrorDetails) { do { $exceptionElement = $dom->createElement('exception'); @@ -75,9 +75,6 @@ public function __invoke( } while ($exception = $exception->getPrevious()); } - $body = $this->streamFactory->createStream((string)$dom->saveXML()); - $response = $response->withBody($body); - - return $response->withHeader('Content-Type', MediaType::APPLICATION_XML); + return (string)$dom->saveXML(); } } diff --git a/Slim/Renderers/JsonRenderer.php b/Slim/Renderers/JsonRenderer.php deleted file mode 100644 index 4ee14a2a2..000000000 --- a/Slim/Renderers/JsonRenderer.php +++ /dev/null @@ -1,65 +0,0 @@ -json($response, ['key' => 'value']); - * ``` - */ -final class JsonRenderer -{ - private StreamFactoryInterface $streamFactory; - - private int $jsonOptions = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR; - - private string $contentType = MediaType::APPLICATION_JSON; - - public function __construct(StreamFactoryInterface $streamFactory) - { - $this->streamFactory = $streamFactory; - } - - public function json(ResponseInterface $response, mixed $data = null): ResponseInterface - { - $response = $response->withHeader('Content-Type', $this->contentType); - $json = (string)json_encode($data, $this->jsonOptions); - - return $response->withBody($this->streamFactory->createStream($json)); - } - - /** - * Change the content type of the response. - */ - public function withContentType(string $type): self - { - $clone = clone $this; - $clone->contentType = $type; - - return $clone; - } - - /** - * Set options for JSON encoding. - * - * @see https://php.net/manual/function.json-encode.php - * @see https://php.net/manual/json.constants.php - */ - public function withJsonOptions(int $options): self - { - $clone = clone $this; - $clone->jsonOptions = $options; - - return $clone; - } -} diff --git a/tests/AppTest.php b/tests/AppTest.php index 68d9bcbc5..1f5dca247 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Http\Message\ServerRequestInterface; @@ -21,7 +22,6 @@ use RuntimeException; use Slim\App; use Slim\Builder\AppBuilder; -use Slim\Error\Renderers\HtmlExceptionRenderer; use Slim\Exception\HttpMethodNotAllowedException; use Slim\Exception\HttpNotFoundException; use Slim\Interfaces\ContainerFactoryInterface; @@ -30,10 +30,10 @@ use Slim\Middleware\BasePathMiddleware; use Slim\Middleware\ContentLengthMiddleware; use Slim\Middleware\EndpointMiddleware; -use Slim\Middleware\ErrorHandlingMiddleware; -use Slim\Middleware\ExceptionHandlingMiddleware; +use Slim\Middleware\ErrorExceptionMiddleware; use Slim\Middleware\ExceptionLoggingMiddleware; use Slim\Middleware\HeadMethodMiddleware; +use Slim\Middleware\HtmlExceptionMiddleware; use Slim\Middleware\RoutingArgumentsMiddleware; use Slim\Middleware\RoutingMiddleware; use Slim\Psr7\Headers; @@ -59,18 +59,17 @@ public function testAppWithExceptionAndErrorDetails(): void $builder = new AppBuilder(); $builder->addDefinitions( [ - ExceptionHandlingMiddleware::class => function ($container) { - $middleware = ExceptionHandlingMiddleware::createFromContainer($container); + HtmlExceptionMiddleware::class => function ($container) { + $responseFactory = $container->get(ResponseFactoryInterface::class); + $middleware = new HtmlExceptionMiddleware($responseFactory); - return $middleware - ->withDisplayErrorDetails(true) - ->withDefaultHandler(HtmlExceptionRenderer::class); + return $middleware->withErrorDetails(true); }, ] ); $app = $builder->build(); - $app->add(ExceptionHandlingMiddleware::class); + $app->add(HtmlExceptionMiddleware::class); $app->add(RoutingMiddleware::class); $app->add(EndpointMiddleware::class); @@ -126,9 +125,9 @@ public function testAppWithMiddlewareStack(): void $app->add(BasePathMiddleware::class); $app->add(RoutingMiddleware::class); $app->add(RoutingArgumentsMiddleware::class); - $app->add(ErrorHandlingMiddleware::class); - $app->add(ExceptionHandlingMiddleware::class); + $app->add(ErrorExceptionMiddleware::class); $app->add(ExceptionLoggingMiddleware::class); + $app->add(HtmlExceptionMiddleware::class); $app->add(EndpointMiddleware::class); $app->add(ContentLengthMiddleware::class); diff --git a/tests/Container/DefaultDefinitionsTest.php b/tests/Container/DefaultDefinitionsTest.php index 1e6741d22..ca27d026f 100644 --- a/tests/Container/DefaultDefinitionsTest.php +++ b/tests/Container/DefaultDefinitionsTest.php @@ -37,7 +37,6 @@ use Slim\Interfaces\ContainerResolverInterface; use Slim\Interfaces\EmitterInterface; use Slim\Interfaces\RequestHandlerInvocationStrategyInterface; -use Slim\Middleware\ExceptionHandlingMiddleware; use Slim\Psr7\Factory\ServerRequestFactory; use Slim\RequestHandler\MiddlewareRequestHandler; use Slim\Routing\Router; @@ -159,14 +158,6 @@ public function testRequestHandlerInvocationStrategyInterface(): void $this->assertInstanceOf(RequestResponse::class, $invocationStrategy); } - public function testExceptionHandlingMiddleware(): void - { - $container = (new AppBuilder())->build()->getContainer(); - $exceptionHandlingMiddleware = $container->get(ExceptionHandlingMiddleware::class); - - $this->assertInstanceOf(ExceptionHandlingMiddleware::class, $exceptionHandlingMiddleware); - } - public function testLoggerInterface(): void { $container = (new AppBuilder())->build()->getContainer(); diff --git a/tests/Error/Renderers/HtmlExceptionFormatterTest.php b/tests/Error/Renderers/HtmlExceptionFormatterTest.php deleted file mode 100644 index b7bff1d01..000000000 --- a/tests/Error/Renderers/HtmlExceptionFormatterTest.php +++ /dev/null @@ -1,84 +0,0 @@ -build(); - - // Create a request and response - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $exception = new Exception('Test exception message'); - - $formatter = $app->getContainer()->get(HtmlExceptionRenderer::class); - $result = $formatter($request, $response, $exception, true); - - $this->assertEquals('text/html', $result->getHeaderLine('Content-Type')); - - $html = (string)$result->getBody(); - $this->assertStringContainsString('

Details

', $html); - $this->assertStringContainsString('Test exception message', $html); - $this->assertStringContainsString('
Type: Exception
', $html); - } - - public function testInvokeWithExceptionAndWithoutErrorDetails() - { - // Create the Slim app - $app = (new AppBuilder())->build(); - - // Create a request and response - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $body = $app->getContainer() - ->get(StreamFactoryInterface::class) - ->createStream(''); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse() - ->withBody($body); - - $exception = new Exception('Test exception message'); - - // Instantiate the formatter and invoke it - $formatter = $app->getContainer()->get(HtmlExceptionRenderer::class); - $result = $formatter($request, $response, $exception, false); - - // Expected HTML - $html = (string)$result->getBody(); - $this->assertStringNotContainsString('

Details

', $html); - $this->assertStringContainsString('Application Error', $html); - $this->assertStringContainsString( - 'A website error has occurred. Sorry for the temporary inconvenience.', - $html - ); - } -} diff --git a/tests/Error/Renderers/JsonExceptionFormatterTest.php b/tests/Error/Renderers/JsonExceptionFormatterTest.php deleted file mode 100644 index 9ada7085f..000000000 --- a/tests/Error/Renderers/JsonExceptionFormatterTest.php +++ /dev/null @@ -1,107 +0,0 @@ -build(); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $exception = new Exception('Test exception message'); - - // Instantiate the formatter with JsonRenderer and invoke it - $formatter = $app->getContainer()->get(JsonExceptionRenderer::class); - $result = $formatter($request, $response, $exception, true); - - $this->assertEquals('application/json', $result->getHeaderLine('Content-Type')); - - $json = (string)$result->getBody(); - $data = json_decode($json, true); - - // Assertions - $this->assertEquals('Application Error', $data['message']); - $this->assertArrayHasKey('exception', $data); - $this->assertCount(1, $data['exception']); - $this->assertEquals('Test exception message', $data['exception'][0]['message']); - } - - public function testInvokeWithExceptionAndWithoutErrorDetails() - { - $app = (new AppBuilder())->build(); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $exception = new Exception('Test exception message'); - - $formatter = $app->getContainer()->get(JsonExceptionRenderer::class); - $result = $formatter($request, $response, $exception, false); - - $this->assertEquals('application/json', $result->getHeaderLine('Content-Type')); - - $json = (string)$result->getBody(); - $data = json_decode($json, true); - - // Assertions - $this->assertEquals('Application Error', $data['message']); - $this->assertArrayNotHasKey('exception', $data); - } - - public function testInvokeWithHttpExceptionAndWithoutErrorDetails() - { - $app = (new AppBuilder())->build(); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse() - ->withStatus(404); - - $exception = new HttpNotFoundException($request, 'Test exception message'); - - $formatter = $app->getContainer()->get(JsonExceptionRenderer::class); - $result = $formatter($request, $response, $exception, true); - - $this->assertEquals('application/json', $result->getHeaderLine('Content-Type')); - - $json = (string)$result->getBody(); - $data = json_decode($json, true); - - // Assertions - $this->assertEquals('404 Not Found', $data['message']); - $this->assertArrayHasKey('exception', $data); - } -} diff --git a/tests/Error/Renderers/PlainTextExceptionFormatterTest.php b/tests/Error/Renderers/PlainTextExceptionFormatterTest.php deleted file mode 100644 index 8b4a2cf76..000000000 --- a/tests/Error/Renderers/PlainTextExceptionFormatterTest.php +++ /dev/null @@ -1,108 +0,0 @@ -build(); - - // Create a request and response - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $exception = new Exception('Test exception message'); - - // Instantiate the formatter and invoke it - $formatter = $app->getContainer()->get(PlainTextExceptionRenderer::class); - $result = $formatter($request, $response, $exception, true); - - // Assertions - $this->assertEquals('text/plain', $result->getHeaderLine('Content-Type')); - - $text = (string)$result->getBody(); - $this->assertStringContainsString('Application Error', $text); - $this->assertStringContainsString('Test exception message', $text); - $this->assertStringContainsString('Type: Exception', $text); - $this->assertStringContainsString('Message: Test exception message', $text); - } - - public function testInvokeWithExceptionAndWithoutErrorDetails() - { - $app = (new AppBuilder())->build(); - - // Create a request and response - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $exception = new Exception('Test exception message'); - - // Instantiate the formatter and invoke it - $formatter = $app->getContainer()->get(PlainTextExceptionRenderer::class); - $result = $formatter($request, $response, $exception, false); - - // Assertions - $this->assertEquals('text/plain', $result->getHeaderLine('Content-Type')); - - $text = (string)$result->getBody(); - $this->assertStringContainsString('Application Error', $text); - $this->assertStringNotContainsString('Test exception message', $text); - $this->assertStringNotContainsString('Type: Exception', $text); - } - - public function testInvokeWithNestedExceptionsAndWithErrorDetails() - { - $app = (new AppBuilder())->build(); - - // Create a request and response - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $innerException = new Exception('Inner exception message'); - $outerException = new Exception('Outer exception message', 0, $innerException); - - // Instantiate the formatter and invoke it - $formatter = $app->getContainer()->get(PlainTextExceptionRenderer::class); - $result = $formatter($request, $response, $outerException, true); - - // Assertions - $this->assertEquals('text/plain', $result->getHeaderLine('Content-Type')); - - $text = (string)$result->getBody(); - $this->assertStringContainsString('Application Error', $text); - $this->assertStringContainsString('Outer exception message', $text); - $this->assertStringContainsString('Inner exception message', $text); - $this->assertStringContainsString('Previous Exception:', $text); - } -} diff --git a/tests/Error/Renderers/XmlExceptionFormatterTest.php b/tests/Error/Renderers/XmlExceptionFormatterTest.php deleted file mode 100644 index 1137ecae4..000000000 --- a/tests/Error/Renderers/XmlExceptionFormatterTest.php +++ /dev/null @@ -1,108 +0,0 @@ -build(); - - // Create a request and response - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $exception = new Exception('Test exception message'); - - // Instantiate the formatter and invoke it - $formatter = $app->getContainer()->get(XmlExceptionRenderer::class); - $result = $formatter($request, $response, $exception, true); - - // Assertions - $this->assertEquals('application/xml', $result->getHeaderLine('Content-Type')); - - $xml = (string)$result->getBody(); - $this->assertStringContainsString('Application Error', $xml); - $this->assertStringContainsString('', $xml); - $this->assertStringContainsString('Exception', $xml); - $this->assertStringContainsString('Test exception message', $xml); - } - - public function testInvokeWithExceptionAndWithoutErrorDetails() - { - $app = (new AppBuilder())->build(); - - // Create a request and response - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $exception = new Exception('Test exception message'); - - // Instantiate the formatter and invoke it - $formatter = $app->getContainer()->get(XmlExceptionRenderer::class); - $result = $formatter($request, $response, $exception, false); - - // Assertions - $this->assertEquals('application/xml', $result->getHeaderLine('Content-Type')); - - $xml = (string)$result->getBody(); - $this->assertStringContainsString('Application Error', $xml); - $this->assertStringNotContainsString('', $xml); - $this->assertStringNotContainsString('Exception', $xml); - } - - public function testInvokeWithNestedExceptionsAndWithErrorDetails() - { - $app = (new AppBuilder())->build(); - - // Create a request and response - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $response = $app->getContainer() - ->get(ResponseFactoryInterface::class) - ->createResponse(); - - $innerException = new Exception('Inner exception message'); - $outerException = new Exception('Outer exception message', 0, $innerException); - - // Instantiate the formatter and invoke it - $formatter = $app->getContainer()->get(XmlExceptionRenderer::class); - $result = $formatter($request, $response, $outerException, true); - - // Assertions - $this->assertEquals('application/xml', $result->getHeaderLine('Content-Type')); - - $xml = (string)$result->getBody(); - $this->assertStringContainsString('Application Error', $xml); - $this->assertStringContainsString('', $xml); - $this->assertStringContainsString('Outer exception message', $xml); - $this->assertStringContainsString('Inner exception message', $xml); - } -} diff --git a/tests/Media/MediaTypeDetectorTest.php b/tests/Media/MediaTypeDetectorTest.php deleted file mode 100644 index 6449715f7..000000000 --- a/tests/Media/MediaTypeDetectorTest.php +++ /dev/null @@ -1,74 +0,0 @@ -build(); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/') - ->withHeader('Accept', $acceptHeader); - - $mediaTypeDetector = new MediaTypeDetector(); - $detectedMediaTypes = $mediaTypeDetector->detect($request); - - $this->assertEquals($expectedMediaTypes, $detectedMediaTypes); - } - - #[DataProvider('provideContentTypeCases')] - public function testDetectFromContentTypeHeader(string $contentTypeHeader, array $expectedMediaTypes) - { - $app = (new AppBuilder())->build(); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('POST', '/') - ->withHeader('Content-Type', $contentTypeHeader); - - $mediaTypeDetector = new MediaTypeDetector(); - $detectedMediaTypes = $mediaTypeDetector->detect($request); - - $this->assertEquals($expectedMediaTypes, $detectedMediaTypes); - } - - public static function provideAcceptHeaderCases(): array - { - return [ - ['application/json', [0 => 'application/json']], - ['text/html', [0 => 'text/html']], - ['application/xml, text/html', [0 => 'application/xml', 1 => 'text/html']], - ['*/*', [0 => '*/*']], - ['', []], - ]; - } - - public static function provideContentTypeCases(): array - { - return [ - ['application/json', [0 => 'application/json']], - ['text/html', [0 => 'text/html']], - ['application/xml; charset=UTF-8', [0 => 'application/xml']], - ['application/vnd.api+json', [0 => 'application/vnd.api+json']], - ['', []], - ]; - } -} diff --git a/tests/Middleware/BodyParsingMiddlewareTest.php b/tests/Middleware/BodyParsingMiddlewareTest.php deleted file mode 100644 index a0aaadc49..000000000 --- a/tests/Middleware/BodyParsingMiddlewareTest.php +++ /dev/null @@ -1,361 +0,0 @@ -addDefinitions( - [ - BodyParsingMiddleware::class => function (ContainerInterface $container) { - $mediaTypeDetector = $container->get(MediaTypeDetector::class); - $middleware = new BodyParsingMiddleware($mediaTypeDetector); - - return $middleware - ->withDefaultMediaType('text/html') - ->withDefaultBodyParsers(); - }, - ] - ); - - $builder->addDefinitionsClass(NyholmDefinitions::class); - $app = $builder->build(); - - $responseFactory = $app->getContainer()->get(ResponseFactoryMiddleware::class); - - $test = $this; - $middlewares = [ - $app->getContainer()->get(BodyParsingMiddleware::class), - $this->createCallbackMiddleware(function (ServerRequestInterface $request) use ($expected, $test) { - $test->assertEquals($expected, $request->getParsedBody()); - }), - $responseFactory, - ]; - - $stream = $app->getContainer() - ->get(StreamFactoryInterface::class) - ->createStream($body); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('POST', '/') - ->withHeader('Accept', $contentType) - ->withHeader('Content-Type', $contentType) - ->withBody($stream); - - (new Runner($middlewares))->handle($request); - } - - public static function parsingProvider(): array - { - return [ - 'form' => [ - 'application/x-www-form-urlencoded;charset=utf8', - 'foo=bar', - ['foo' => 'bar'], - ], - 'json' => [ - 'application/json', - '{"foo":"bar"}', - ['foo' => 'bar'], - ], - 'json-with-charset' => [ - "application/json\t ; charset=utf8", - '{"foo":"bar"}', - ['foo' => 'bar'], - ], - 'json-suffix' => [ - 'application/vnd.api+json;charset=utf8', - '{"foo":"bar"}', - ['foo' => 'bar'], - ], - 'xml' => [ - 'application/xml', - 'John', - simplexml_load_string('John'), - ], - 'text-xml' => [ - 'text/xml', - 'John', - simplexml_load_string('John'), - ], - 'valid-json-but-not-an-array' => [ - 'application/json;charset=utf8', - '"foo bar"', - null, - ], - 'unknown-contenttype' => [ - 'text/foo+bar', - '"foo bar"', - null, - ], - 'empty-contenttype' => [ - '', - '"foo bar"', - null, - ], - // null is not supported anymore - // 'no-contenttype' => [ - // null, - // '"foo bar"', - // null, - // ], - 'invalid-contenttype' => [ - 'foo', - '"foo bar"', - null, - ], - 'invalid-xml' => [ - 'application/xml', - 'John', - null, - ], - 'invalid-textxml' => [ - 'text/xml', - 'John', - null, - ], - ]; - } - - #[DataProvider('parsingInvalidJsonProvider')] - public function testParsingInvalidJsonWithSlimPsr7($contentType, $body) - { - $builder = new AppBuilder(); - $builder->addDefinitions( - [ - BodyParsingMiddleware::class => function (ContainerInterface $container) { - $mediaTypeDetector = $container->get(MediaTypeDetector::class); - $middleware = new BodyParsingMiddleware($mediaTypeDetector); - - return $middleware - ->withDefaultMediaType('text/html') - ->withDefaultBodyParsers(); - }, - ] - ); - - $builder->addDefinitionsClass(SlimPsr7Definitions::class); - $app = $builder->build(); - $container = $app->getContainer(); - - $middlewares = [ - $container->get(BodyParsingMiddleware::class), - $container->get(ResponseFactoryMiddleware::class), - ]; - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('POST', '/') - ->withHeader('Accept', $contentType) - ->withHeader('Content-Type', $contentType); - - $request->getBody()->write($body); - - $response = (new Runner($middlewares))->handle($request); - - $this->assertSame('', (string)$response->getBody()); - } - - public static function parsingInvalidJsonProvider(): array - { - return [ - 'invalid-json' => [ - 'application/json;charset=utf8', - '{"foo"}/bar', - ], - 'invalid-json-2' => [ - 'application/json', - '{', - ], - ]; - } - - public function testParsingWithARegisteredParserAndSlimHttp() - { - $builder = new AppBuilder(); - - // Replace or change the PSR-17 factory because slim/http has its own parser - $builder->addDefinitionsClass(SlimHttpDefinitions::class); - $builder->addDefinitions( - [ - BodyParsingMiddleware::class => function (ContainerInterface $container) { - $mediaTypeDetector = $container->get(MediaTypeDetector::class); - $middleware = new BodyParsingMiddleware($mediaTypeDetector); - - return $middleware->withBodyParser('application/vnd.api+json', function ($input) { - return ['data' => json_decode($input, true)]; - }); - }, - ] - ); - $app = $builder->build(); - - $input = '{"foo":"bar"}'; - $stream = $app->getContainer() - ->get(StreamFactoryInterface::class) - ->createStream($input); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('POST', '/') - ->withHeader('Accept', 'application/vnd.api+json;charset=utf8') - ->withBody($stream); - - $middlewares = []; - $middlewares[] = $app->getContainer()->get(BodyParsingMiddleware::class); - $middlewares[] = $this->createParsedBodyMiddleware(); - $middlewares[] = $app->getContainer()->get(ResponseFactoryMiddleware::class); - - $response = (new Runner($middlewares))->handle($request); - - $this->assertJsonResponse(['data' => ['foo' => 'bar']], $response); - $this->assertSame(['data' => ['foo' => 'bar']], json_decode((string)$response->getBody(), true)); - } - - #[DataProvider('httpDefinitionsProvider')] - public function testParsingFailsWhenAnInvalidTypeIsReturned(string $definitions) - { - // The slim/http package has its own body parser, so this middleware will not be used. - // The SlimHttpDefinitions::class will not fail here, because the body parser will not be executed. - if ($definitions === SlimHttpDefinitions::class) { - $this->assertTrue(true); - - return; - } - - $this->expectException(RuntimeException::class); - - $builder = new AppBuilder(); - $builder->addDefinitionsClass($definitions); - - $builder->addDefinitions( - [ - BodyParsingMiddleware::class => function (ContainerInterface $container) { - $mediaTypeDetector = $container->get(MediaTypeDetector::class); - $middleware = new BodyParsingMiddleware($mediaTypeDetector); - - $middleware = $middleware->withBodyParser('application/json', function () { - // invalid - should return null, array or object - return 10; - }); - - return $middleware; - }, - ] - ); - $app = $builder->build(); - - $stream = $app->getContainer() - ->get(StreamFactoryInterface::class) - ->createStream('{"foo":"bar"}'); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('POST', '/') - ->withHeader('Accept', 'application/json;charset=utf8') - ->withHeader('Content-Type', 'application/json;charset=utf8') - ->withBody($stream); - - $middlewares = []; - $middlewares[] = $app->getContainer()->get(BodyParsingMiddleware::class); - $middlewares[] = $this->createParsedBodyMiddleware(); - $middlewares[] = $app->getContainer()->get(ResponseFactoryMiddleware::class); - - (new Runner($middlewares))->handle($request); - } - - public static function httpDefinitionsProvider(): array - { - return [ - 'GuzzleDefinitions' => [GuzzleDefinitions::class], - 'HttpSoftDefinitions' => [HttpSoftDefinitions::class], - 'LaminasDiactorosDefinitions' => [LaminasDiactorosDefinitions::class], - 'NyholmDefinitions' => [NyholmDefinitions::class], - 'SlimHttpDefinitions' => [SlimHttpDefinitions::class], - 'SlimPsr7Definitions' => [SlimPsr7Definitions::class], - ]; - } - - private function createParsedBodyMiddleware(): MiddlewareInterface - { - return new class implements MiddlewareInterface { - public function process( - ServerRequestInterface $request, - RequestHandlerInterface $handler, - ): ResponseInterface { - $response = $handler->handle($request); - - // Return the parsed body - $response->getBody()->write(json_encode($request->getParsedBody())); - - return $response; - } - }; - } - - private function createCallbackMiddleware(callable $callback): MiddlewareInterface - { - return new class ($callback) implements MiddlewareInterface { - /** - * @var callable - */ - private $callback; - - public function __construct(callable $callback) - { - $this->callback = $callback; - } - - public function process( - ServerRequestInterface $request, - RequestHandlerInterface $handler, - ): ResponseInterface { - $response = $handler->handle($request); - - call_user_func($this->callback, $request, $handler); - - return $response; - } - }; - } -} diff --git a/tests/Middleware/ErrorHandlingMiddlewareTest.php b/tests/Middleware/ErrorExceptionMiddlewareTest.php similarity index 90% rename from tests/Middleware/ErrorHandlingMiddlewareTest.php rename to tests/Middleware/ErrorExceptionMiddlewareTest.php index 19fd5a7bf..3935d9176 100644 --- a/tests/Middleware/ErrorHandlingMiddlewareTest.php +++ b/tests/Middleware/ErrorExceptionMiddlewareTest.php @@ -19,10 +19,10 @@ use Psr\Http\Server\RequestHandlerInterface; use Slim\Builder\AppBuilder; use Slim\Middleware\EndpointMiddleware; -use Slim\Middleware\ErrorHandlingMiddleware; +use Slim\Middleware\ErrorExceptionMiddleware; use Slim\Middleware\RoutingMiddleware; -final class ErrorHandlingMiddlewareTest extends TestCase +final class ErrorExceptionMiddlewareTest extends TestCase { public function testProcessHandlesError(): void { @@ -45,7 +45,7 @@ public function testProcessHandlesError(): void $app = (new AppBuilder())->build(); $middleware = $app ->getContainer() - ->get(ErrorHandlingMiddleware::class); + ->get(ErrorExceptionMiddleware::class); // Invoke the middleware process method $middleware->process($request, $handler); @@ -59,7 +59,7 @@ public function testProcessHandlesErrorSilent(): void $app = $builder->build(); $middleware = $app ->getContainer() - ->get(ErrorHandlingMiddleware::class); + ->get(ErrorExceptionMiddleware::class); $app->add($middleware); $app->add(RoutingMiddleware::class); @@ -97,7 +97,7 @@ public function testProcessHandlesException(): void // Instantiate the middleware $app = (new AppBuilder())->build(); - $middleware = $app->getContainer()->get(ErrorHandlingMiddleware::class); + $middleware = $app->getContainer()->get(ErrorExceptionMiddleware::class); // Invoke the middleware process method $middleware->process($request, $handler); @@ -115,7 +115,7 @@ public function testProcessReturnsResponse(): void ->willReturn($response); $app = (new AppBuilder())->build(); - $middleware = $app->getContainer()->get(ErrorHandlingMiddleware::class); + $middleware = $app->getContainer()->get(ErrorExceptionMiddleware::class); // Invoke the middleware process method and assert the response is returned $result = $middleware->process($request, $handler); diff --git a/tests/Middleware/ExceptionHandlingMiddlewareTest.php b/tests/Middleware/ExceptionHandlingMiddlewareTest.php deleted file mode 100644 index f4de371ad..000000000 --- a/tests/Middleware/ExceptionHandlingMiddlewareTest.php +++ /dev/null @@ -1,356 +0,0 @@ -addDefinitions( - [ - ExceptionHandlingMiddleware::class => function ($container) { - $middleware = ExceptionHandlingMiddleware::createFromContainer($container); - - return $middleware - ->withDisplayErrorDetails(false) - ->withDefaultHandler(HtmlExceptionRenderer::class); - }, - ] - ); - $app = $builder->build(); - - $app->add(ExceptionHandlingMiddleware::class); - $app->add(RoutingMiddleware::class); - $app->add(EndpointMiddleware::class); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $app->get('/', function () { - throw new RuntimeException('Test error'); - }); - - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $this->assertSame('text/html', $response->getHeaderLine('Content-Type')); - $this->assertStringNotContainsString('Test Error message', (string)$response->getBody()); - $this->assertStringContainsString('

Application Error

', (string)$response->getBody()); - } - - public function testDefaultHandlerWithDetails(): void - { - $builder = new AppBuilder(); - $builder->addDefinitions( - [ - ExceptionHandlingMiddleware::class => function ($container) { - $middleware = ExceptionHandlingMiddleware::createFromContainer($container); - - return $middleware - ->withDisplayErrorDetails(true) - ->withDefaultHandler(HtmlExceptionRenderer::class); - }, - ] - ); - $app = $builder->build(); - - $app->add(ExceptionHandlingMiddleware::class); - $app->add(RoutingMiddleware::class); - $app->add(EndpointMiddleware::class); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $app->get('/', function () { - throw new RuntimeException('Test error', 123); - }); - - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $this->assertSame('text/html', (string)$response->getHeaderLine('Content-Type')); - $this->assertStringNotContainsString('Test Error message', (string)$response->getBody()); - $this->assertStringContainsString('

Application Error

', (string)$response->getBody()); - } - - public function testDefaultHtmlMediaTypeWithDetails(): void - { - $builder = new AppBuilder(); - $builder->addDefinitions( - [ - ExceptionHandlingMiddleware::class => function ($container) { - $middleware = ExceptionHandlingMiddleware::createFromContainer($container); - - return $middleware - ->withDisplayErrorDetails(true) - ->withDefaultMediaType(MediaType::TEXT_HTML) - ->withHandler(MediaType::TEXT_HTML, HtmlExceptionRenderer::class); - }, - ] - ); - $app = $builder->build(); - - $app->add(ExceptionHandlingMiddleware::class); - $app->add(RoutingMiddleware::class); - $app->add(EndpointMiddleware::class); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/') - ->withHeader('Accept', 'application/json'); - - $app->get('/', function () { - throw new RuntimeException('Test error', 123); - }); - - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $this->assertSame('text/html', (string)$response->getHeaderLine('Content-Type')); - $this->assertStringNotContainsString('Test Error message', (string)$response->getBody()); - $this->assertStringContainsString('

Application Error

', (string)$response->getBody()); - } - - public function testJsonMediaTypeDisplayErrorDetails(): void - { - $builder = new AppBuilder(); - - $builder->addDefinitions( - [ - ExceptionHandlingMiddleware::class => function ($container) { - $middleware = ExceptionHandlingMiddleware::createFromContainer($container); - - return $middleware - ->withDisplayErrorDetails(true) - ->withHandler(MediaType::APPLICATION_JSON, JsonExceptionRenderer::class); - }, - ] - ); - - $app = $builder->build(); - - $app->add(ExceptionHandlingMiddleware::class); - $app->add(RoutingMiddleware::class); - $app->add(EndpointMiddleware::class); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/') - ->withHeader('Accept', 'application/json'); - - $app->get('/', function () { - throw new RuntimeException('Test error', 123); - }); - - $response = $app->handle($request); - - $actual = json_decode((string)$response->getBody(), true); - $this->assertSame('Application Error', $actual['message']); - $this->assertSame(1, count($actual['exception'])); - $this->assertSame('RuntimeException', $actual['exception'][0]['type']); - $this->assertSame(123, $actual['exception'][0]['code']); - $this->assertSame('Test error', $actual['exception'][0]['message']); - } - - public function testWithoutHandler(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Exception handler for "text/html" not found'); - - $builder = new AppBuilder(); - $app = $builder->build(); - - $app->add(ExceptionHandlingMiddleware::class); - $app->add(RoutingMiddleware::class); - $app->add(EndpointMiddleware::class); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/'); - - $app->get('/', function () { - throw new RuntimeException('Test error', 123); - }); - - $app->handle($request); - } - - #[DataProvider('textHmlHeaderProvider')] - public function testWithTextHtml(string $header, string $headerValue): void - { - $builder = new AppBuilder(); - $builder->addDefinitions( - [ - ExceptionHandlingMiddleware::class => function ($container) { - $middleware = ExceptionHandlingMiddleware::createFromContainer($container); - - return $middleware - ->withDisplayErrorDetails(true) - ->withHandler(MediaType::TEXT_HTML, HtmlExceptionRenderer::class); - }, - ] - ); - $app = $builder->build(); - - $app->add(ExceptionHandlingMiddleware::class); - $app->add(RoutingMiddleware::class); - $app->add(EndpointMiddleware::class); - - $app->get('/', function () { - throw new RuntimeException('Test Error message'); - }); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/') - ->withHeader($header, $headerValue); - - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $this->assertSame('text/html', (string)$response->getHeaderLine('Content-Type')); - $this->assertStringContainsString('Test Error message', (string)$response->getBody()); - } - - public static function textHmlHeaderProvider(): array - { - return [ - ['Accept', 'text/html'], - ['Accept', 'text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8'], - ['Content-Type', 'text/html'], - ['Content-Type', 'text/html; charset=utf-8'], - ]; - } - - // todo: Add test for other media types - - public function testWithAcceptJson(): void - { - $builder = new AppBuilder(); - $builder->addDefinitions( - [ - ExceptionHandlingMiddleware::class => function ($container) { - $middleware = ExceptionHandlingMiddleware::createFromContainer($container); - - return $middleware - ->withDisplayErrorDetails(false) - ->withHandler(MediaType::APPLICATION_JSON, JsonExceptionRenderer::class); - }, - ] - ); - $app = $builder->build(); - - $app->add(ExceptionHandlingMiddleware::class); - $app->add(RoutingMiddleware::class); - $app->add(EndpointMiddleware::class); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/') - ->withHeader('Accept', 'application/json'); - - $app->get('/', function () { - throw new RuntimeException('Test exception'); - }); - - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $expected = [ - 'message' => 'Application Error', - ]; - $this->assertJsonResponse($expected, $response); - } - - public static function xmlHeaderProvider(): array - { - return [ - ['Accept', 'application/xml'], - ['Accept', 'application/xml, application/json'], - ['Content-Type', 'application/xml'], - ['Content-Type', 'application/xml; charset=utf-8'], - ]; - } - - #[DataProvider('xmlHeaderProvider')] - public function testWithAcceptXml(string $header, string $headerValue): void - { - $builder = new AppBuilder(); - $builder->addDefinitions( - [ - ExceptionHandlingMiddleware::class => function ($container) { - $middleware = ExceptionHandlingMiddleware::createFromContainer($container); - - return $middleware->withDisplayErrorDetails(false) - ->withoutHandlers() - ->withHandler('application/json', JsonExceptionRenderer::class) - ->withHandler('application/xml', XmlExceptionRenderer::class); - }, - ] - ); - $app = $builder->build(); - - $app->add(ExceptionHandlingMiddleware::class); - $app->add(RoutingMiddleware::class); - $app->add(EndpointMiddleware::class); - - $request = $app->getContainer() - ->get(ServerRequestFactoryInterface::class) - ->createServerRequest('GET', '/') - ->withHeader($header, $headerValue); - - $app->get('/', function () { - throw new RuntimeException('Test exception'); - }); - - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $expected = ' - - Application Error - '; - - $dom = new DOMDocument(); - $dom->preserveWhiteSpace = false; - $dom->formatOutput = true; - $dom->loadXML($expected); - $expected = $dom->saveXML(); - - $dom2 = new DOMDocument(); - $dom2->preserveWhiteSpace = false; - $dom2->formatOutput = true; - $dom2->loadXML((string)$response->getBody()); - $actual = $dom2->saveXML(); - - $this->assertSame($expected, $actual); - } -} diff --git a/tests/Middleware/ExceptionLoggingMiddlewareTest.php b/tests/Middleware/ExceptionLoggingMiddlewareTest.php index f356740e4..53b6297ee 100644 --- a/tests/Middleware/ExceptionLoggingMiddlewareTest.php +++ b/tests/Middleware/ExceptionLoggingMiddlewareTest.php @@ -20,7 +20,7 @@ use RuntimeException; use Slim\Builder\AppBuilder; use Slim\Middleware\EndpointMiddleware; -use Slim\Middleware\ErrorHandlingMiddleware; +use Slim\Middleware\ErrorExceptionMiddleware; use Slim\Middleware\ExceptionLoggingMiddleware; use Slim\Middleware\RoutingMiddleware; use Slim\Tests\Logging\TestLogger; @@ -113,7 +113,7 @@ public function testUserLevelErrorIsLogged(): void error_reporting(E_ALL); $logger = new TestLogger(); - $app->add(ErrorHandlingMiddleware::class); + $app->add(ErrorExceptionMiddleware::class); $middleware = new ExceptionLoggingMiddleware($logger); diff --git a/tests/Middleware/FormUrlEncodedBodyParserMiddlewareTest.php b/tests/Middleware/FormUrlEncodedBodyParserMiddlewareTest.php new file mode 100644 index 000000000..f74259403 --- /dev/null +++ b/tests/Middleware/FormUrlEncodedBodyParserMiddlewareTest.php @@ -0,0 +1,100 @@ +createStream($body); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($stream); + + $middleware = new FormUrlEncodedBodyParserMiddleware(); + $response = $middleware->process($request, new class implements RequestHandlerInterface { + public function handle( + ServerRequestInterface $request + ): ResponseInterface { + $parsed = $request->getParsedBody(); + $response = new Response(); + $response->getBody()->write($parsed['foo'] . ',' . $parsed['baz']); + + return $response; + } + }); + + $this->assertSame('bar,qux', (string)$response->getBody()); + } + + public function testSkipsParsingForNonFormContentType(): void + { + $stream = (new StreamFactory())->createStream('foo=bar&baz=qux'); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', 'text/plain') + ->withBody($stream); + + $middleware = new FormUrlEncodedBodyParserMiddleware(); + $response = $middleware->process($request, new class implements RequestHandlerInterface { + public function handle( + ServerRequestInterface $request + ): ResponseInterface { + $parsed = $request->getParsedBody(); + $response = new Response(); + $response->getBody()->write($parsed === null ? 'no-parse' : 'parsed'); + + return $response; + } + }); + + $this->assertSame('no-parse', (string)$response->getBody()); + } + + public function testSkipsParsingForEmptyBody(): void + { + $stream = (new StreamFactory())->createStream(''); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($stream); + + $middleware = new FormUrlEncodedBodyParserMiddleware(); + $response = $middleware->process($request, new class implements RequestHandlerInterface { + public function handle( + ServerRequestInterface $request + ): ResponseInterface { + $parsed = $request->getParsedBody(); + $response = new Response(); + $response->getBody()->write(json_encode($parsed)); + + return $response; + } + }); + + // empty + $this->assertSame('[]', (string)$response->getBody()); + } +} diff --git a/tests/Middleware/HtmlExceptionMiddlewareTest.php b/tests/Middleware/HtmlExceptionMiddlewareTest.php new file mode 100644 index 000000000..c68f41fd0 --- /dev/null +++ b/tests/Middleware/HtmlExceptionMiddlewareTest.php @@ -0,0 +1,114 @@ +createServerRequest('GET', '/'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return (new ResponseFactory())->createResponse(); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testProcessCatchesGenericExceptionWithHtmlAccept(): void + { + $middleware = (new HtmlExceptionMiddleware(new ResponseFactory())) + ->withMimeType('text/html'); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/') + ->withHeader('Accept', 'text/html'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Generic failure'); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertSame('text/html', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString('Application Error', (string)$response->getBody()); + } + + public function testProcessWithHttpMethodNotAllowedAddsAllowHeader(): void + { + $middleware = (new HtmlExceptionMiddleware(new ResponseFactory())) + ->withMimeType('text/html'); + + $request = (new ServerRequestFactory())->createServerRequest('POST', '/') + ->withHeader('Accept', 'text/html'); + + $handler = new class ($request) implements RequestHandlerInterface { + private $request; + + public function __construct($request) + { + $this->request = $request; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $exception = new HttpMethodNotAllowedException($this->request); + $exception->setAllowedMethods(['GET', 'PUT']); + + throw $exception; + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertSame('GET, PUT', $response->getHeaderLine('Allow')); + } + + public function testProcessThrowsOriginalExceptionIfMediaTypeNotAccepted(): void + { + $middleware = new HtmlExceptionMiddleware(new ResponseFactory()); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/xml'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Unsupported type'); + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unsupported type'); + + $middleware->process($request, $handler); + } +} diff --git a/tests/Middleware/JsonBodyParserMiddlewareTest.php b/tests/Middleware/JsonBodyParserMiddlewareTest.php new file mode 100644 index 000000000..30ebb008f --- /dev/null +++ b/tests/Middleware/JsonBodyParserMiddlewareTest.php @@ -0,0 +1,201 @@ +createStream($body); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', $contentType) + ->withBody($stream); + + $middleware = new JsonBodyParserMiddleware(); + + $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 function testParsesStructuredJsonType(): void + { + $stream = (new StreamFactory())->createStream('{"hello":"world"}'); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', 'application/vnd.api+json') + ->withBody($stream); + + $middleware = new JsonBodyParserMiddleware(); + + $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('{"hello":"world"}', (string)$response->getBody()); + } + + #[DataProvider('invalidJsonProvider')] + public function testThrowsExceptionOnInvalidJson($contentType, $body): void + { + $this->expectException(JsonException::class); + $this->expectExceptionMessage('Syntax error'); + + $stream = (new StreamFactory())->createStream('{"foo": "bar"'); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', 'application/json') + ->withBody($stream); + + $middleware = new JsonBodyParserMiddleware(); + + $middleware->process($request, new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(); + } + }); + } + + public function testSkipsParsingForNonJsonContentType(): void + { + $stream = (new StreamFactory())->createStream('{"foo":"bar"}'); + + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/') + ->withHeader('Content-Type', 'text/plain') + ->withBody($stream); + + $middleware = new JsonBodyParserMiddleware(); + + $response = $middleware->process($request, new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + $response = new Response(); + $parsedBody = $request->getParsedBody(); + $response->getBody()->write($parsedBody === null ? 'no-parse' : 'parsed'); + + return $response; + } + }); + + $this->assertSame('no-parse', (string)$response->getBody()); + } + + public static function validJsonProvider(): array + { + return [ + 'json' => [ + 'application/json', + '{"foo":"bar"}', + '{"foo":"bar"}', + ], + 'json-with-charset' => [ + "application/json\t ; charset=utf8", + '{"foo":"bar"}', + '{"foo":"bar"}', + ], + 'json-suffix' => [ + 'application/vnd.api+json;charset=utf8', + '{"foo":"bar"}', + '{"foo":"bar"}', + ], + 'valid-json-but-not-an-array' => [ + 'application/json;charset=utf8', + '"foo bar"', + 'null', + ], + 'empty-object' => [ + 'application/json', + '{}', + '[]', + ], + 'unknown-contenttype' => [ + 'text/foo+bar', + '"foo bar"', + 'null', + ], + 'empty-contenttype' => [ + '', + '"foo bar"', + 'null', + ], + // null is not supported anymore + // Header values must be RFC 7230 compatible strings. + /* 'no-contenttype' => [ + null, + '"foo bar"', + 'null', + ],*/ + 'json-null' => [ + 'application/json', + 'null', + 'null', + ], + 'json-false' => [ + 'application/json', + 'false', + 'null', + ], + 'invalid-contenttype' => [ + 'foo', + '"foo bar"', + 'null', + ], + ]; + } + + public static function invalidJsonProvider(): array + { + return [ + 'invalid-json' => [ + 'application/json', + '{"foo": "bar"', + ], + 'invalid-json-empty-string' => [ + 'application/json', + '', + ], + ]; + } +} diff --git a/tests/Middleware/JsonExceptionMiddlewareTest.php b/tests/Middleware/JsonExceptionMiddlewareTest.php new file mode 100644 index 000000000..39a88df6c --- /dev/null +++ b/tests/Middleware/JsonExceptionMiddlewareTest.php @@ -0,0 +1,224 @@ +createServerRequest('GET', '/'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return (new ResponseFactory())->createResponse(); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testProcessCatchesExceptionWithJsonAccept(): void + { + $middleware = (new JsonExceptionMiddleware(new ResponseFactory())) + ->withMimeType('application/json') + ->withErrorDetails(true); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/json'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Something went wrong'); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertJson((string)$response->getBody()); + $this->assertStringContainsString('Something went wrong', (string)$response->getBody()); + } + + public function testProcessWithHttpMethodNotAllowedIncludesAllowHeader(): void + { + $middleware = (new JsonExceptionMiddleware(new ResponseFactory())) + ->withMimeType('application/json'); + + $request = (new ServerRequestFactory())->createServerRequest('POST', '/') + ->withHeader('Accept', 'application/json'); + + $handler = new class ($request) implements RequestHandlerInterface { + private $request; + + public function __construct($request) + { + $this->request = $request; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $exception = new HttpMethodNotAllowedException($this->request); + $exception->setAllowedMethods(['GET', 'PATCH']); + throw $exception; + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertSame('GET, PATCH', $response->getHeaderLine('Allow')); + } + + public function testWithErrorDetails(): void + { + $middleware = (new JsonExceptionMiddleware(new ResponseFactory())) + ->withMimeType('application/json') + ->withErrorDetails(true); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/json'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Detailed error'); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(500, $response->getStatusCode()); + + $body = (string)$response->getBody(); + $this->assertJson($body); + $this->assertStringContainsString('exception', $body); + $this->assertStringContainsString('Application Error', $body); + $this->assertStringContainsString('Detailed error', $body); + + $data = json_decode($body, true); + + $this->assertSame('Application Error', $data['message']); + $this->assertArrayHasKey('exception', $data); + $this->assertSame(1, count($data['exception'])); + $this->assertSame('Detailed error', $data['exception'][0]['message']); + $this->assertSame(['type', 'code', 'message', 'file', 'line'], array_keys($data['exception'][0])); + } + + public function testWithoutErrorDetails(): void + { + $middleware = (new JsonExceptionMiddleware(new ResponseFactory())) + ->withMimeType('application/json') + ->withErrorDetails(false); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/json'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Hidden error'); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertJson((string)$response->getBody()); + $this->assertStringNotContainsString('exception', (string)$response->getBody()); + $this->assertStringContainsString('Application Error', (string)$response->getBody()); + $this->assertStringNotContainsString('Hidden error', (string)$response->getBody()); + } + + public function testRethrowsExceptionWhenNoAcceptableContentTypeDetected(): void + { + $middleware = (new JsonExceptionMiddleware(new ResponseFactory())); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/unsupported-type'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('This should be rethrown'); + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This should be rethrown'); + + $middleware->process($request, $handler); + } + + public function testWithMimeTypeSupportsAdditionalJsonTypes(): void + { + $middleware = (new JsonExceptionMiddleware(new ResponseFactory())) + ->withErrorDetails(true) + ->withMimeType('application/vnd.api+json') + ->withMimeType('application/ld+json'); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/vnd.api+json'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('Test message'); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertSame('application/vnd.api+json', $response->getHeaderLine('Content-Type')); + $this->assertJson((string)$response->getBody()); + $this->assertStringContainsString('Test message', (string)$response->getBody()); + } + + public function testWithJsonOptionsChangesEncoding(): void + { + $middleware = (new JsonExceptionMiddleware(new ResponseFactory())) + ->withErrorDetails(true) + ->withMimeType('application/json') + ->withJsonOptions(JSON_HEX_TAG); + + $request = (new ServerRequestFactory())->createServerRequest('GET', '/') + ->withHeader('Accept', 'application/json'); + + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('