Skip to content

Commit

Permalink
Merge pull request #261 from dotkernel/issue-259
Browse files Browse the repository at this point in the history
Implement content-negociation
  • Loading branch information
arhimede authored May 27, 2024
2 parents 5c5624f + baeab52 commit eeff618
Show file tree
Hide file tree
Showing 6 changed files with 342 additions and 9 deletions.
22 changes: 22 additions & 0 deletions config/autoload/content-negotiation.global.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

return [
'content-negotiation' => [
'default' => [ // default to any route if not configured above
'Accept' => [
'application/json',
'application/hal+json',
],
'Content-Type' => [
'application/json',
'application/hal+json',
],
],
'your.route.name' => [
'Accept' => [],
'Content-Type' => [],
],
],
];
12 changes: 8 additions & 4 deletions config/pipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Api\App\Handler\NotFoundHandler;
use Api\App\Middleware\AuthenticationMiddleware;
use Api\App\Middleware\AuthorizationMiddleware;
use Api\App\Middleware\ContentNegotiationMiddleware;
use Dot\ErrorHandler\ErrorHandlerInterface;
use Dot\ResponseHeader\Middleware\ResponseHeaderMiddleware;
use Mezzio\Application;

Check warning on line 11 in config/pipeline.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'Application'
Expand Down Expand Up @@ -48,9 +49,6 @@
// Register the routing middleware in the middleware pipeline.
// This middleware registers the Mezzio\Router\RouteResult request attribute.
$app->pipe(RouteMiddleware::class);
$app->pipe(ResponseHeaderMiddleware::class);
$app->pipe(AuthenticationMiddleware::class);
$app->pipe(AuthorizationMiddleware::class);

// The following handle routing failures for common conditions:
// - HEAD request but no routes answer that method
Expand All @@ -62,9 +60,16 @@
$app->pipe(ImplicitOptionsMiddleware::class);

Check warning on line 60 in config/pipeline.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ImplicitOptionsMiddleware'
$app->pipe(MethodNotAllowedMiddleware::class);

Check warning on line 61 in config/pipeline.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'MethodNotAllowedMiddleware'

$app->pipe(ContentNegotiationMiddleware::class);

$app->pipe(ResponseHeaderMiddleware::class);

Check warning on line 65 in config/pipeline.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ResponseHeaderMiddleware'

// Seed the UrlHelper with the routing results:
$app->pipe(UrlHelperMiddleware::class);

Check warning on line 68 in config/pipeline.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'UrlHelperMiddleware'

$app->pipe(AuthenticationMiddleware::class);
$app->pipe(AuthorizationMiddleware::class);

// Add more middleware here that needs to introspect the routing results; this
// might include:
//
Expand All @@ -74,7 +79,6 @@

// Register the dispatch middleware in the middleware pipeline
$app->pipe(DispatchMiddleware::class);

// At this point, if no Response is returned by any middleware, the
// NotFoundHandler kicks in; alternately, you can provide other fallback
// middleware to execute.
Expand Down
2 changes: 2 additions & 0 deletions src/App/src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Api\App\Factory\TokenGenerateCommandFactory;
use Api\App\Middleware\AuthenticationMiddleware;
use Api\App\Middleware\AuthorizationMiddleware;
use Api\App\Middleware\ContentNegotiationMiddleware;
use Api\App\Middleware\ErrorResponseMiddleware;
use Api\App\Service\ErrorReportService;
use Api\App\Service\ErrorReportServiceInterface;
Expand Down Expand Up @@ -63,6 +64,7 @@ public function getDependencies(): array
'dot-mail.service.default' => MailServiceAbstractFactory::class,
AuthenticationMiddleware::class => AuthenticationMiddlewareFactory::class,
AuthorizationMiddleware::class => AnnotatedServiceFactory::class,

Check warning on line 66 in src/App/src/ConfigProvider.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'AnnotatedServiceFactory'
ContentNegotiationMiddleware::class => AnnotatedServiceFactory::class,
Environment::class => TwigEnvironmentFactory::class,

Check warning on line 68 in src/App/src/ConfigProvider.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'TwigEnvironmentFactory'
TwigExtension::class => TwigExtensionFactory::class,

Check warning on line 69 in src/App/src/ConfigProvider.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'TwigExtensionFactory'
TwigRenderer::class => TwigRendererFactory::class,

Check warning on line 70 in src/App/src/ConfigProvider.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'TwigRenderer'
Expand Down
161 changes: 161 additions & 0 deletions src/App/src/Middleware/ContentNegotiationMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

namespace Api\App\Middleware;

use Dot\AnnotatedServices\Annotation\Inject;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Http\Response;
use Mezzio\Router\RouteResult;

Check warning on line 10 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RouteResult'
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

Check warning on line 12 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ServerRequestInterface'
use Psr\Http\Server\MiddlewareInterface;

Check warning on line 13 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'MiddlewareInterface'
use Psr\Http\Server\RequestHandlerInterface;

Check warning on line 14 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RequestHandlerInterface'

use function array_filter;
use function array_intersect;
use function array_map;
use function explode;
use function in_array;
use function is_array;
use function str_contains;
use function strtok;
use function trim;

class ContentNegotiationMiddleware implements MiddlewareInterface

Check warning on line 26 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'MiddlewareInterface'
{
/**
* @Inject({"config.content-negotiation"})
*/
public function __construct(private array $config)

Check notice on line 31 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Property can be 'readonly'

Property can be 'readonly'
{
}

public function process(
ServerRequestInterface $request,

Check warning on line 36 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ServerRequestInterface'
RequestHandlerInterface $handler
): ResponseInterface {
$routeResult = $request->getAttribute(RouteResult::class);

Check warning on line 39 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RouteResult'
if (! $routeResult instanceof RouteResult || $routeResult->isFailure()) {
return $handler->handle($request);
}

$routeName = (string) $routeResult->getMatchedRouteName();

$accept = $this->formatAcceptRequest($request->getHeaderLine('Accept'));
if (! $this->checkAccept($routeName, $accept)) {
return $this->notAcceptedResponse('Not Acceptable');

Check warning on line 48 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Incompatible return type

Return value type is not compatible with declared
}

$contentType = $request->getHeaderLine('Content-Type');
if (! $this->checkContentType($routeName, $contentType)) {
return $this->unsupportedMediaTypeResponse(

Check warning on line 53 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Incompatible return type

Return value type is not compatible with declared
'Unsupported Media Type'
);
}

$response = $handler->handle($request);

$responseContentType = $response->getHeaderLine('Content-Type');
if (
! $this->validateResponseContentType(
$responseContentType,
$accept
)
) {
return $this->notAcceptedResponse(

Check warning on line 67 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Incompatible return type

Return value type is not compatible with declared
'Unable to resolve Accept header to a representation'
);
}

return $response;
}

public function formatAcceptRequest(string $accept): array
{
$accept = array_map(function ($item) {
return trim(strtok($item, ';'));
}, explode(',', $accept));

return array_filter($accept);
}

public function checkAccept(string $routeName, array $accept): bool
{
if (in_array('*/*', $accept, true)) {
return true;
}

$acceptList = $this->config['default']['Accept'] ?? [];
if (isset($this->config[$routeName])) {
$acceptList = $this->config[$routeName]['Accept'] ?? [];
}

if (is_array($acceptList)) {
return ! empty(array_intersect($accept, $acceptList));
} else {
return in_array($acceptList, $accept, true);
}
}

public function checkContentType(string $routeName, string $contentType): bool
{
if (empty($contentType)) {
return true;
}
$acceptList = $this->config['default']['Content-Type'] ?? [];
if (isset($this->config[$routeName])) {
$acceptList = $this->config[$routeName]['Content-Type'] ?? [];
}

if (is_array($acceptList)) {
return in_array($contentType, $acceptList, true);
} else {
return $contentType === $acceptList;
}
}

public function notAcceptedResponse(string $message): JsonResponse

Check warning on line 119 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'JsonResponse'
{
return new JsonResponse([

Check warning on line 121 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'JsonResponse'
'error' => [
'messages' => [
$message,
],
],
], Response::STATUS_CODE_406);
}

public function unsupportedMediaTypeResponse(string $message): JsonResponse

Check warning on line 130 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'JsonResponse'
{
return new JsonResponse([

Check warning on line 132 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'JsonResponse'
'error' => [
'messages' => [
$message,
],
],
], Response::STATUS_CODE_415);

Check warning on line 138 in src/App/src/Middleware/ContentNegotiationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'Response'
}

public function validateResponseContentType(?string $contentType, array $accept): bool
{
if (in_array('*/*', $accept, true)) {
return true;
}

if (null === $contentType) {
return false;
}

$accept = array_map(function ($item) {
return str_contains($item, 'json') ? 'json' : $item;
}, $accept);

if (str_contains($contentType, 'json')) {
$contentType = 'json';
}

return in_array($contentType, $accept, true);
}
}
10 changes: 5 additions & 5 deletions test/Functional/AbstractFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ protected function get(
string $uri,
array $queryParams = [],
array $uploadedFiles = [],
array $headers = [],
array $headers = ['Accept' => 'application/json'],
array $cookies = []
): ResponseInterface {
$request = $this->createRequest(
Expand All @@ -180,7 +180,7 @@ protected function post(
array $parsedBody = [],
array $queryParams = [],
array $uploadedFiles = [],
array $headers = [],
array $headers = ['Accept' => 'application/json'],
array $cookies = []
): ResponseInterface {

Check warning on line 185 in test/Functional/AbstractFunctionalTest.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ResponseInterface'
$request = $this->createRequest(
Expand All @@ -201,7 +201,7 @@ protected function patch(
array $parsedBody = [],
array $queryParams = [],
array $uploadedFiles = [],
array $headers = [],
array $headers = ['Accept' => 'application/json'],
array $cookies = []
): ResponseInterface {
$request = $this->createRequest(
Expand All @@ -222,7 +222,7 @@ protected function put(
array $parsedBody = [],
array $queryParams = [],
array $uploadedFiles = [],
array $headers = [],
array $headers = ['Accept' => 'application/json'],
array $cookies = []
): ResponseInterface {
$request = $this->createRequest(
Expand All @@ -241,7 +241,7 @@ protected function put(
protected function delete(
string $uri,
array $queryParams = [],
array $headers = [],
array $headers = ['Accept' => 'application/json'],
array $cookies = []
): ResponseInterface {
$request = $this->createRequest(
Expand Down
Loading

0 comments on commit eeff618

Please sign in to comment.