From 8b37d47616a52f521dca0c319cf10fe39621b081 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 1 Sep 2021 17:56:58 +1000 Subject: [PATCH] Implement content negotiation --- composer.json | 3 +- src/Http/MediaTypes.php | 65 ------- src/JsonApi.php | 182 +++++++++++------- src/functions.php | 2 +- .../specification/ContentNegotiationTest.php | 70 ++++--- tests/unit/Http/MediaTypesTest.php | 72 ------- 6 files changed, 162 insertions(+), 232 deletions(-) delete mode 100644 src/Http/MediaTypes.php delete mode 100644 tests/unit/Http/MediaTypesTest.php diff --git a/composer.json b/composer.json index 6f96d1b..54f1b03 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "json-api-php/json-api": "^2.2", "nyholm/psr7": "^1.3", "psr/http-message": "^1.0", - "psr/http-server-handler": "^1.0" + "psr/http-server-handler": "^1.0", + "xynha/http-accept": "dev-master" }, "license": "MIT", "authors": [ diff --git a/src/Http/MediaTypes.php b/src/Http/MediaTypes.php deleted file mode 100644 index fc67a8b..0000000 --- a/src/Http/MediaTypes.php +++ /dev/null @@ -1,65 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobyz\JsonApiServer\Http; - -class MediaTypes -{ - private $value; - - public function __construct(string $value) - { - $this->value = $value; - } - - /** - * Determine whether the list contains the given type without modifications - * - * This is meant to ease implementation of JSON:API rules for content - * negotiation, which demand HTTP error responses e.g. when all of the - * JSON:API media types in the "Accept" header are modified with "media type - * parameters". Therefore, this method only returns true when the requested - * media type is contained without additional parameters (except for the - * weight parameter "q" and "Accept extension parameters"). - * - * @param string $mediaType - * @return bool - */ - public function containsExactly(string $mediaType): bool - { - $types = array_map('trim', explode(',', $this->value)); - - // Accept headers can contain multiple media types, so we need to check - // whether any of them matches. - foreach ($types as $type) { - $parts = array_map('trim', explode(';', $type)); - - // The actual media type needs to be an exact match - if (array_shift($parts) !== $mediaType) { - continue; - } - - // The media type can optionally be followed by "media type - // parameters". Parameters after the "q" parameter are considered - // "Accept extension parameters", which we don't care about. Thus, - // we have an exact match if there are no parameters at all or if - // the first one is named "q". - // See https://tools.ietf.org/html/rfc7231#section-5.3.2. - if (empty($parts) || substr($parts[0], 0, 2) === 'q=') { - return true; - } - - continue; - } - - return false; - } -} diff --git a/src/JsonApi.php b/src/JsonApi.php index 12c9c23..a60c759 100644 --- a/src/JsonApi.php +++ b/src/JsonApi.php @@ -26,6 +26,7 @@ use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; use Tobyz\JsonApiServer\Extension\Extension; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; +use Xynha\HttpAccept\AcceptParser; final class JsonApi implements RequestHandlerInterface { @@ -113,33 +114,52 @@ final class JsonApi implements RequestHandlerInterface */ public function handle(Request $request): Response { - // $this->validateRequest($request); - $this->validateQueryParameters($request); $context = new Context($this, $request); - foreach ($this->extensions as $extension) { + $response = $this->runExtensions($context); + + if (! $response) { + $response = $this->route($context); + } + + return $response->withAddedHeader('Vary', 'Accept'); + } + + private function runExtensions(Context $context): ?Response + { + $request = $context->getRequest(); + + $contentTypeExtensionUris = $this->getContentTypeExtensionUris($request); + $acceptableExtensionUris = $this->getAcceptableExtensionUris($request); + + $activeExtensions = array_intersect_key( + $this->extensions, + array_flip($contentTypeExtensionUris), + array_flip($acceptableExtensionUris) + ); + + foreach ($activeExtensions as $extension) { if ($response = $extension->handle($context)) { - return $response; + return $response->withHeader('Content-Type', self::MEDIA_TYPE.'; ext='.$extension->uri()); } } - // TODO: apply Vary: Accept header to response + return null; + } - $path = $this->stripBasePath( - $request->getUri()->getPath() - ); - - $segments = explode('/', trim($path, '/')); + private function route(Context $context): Response + { + $segments = explode('/', trim($context->getPath(), '/')); $resourceType = $this->getResourceType($segments[0]); switch (count($segments)) { case 1: - return $this->handleCollection($context, $resourceType); + return $this->routeCollection($context, $resourceType); case 2: - return $this->handleResource($context, $resourceType, $segments[1]); + return $this->routeResource($context, $resourceType, $segments[1]); case 3: throw new NotImplementedException(); @@ -165,63 +185,7 @@ final class JsonApi implements RequestHandlerInterface } } - private function validateRequest(Request $request): void - { - // TODO - - // split content type - // ensure type is json-api - // ensure no params other than ext/profile - // ensure no ext other than those supported - // return list of ext/profiles to apply - - if ($accept = $request->getHeaderLine('Accept')) { - $types = array_map('trim', explode(',', $accept)); - - foreach ($types as $type) { - $parts = array_map('trim', explode(';', $type)); - } - } - - // if accept present - // split accept, order by qvalue - // for each media type: - // if type is not json-api, continue - // if any params other than ext/profile, continue - // if any ext other than those supported, continue - // return list of ext/profiles to apply - // if none matching, Not Acceptable - } - - // private function validateRequestContentType(Request $request): void - // { - // $header = $request->getHeaderLine('Content-Type'); - // - // if ((new MediaTypes($header))->containsWithOptionalParameters(self::MEDIA_TYPE, ['ext'])) { - // return; - // } - // - // throw new UnsupportedMediaTypeException; - // } - // - // private function getAcceptedParameters(Request $request): array - // { - // $header = $request->getHeaderLine('Accept'); - // - // if (empty($header)) { - // return []; - // } - // - // $mediaTypes = new MediaTypes($header); - // - // if ($parameters = $mediaTypes->get(self::MEDIA_TYPE, ['ext', 'profile'])) { - // return $parameters; - // } - // - // throw new NotAcceptableException; - // } - - private function handleCollection(Context $context, ResourceType $resourceType): Response + private function routeCollection(Context $context, ResourceType $resourceType): Response { switch ($context->getRequest()->getMethod()) { case 'GET': @@ -235,9 +199,9 @@ final class JsonApi implements RequestHandlerInterface } } - private function handleResource(Context $context, ResourceType $resourceType, string $id): Response + private function routeResource(Context $context, ResourceType $resourceType, string $resourceId): Response { - $model = $this->findResource($resourceType, $id, $context); + $model = $this->findResource($resourceType, $resourceId, $context); switch ($context->getRequest()->getMethod()) { case 'PATCH': @@ -254,6 +218,82 @@ final class JsonApi implements RequestHandlerInterface } } + private function getContentTypeExtensionUris(Request $request): array + { + if (! $contentType = $request->getHeaderLine('Content-Type')) { + return []; + } + + $mediaList = (new AcceptParser())->parse($contentType); + + if ($mediaList->count() > 1) { + throw new UnsupportedMediaTypeException(); + } + + $mediaType = $mediaList->preferredMedia(0); + + if ($mediaType->mimetype() !== JsonApi::MEDIA_TYPE) { + throw new UnsupportedMediaTypeException(); + } + + $parameters = $this->parseParameters($mediaType->parameters()); + + if (! empty(array_diff(array_keys($parameters), ['ext', 'profile']))) { + throw new UnsupportedMediaTypeException(); + } + + $extensionUris = isset($parameters['ext']) ? explode(' ', $parameters['ext']) : []; + + if (! empty(array_diff($extensionUris, array_keys($this->extensions)))) { + throw new UnsupportedMediaTypeException(); + } + + return $extensionUris; + } + + private function getAcceptableExtensionUris(Request $request): array + { + if (! $accept = $request->getHeaderLine('Accept')) { + return []; + } + + $mediaList = (new AcceptParser())->parse($accept); + $count = $mediaList->count(); + + for ($i = 0; $i < $count; $i++) { + $mediaType = $mediaList->preferredMedia($i); + + if (! in_array($mediaType->mimetype(), [JsonApi::MEDIA_TYPE, '*/*'])) { + continue; + } + + $parameters = $this->parseParameters($mediaType->parameters()); + + if (! empty(array_diff(array_keys($parameters), ['ext', 'profile']))) { + continue; + } + + $extensionUris = isset($parameters['ext']) ? explode(' ', $parameters['ext']) : []; + + if (! empty(array_diff($extensionUris, array_keys($this->extensions)))) { + continue; + } + + return $extensionUris; + } + + throw new NotAcceptableException(); + } + + private function parseParameters(array $parameters): array + { + return array_reduce($parameters, function ($a, $v) { + $parts = explode('=', $v, 2); + $a[$parts[0]] = trim($parts[1], '"'); + return $a; + }, []); + } + /** * Convert an exception into a JSON:API error document response. * diff --git a/src/functions.php b/src/functions.php index c07a73b..dbe3f23 100644 --- a/src/functions.php +++ b/src/functions.php @@ -19,7 +19,7 @@ use Tobyz\JsonApiServer\Schema\Field; function json_api_response($document, int $status = 200): Response { return (new Response($status)) - ->withHeader('content-type', JsonApi::MEDIA_TYPE) + ->withHeader('Content-Type', JsonApi::MEDIA_TYPE) ->withBody(Stream::create(json_encode($document, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES))); } diff --git a/tests/specification/ContentNegotiationTest.php b/tests/specification/ContentNegotiationTest.php index 93a77c5..afbb0d8 100644 --- a/tests/specification/ContentNegotiationTest.php +++ b/tests/specification/ContentNegotiationTest.php @@ -11,15 +11,14 @@ namespace Tobyz\Tests\JsonApiServer\specification; -use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Exception\NotAcceptableException; use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; -use Tobyz\JsonApiServer\Schema\Type; +use Tobyz\JsonApiServer\JsonApi; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockAdapter; /** - * @see https://jsonapi.org/format/#content-negotiation + * @see https://jsonapi.org/format/1.1/#content-negotiation */ class ContentNegotiationTest extends AbstractTestCase { @@ -31,9 +30,7 @@ class ContentNegotiationTest extends AbstractTestCase public function setUp(): void { $this->api = new JsonApi('http://example.com'); - $this->api->resourceType('users', new MockAdapter(), function (Type $type) { - // no fields - }); + $this->api->resourceType('users', new MockAdapter()); } public function test_json_api_content_type_is_returned() @@ -48,36 +45,36 @@ class ContentNegotiationTest extends AbstractTestCase ); } - public function test_error_when_request_content_type_has_parameters() + public function test_success_when_request_content_type_contains_profile() + { + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ->withHeader('Accept', 'application/vnd.api+json; profile="http://example.com/profile"') + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + public function test_error_when_request_content_type_contains_unknown_parameter() { $request = $this->buildRequest('PATCH', '/users/1') - ->withHeader('Content-Type', 'application/vnd.api+json;profile="http://example.com/last-modified"'); + ->withHeader('Content-Type', 'application/vnd.api+json; unknown="parameter"'); $this->expectException(UnsupportedMediaTypeException::class); $this->api->handle($request); } - public function test_error_when_all_accepts_have_parameters() + public function test_error_when_request_content_type_contains_unsupported_extension() { - $request = $this->buildRequest('GET', '/users/1') - ->withHeader('Accept', 'application/vnd.api+json;profile="http://example.com/last-modified", application/vnd.api+json;profile="http://example.com/versioning"'); + $request = $this->buildRequest('PATCH', '/users/1') + ->withHeader('Content-Type', 'application/vnd.api+json; ext="http://example.com/extension"'); - $this->expectException(NotAcceptableException::class); + $this->expectException(UnsupportedMediaTypeException::class); $this->api->handle($request); } - public function test_success_when_only_some_accepts_have_parameters() - { - $response = $this->api->handle( - $this->buildRequest('GET', '/users/1') - ->withHeader('Accept', 'application/vnd.api+json;profile="http://example.com/last-modified", application/vnd.api+json') - ); - - $this->assertEquals(200, $response->getStatusCode()); - } - public function test_success_when_accepts_wildcard() { $response = $this->api->handle( @@ -87,4 +84,33 @@ class ContentNegotiationTest extends AbstractTestCase $this->assertEquals(200, $response->getStatusCode()); } + + public function test_error_when_all_accepts_have_unknown_parameters() + { + $request = $this->buildRequest('GET', '/users/1') + ->withHeader('Accept', 'application/vnd.api+json; unknown="parameter", application/vnd.api+json; unknown="parameter2"'); + + $this->expectException(NotAcceptableException::class); + + $this->api->handle($request); + } + + public function test_success_when_only_some_accepts_have_parameters() + { + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ->withHeader('Accept', 'application/vnd.api+json; unknown="parameter", application/vnd.api+json') + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + public function test_responds_with_vary_header() + { + $response = $this->api->handle( + $this->buildRequest('GET', '/users/1') + ); + + $this->assertEquals('Accept', $response->getHeaderLine('vary')); + } } diff --git a/tests/unit/Http/MediaTypesTest.php b/tests/unit/Http/MediaTypesTest.php deleted file mode 100644 index b4b9bcb..0000000 --- a/tests/unit/Http/MediaTypesTest.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobyz\Tests\JsonApiServer\unit\Http; - -use PHPUnit\Framework\TestCase; -use Tobyz\JsonApiServer\Http\MediaTypes; - -class MediaTypesTest extends TestCase -{ - public function test_contains_on_exact_match() - { - $header = new MediaTypes('application/json'); - - $this->assertTrue( - $header->containsExactly('application/json') - ); - } - - public function test_contains_does_not_match_with_extra_parameters() - { - $header = new MediaTypes('application/json; profile=foo'); - - $this->assertFalse( - $header->containsExactly('application/json') - ); - } - - public function test_contains_matches_when_only_weight_is_provided() - { - $header = new MediaTypes('application/json; q=0.8'); - - $this->assertTrue( - $header->containsExactly('application/json') - ); - } - - public function test_contains_does_not_match_with_extra_parameters_before_weight() - { - $header = new MediaTypes('application/json; profile=foo; q=0.8'); - - $this->assertFalse( - $header->containsExactly('application/json') - ); - } - - public function test_contains_matches_with_extra_parameters_after_weight() - { - $header = new MediaTypes('application/json; q=0.8; profile=foo'); - - $this->assertTrue( - $header->containsExactly('application/json') - ); - } - - public function test_contains_matches_when_one_of_multiple_media_types_is_valid() - { - $header = new MediaTypes('application/json; profile=foo, application/json; q=0.6'); - - $this->assertTrue( - $header->containsExactly('application/json') - ); - } -}