diff --git a/config/autoload/content-negotiation.global.php b/config/autoload/content-negotiation.global.php index 59a2d6d..c58f8da 100644 --- a/config/autoload/content-negotiation.global.php +++ b/config/autoload/content-negotiation.global.php @@ -2,23 +2,24 @@ declare(strict_types=1); +use Api\App\Middleware\ContentNegotiationMiddleware; + return [ 'content-negotiation' => [ - 'default' => [ // default to any route if not configured above - 'Accept' => [ // the Accept is what format the server can send back + // default to any route if not configured below + ContentNegotiationMiddleware::DEFAULT_HEADERS => [ + // the Accept is what format the server can send back + 'Accept' => [ 'application/json', 'application/hal+json', ], - 'Content-Type' => [ // the Content-Type is what format the server can process + // the Content-Type is what format the server can process + 'Content-Type' => [ 'application/json', 'application/hal+json', ], ], - 'your.route.name' => [ - 'Accept' => [], - 'Content-Type' => [], - ], - 'user::create-account-avatar' => [ + 'user::create-account-avatar' => [ 'Accept' => [ 'application/json', 'application/hal+json', @@ -27,7 +28,7 @@ 'multipart/form-data', ], ], - 'user::create-user-avatar' => [ + 'user::create-user-avatar' => [ 'Accept' => [ 'application/json', 'application/hal+json', diff --git a/src/App/src/Exception/NotAcceptableException.php b/src/App/src/Exception/NotAcceptableException.php new file mode 100644 index 0000000..73ddfbb --- /dev/null +++ b/src/App/src/Exception/NotAcceptableException.php @@ -0,0 +1,28 @@ +type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_NOT_ACCEPTABLE; + $exception->title = $title; + $exception->additional = $additional; + + return $exception; + } +} diff --git a/src/App/src/Exception/UnsupportedMediaTypeException.php b/src/App/src/Exception/UnsupportedMediaTypeException.php new file mode 100644 index 0000000..65dff3d --- /dev/null +++ b/src/App/src/Exception/UnsupportedMediaTypeException.php @@ -0,0 +1,28 @@ +type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE; + $exception->title = $title; + $exception->additional = $additional; + + return $exception; + } +} diff --git a/src/App/src/Middleware/ContentNegotiationMiddleware.php b/src/App/src/Middleware/ContentNegotiationMiddleware.php index 8815560..7d0a321 100644 --- a/src/App/src/Middleware/ContentNegotiationMiddleware.php +++ b/src/App/src/Middleware/ContentNegotiationMiddleware.php @@ -4,6 +4,9 @@ namespace Api\App\Middleware; +use Api\App\Exception\NotAcceptableException; +use Api\App\Exception\UnsupportedMediaTypeException; +use Core\App\Message; use Dot\DependencyInjection\Attribute\Inject; use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\JsonResponse; @@ -13,26 +16,35 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use function array_filter; -use function array_intersect; -use function array_map; +use function count; use function explode; use function in_array; use function is_array; +use function preg_match; use function str_contains; -use function strtok; +use function str_ends_with; +use function str_starts_with; +use function strpos; +use function substr; use function trim; +use function usort; -readonly class ContentNegotiationMiddleware implements MiddlewareInterface +class ContentNegotiationMiddleware implements MiddlewareInterface { + public const DEFAULT_HEADERS = 'default'; + #[Inject( 'config.content-negotiation', )] public function __construct( - private array $config, + private readonly array $config, ) { } + /** + * @throws NotAcceptableException + * @throws UnsupportedMediaTypeException + */ public function process( ServerRequestInterface $request, RequestHandlerInterface $handler @@ -44,92 +56,180 @@ public function process( $routeName = (string) $routeResult->getMatchedRouteName(); - $accept = $this->formatAcceptRequest($request->getHeaderLine('Accept')); - if (! $this->checkAccept($routeName, $accept)) { - return $this->notAcceptableResponse('Not Acceptable'); + // Parse Accept header including quality values + $acceptedTypes = $this->parseAcceptHeader($request->getHeaderLine('Accept')); + if (count($acceptedTypes) === 0) { + // If no Accept header is provided, assume a wildcard + $acceptedTypes = [['mediaType' => '*/*', 'quality' => 1.0]]; } - $contentType = $request->getHeaderLine('Content-Type'); - if (! $this->checkContentType($routeName, $contentType)) { - return $this->unsupportedMediaTypeResponse('Unsupported Media Type'); + $supportedTypes = $this->getConfiguredTypes($routeName, 'Accept'); + if (! $this->isAcceptable($acceptedTypes, $supportedTypes)) { + throw NotAcceptableException::create(Message::notAcceptable($supportedTypes)); } - $response = $handler->handle($request); - - $responseContentType = $response->getHeaderLine('Content-Type'); + $contentTypeHeader = $request->getHeaderLine('Content-Type'); + if (! empty($contentTypeHeader)) { + $contentType = $this->parseContentTypeHeader($contentTypeHeader); + $acceptableContentTypes = $this->getConfiguredTypes($routeName, 'Content-Type'); + if (! $this->isContentTypeSupported($contentType, $acceptableContentTypes)) { + throw UnsupportedMediaTypeException::create(Message::unsupportedMediaType($acceptableContentTypes)); + } + } - if (! $this->validateResponseContentType($responseContentType, $accept)) { - return $this->notAcceptableResponse('Unable to resolve Accept header to a representation'); + $response = $handler->handle($request); + if (! $this->isResponseContentTypeValid($response->getHeaderLine('Content-Type'), $acceptedTypes)) { + throw NotAcceptableException::create('Unable to provide content in any of the accepted formats.'); } return $response; } - public function formatAcceptRequest(string $accept): array + private function parseAcceptHeader(string $header): array { - $accept = array_map( - fn ($item): string => trim((string) strtok($item, ';')), - explode(',', $accept) - ); + if (empty($header)) { + return []; + } - return array_filter($accept); + $types = []; + $parts = explode(',', $header); + + foreach ($parts as $part) { + $part = trim($part); + $quality = 1.0; + $mediaType = $part; + if (str_contains($part, ';')) { + [$mediaType, $parameters] = explode(';', $part, 2); + + $mediaType = trim($mediaType); + + // Extract quality value if present + if (preg_match('/q=([0-9]*\.?[0-9]+)/', $parameters, $matches)) { + $quality = (float) $matches[1]; + } + } + + // Skip empty media types + if (empty($mediaType)) { + continue; + } + + $types[] = [ + 'mediaType' => $mediaType, + 'quality' => $quality, + ]; + } + + // Sort by quality in descending order + usort($types, fn ($a, $b) => $b['quality'] <=> $a['quality']); + + return $types; } - public function checkAccept(string $routeName, array $accept): bool + private function parseContentTypeHeader(string $header): array { - if (in_array('*/*', $accept, true)) { - return true; + if (empty($header)) { + return []; } - $acceptList = $this->config['default']['Accept'] ?? []; - if (! empty($this->config[$routeName]['Accept'])) { - $acceptList = $this->config[$routeName]['Accept'] ?? []; - } + $parts = explode(';', $header); - if (is_array($acceptList)) { - return ! empty(array_intersect($accept, $acceptList)); - } else { - return in_array($acceptList, $accept, true); + $params = []; + for ($i = 1; $i < count($parts); $i++) { + $paramParts = explode('=', $parts[$i], 2); + if (count($paramParts) === 2) { + $params[trim($paramParts[0])] = trim($paramParts[1], ' "\''); + } } + + return [ + 'mediaType' => trim($parts[0]), + 'parameters' => $params, + ]; } - public function checkContentType(string $routeName, string $contentType): bool + private function getConfiguredTypes(string $routeName, string $headerType): array { - if (empty($contentType)) { - return true; + $types = $this->config[self::DEFAULT_HEADERS][$headerType] ?? []; + if (! empty($this->config[$routeName][$headerType])) { + $types = $this->config[$routeName][$headerType]; } - $contentType = explode(';', $contentType); + return is_array($types) ? $types : [$types]; + } - $acceptList = $this->config['default']['Content-Type'] ?? []; - if (! empty($this->config[$routeName]['Content-Type'])) { - $acceptList = $this->config[$routeName]['Content-Type'] ?? []; + private function isAcceptable(array $acceptedTypes, array $supportedTypes): bool + { + foreach ($acceptedTypes as $accept) { + // Wildcard accept + if ($accept['mediaType'] === '*/*') { + return true; + } + + // Type a wildcard like image/* + if (str_ends_with($accept['mediaType'], '/*')) { + $prefix = substr($accept['mediaType'], 0, strpos($accept['mediaType'], '/*')); + foreach ($supportedTypes as $supported) { + if (str_starts_with($supported, $prefix . '/')) { + return true; + } + } + } + + // Direct match + if (in_array($accept['mediaType'], $supportedTypes, true)) { + return true; + } } - if (is_array($acceptList)) { - return ! empty(array_intersect($contentType, $acceptList)); - } else { - return in_array($acceptList, $contentType, true); - } + return false; } - public function validateResponseContentType(?string $contentType, array $accept): bool + private function isContentTypeSupported(array $contentType, array $supportedTypes): bool { - if (in_array('*/*', $accept, true)) { + if (empty($contentType)) { return true; } - if (null === $contentType) { - return false; - } + return in_array($contentType['mediaType'], $supportedTypes, true); + } - $accept = array_map(fn (string $item): string => str_contains($item, 'json') ? 'json' : $item, $accept); + private function isResponseContentTypeValid(?string $responseType, array $acceptedTypes): bool + { + if (empty($responseType)) { + return true; + } - if (str_contains($contentType, 'json')) { - $contentType = 'json'; + // Parse response content type to handle parameters + $parts = explode(';', $responseType); + $mediaType = trim($parts[0]); + + // Check for wildcard accept + foreach ($acceptedTypes as $accept) { + if ($accept['mediaType'] === '*/*') { + return true; + } + + // Type a wildcard like image/* + if (str_ends_with($accept['mediaType'], '/*')) { + $prefix = substr($accept['mediaType'], 0, strpos($accept['mediaType'], '/*')); + if (str_starts_with($mediaType, $prefix . '/')) { + return true; + } + } + + // Handle +json suffix matching + if (str_ends_with($mediaType, '+json') && str_ends_with($accept['mediaType'], '+json')) { + return true; + } + + // Direct match + if ($mediaType === $accept['mediaType']) { + return true; + } } - return in_array($contentType, $accept, true); + return false; } public function notAcceptableResponse(string $message): ResponseInterface diff --git a/src/Core/src/App/src/Message.php b/src/Core/src/App/src/Message.php index 1ab6429..2966971 100644 --- a/src/Core/src/App/src/Message.php +++ b/src/Core/src/App/src/Message.php @@ -4,6 +4,7 @@ namespace Core\App; +use function count; use function implode; use function sprintf; @@ -34,6 +35,7 @@ class Message . 'you will receive an email with further instructions on resetting your account\'s password.'; public const MAIL_SENT_USER_ACTIVATION = 'User activation mail has been successfully sent to "%s"'; public const MISSING_CONFIG = 'Missing configuration value: "%s".'; + public const NOT_ACCEPTABLE = 'Not acceptable.'; public const RESET_PASSWORD_EXPIRED = 'Reset password hash is invalid (expired).'; public const RESET_PASSWORD_NOT_FOUND = 'Reset password request not found.'; public const RESET_PASSWORD_OK = 'Password successfully modified.'; @@ -48,6 +50,7 @@ class Message public const ROLE_NOT_FOUND = 'Role not found.'; public const SERVICE_NOT_FOUND = 'Service %s not found in the container.'; public const SETTING_NOT_FOUND = 'Setting "%s" not found.'; + public const UNSUPPORTED_MEDIA_TYPE = 'Unsupported Media Type.'; public const USER_ACTIVATED = 'User account has been activated.'; public const USER_ALREADY_ACTIVATED = 'User account is already active.'; public const USER_ALREADY_DEACTIVATED = 'User account is already inactive.'; @@ -95,6 +98,15 @@ public static function mailSentUserActivation(string $email): string return sprintf(self::MAIL_SENT_USER_ACTIVATION, $email); } + public static function notAcceptable(array $types = []): string + { + if (count($types) === 0) { + return self::NOT_ACCEPTABLE; + } + + return sprintf('%s Supported types: %s', self::NOT_ACCEPTABLE, implode(', ', $types)); + } + public static function resourceAlreadyRegistered(string $resource): string { return sprintf(self::RESOURCE_ALREADY_REGISTERED, $resource); @@ -125,6 +137,15 @@ public static function settingNotFound(string $identifier): string return sprintf(self::SETTING_NOT_FOUND, $identifier); } + public static function unsupportedMediaType(array $types = []): string + { + if (count($types) === 0) { + return self::UNSUPPORTED_MEDIA_TYPE; + } + + return sprintf('%s Supported types: %s', self::UNSUPPORTED_MEDIA_TYPE, implode(', ', $types)); + } + public static function validatorLengthMax(int $max): string { return sprintf(self::VALIDATOR_LENGTH_MAX, $max); diff --git a/test/Functional/AbstractFunctionalTest.php b/test/Functional/AbstractFunctionalTest.php index 4cd41d7..8a03d8a 100644 --- a/test/Functional/AbstractFunctionalTest.php +++ b/test/Functional/AbstractFunctionalTest.php @@ -158,7 +158,7 @@ protected function get( string $uri, array $queryParams = [], array $uploadedFiles = [], - array $headers = ['Accept' => 'application/json'], + array $headers = ['Accept' => ['application/json, application/hal+json']], array $cookies = [] ): ResponseInterface { $request = $this->createRequest( @@ -179,7 +179,7 @@ protected function post( array $parsedBody = [], array $queryParams = [], array $uploadedFiles = [], - array $headers = ['Accept' => 'application/json'], + array $headers = ['Accept' => ['application/json, application/hal+json']], array $cookies = [] ): ResponseInterface { $request = $this->createRequest( @@ -200,7 +200,7 @@ protected function patch( array $parsedBody = [], array $queryParams = [], array $uploadedFiles = [], - array $headers = ['Accept' => 'application/json'], + array $headers = ['Accept' => ['application/json, application/hal+json']], array $cookies = [] ): ResponseInterface { $request = $this->createRequest( @@ -221,7 +221,7 @@ protected function put( array $parsedBody = [], array $queryParams = [], array $uploadedFiles = [], - array $headers = ['Accept' => 'application/json'], + array $headers = ['Accept' => ['application/json, application/hal+json']], array $cookies = [] ): ResponseInterface { $request = $this->createRequest( @@ -240,7 +240,7 @@ protected function put( protected function delete( string $uri, array $queryParams = [], - array $headers = ['Accept' => 'application/json, text/plain'], + array $headers = ['Accept' => 'application/json, application/hal+json, text/plain'], array $cookies = [] ): ResponseInterface { $request = $this->createRequest( diff --git a/test/Unit/App/Middleware/ContentNegotiationMiddlewareTest.php b/test/Unit/App/Middleware/ContentNegotiationMiddlewareTest.php index d6eb383..08f7e05 100644 --- a/test/Unit/App/Middleware/ContentNegotiationMiddlewareTest.php +++ b/test/Unit/App/Middleware/ContentNegotiationMiddlewareTest.php @@ -4,8 +4,9 @@ namespace ApiTest\Unit\App\Middleware; +use Api\App\Exception\NotAcceptableException; +use Api\App\Exception\UnsupportedMediaTypeException; use Api\App\Middleware\ContentNegotiationMiddleware; -use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\ServerRequest; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; @@ -22,8 +23,6 @@ class ContentNegotiationMiddlewareTest extends TestCase private RequestHandlerInterface $handler; private RouteResult $routeResult; - private const ROUTE_NAME = 'test.route'; - private const CONFIG = [ 'test.route' => [ @@ -58,88 +57,50 @@ protected function setUp(): void $this->contentNegotiationMiddleware = new ContentNegotiationMiddleware(self::CONFIG); } + /** + * @throws UnsupportedMediaTypeException + * @throws NotAcceptableException + */ public function testWrongAccept(): void { - $request = $this->request->withAttribute( - RouteResult::class, - $this->routeResult - ); - $request = $request->withHeader('Accept', 'text/html'); - $this->assertSame( - StatusCodeInterface::STATUS_NOT_ACCEPTABLE, - $this->contentNegotiationMiddleware->process($request, $this->handler)->getStatusCode() - ); - } + $request = $this->request + ->withAttribute(RouteResult::class, $this->routeResult) + ->withHeader('Accept', 'text/html'); - public function testWrongContentType(): void - { - $request = $this->request->withAttribute( - RouteResult::class, - $this->routeResult - ); - $request = $request->withHeader('Accept', 'application/hal+json'); - $request = $request->withHeader('Content-Type', 'text/html'); - $this->assertSame( - StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE, - $this->contentNegotiationMiddleware->process($request, $this->handler)->getStatusCode() - ); - } + $this->expectException(NotAcceptableException::class); - public function testCannotResolveRepresentation(): void - { - $request = $this->request->withAttribute( - RouteResult::class, - $this->routeResult - ); - $request = $request->withHeader('Accept', 'application/json'); - $request = $request->withHeader('Content-Type', 'application/json'); - $this->assertSame( - StatusCodeInterface::STATUS_NOT_ACCEPTABLE, - $this->contentNegotiationMiddleware->process($request, $this->handler)->getStatusCode() - ); + $this->contentNegotiationMiddleware->process($request, $this->handler); } - public function testFormatAcceptRequest(): void + /** + * @throws NotAcceptableException + * @throws UnsupportedMediaTypeException + */ + public function testWrongContentType(): void { - $accept = $this->contentNegotiationMiddleware->formatAcceptRequest('application/json'); + $request = $this->request + ->withAttribute(RouteResult::class, $this->routeResult) + ->withHeader('Accept', 'application/hal+json') + ->withHeader('Content-Type', 'text/html'); - $this->assertNotEmpty($accept); - $this->assertSame(['application/json'], $accept); - } + $this->expectException(UnsupportedMediaTypeException::class); - public function testCheckAccept(): void - { - $this->assertTrue( - $this->contentNegotiationMiddleware->checkAccept( - self::ROUTE_NAME, - ['*/*'] - ) - ); - $this->assertTrue( - $this->contentNegotiationMiddleware->checkAccept( - self::ROUTE_NAME, - ['application/json'] - ) - ); - $this->assertFalse( - $this->contentNegotiationMiddleware->checkAccept(self::ROUTE_NAME, ['text/html']) - ); + $this->contentNegotiationMiddleware->process($request, $this->handler); } - public function testCheckContentType(): void + /** + * @throws NotAcceptableException + * @throws UnsupportedMediaTypeException + */ + public function testCannotResolveRepresentation(): void { - $this->assertTrue( - $this->contentNegotiationMiddleware->checkContentType(self::ROUTE_NAME, '') - ); + $request = $this->request + ->withAttribute(RouteResult::class, $this->routeResult) + ->withHeader('Accept', 'text/html') + ->withHeader('Content-Type', 'application/json'); - $this->assertTrue( - $this->contentNegotiationMiddleware->checkContentType( - self::ROUTE_NAME, - 'application/json' - ) - ); - $this->assertFalse( - $this->contentNegotiationMiddleware->checkContentType(self::ROUTE_NAME, 'text/html') - ); + $this->expectException(NotAcceptableException::class); + + $this->contentNegotiationMiddleware->process($request, $this->handler); } }