Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions config/autoload/content-negotiation.global.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -27,7 +28,7 @@
'multipart/form-data',
],
],
'user::create-user-avatar' => [
'user::create-user-avatar' => [
'Accept' => [
'application/json',
'application/hal+json',
Expand Down
28 changes: 28 additions & 0 deletions src/App/src/Exception/NotAcceptableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Api\App\Exception;

use Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;

class NotAcceptableException extends Exception implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;

public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self
{
$exception = new self();

$exception->type = $type;
$exception->detail = $detail;
$exception->status = StatusCodeInterface::STATUS_NOT_ACCEPTABLE;
$exception->title = $title;
$exception->additional = $additional;

return $exception;
}
}
28 changes: 28 additions & 0 deletions src/App/src/Exception/UnsupportedMediaTypeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Api\App\Exception;

use Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;

class UnsupportedMediaTypeException extends Exception implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;

public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self
{
$exception = new self();

$exception->type = $type;
$exception->detail = $detail;
$exception->status = StatusCodeInterface::STATUS_UNSUPPORTED_MEDIA_TYPE;
$exception->title = $title;
$exception->additional = $additional;

return $exception;
}
}
210 changes: 155 additions & 55 deletions src/App/src/Middleware/ContentNegotiationMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading