Implement content negotiation

This commit is contained in:
Toby Zerner 2021-09-01 17:56:58 +10:00
parent c1dc91c558
commit 8b37d47616
6 changed files with 162 additions and 232 deletions

View File

@ -8,7 +8,8 @@
"json-api-php/json-api": "^2.2", "json-api-php/json-api": "^2.2",
"nyholm/psr7": "^1.3", "nyholm/psr7": "^1.3",
"psr/http-message": "^1.0", "psr/http-message": "^1.0",
"psr/http-server-handler": "^1.0" "psr/http-server-handler": "^1.0",
"xynha/http-accept": "dev-master"
}, },
"license": "MIT", "license": "MIT",
"authors": [ "authors": [

View File

@ -1,65 +0,0 @@
<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}

View File

@ -26,6 +26,7 @@ use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
use Tobyz\JsonApiServer\Extension\Extension; use Tobyz\JsonApiServer\Extension\Extension;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use Xynha\HttpAccept\AcceptParser;
final class JsonApi implements RequestHandlerInterface final class JsonApi implements RequestHandlerInterface
{ {
@ -113,33 +114,52 @@ final class JsonApi implements RequestHandlerInterface
*/ */
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
// $this->validateRequest($request);
$this->validateQueryParameters($request); $this->validateQueryParameters($request);
$context = new Context($this, $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)) { 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( private function route(Context $context): Response
$request->getUri()->getPath() {
); $segments = explode('/', trim($context->getPath(), '/'));
$segments = explode('/', trim($path, '/'));
$resourceType = $this->getResourceType($segments[0]); $resourceType = $this->getResourceType($segments[0]);
switch (count($segments)) { switch (count($segments)) {
case 1: case 1:
return $this->handleCollection($context, $resourceType); return $this->routeCollection($context, $resourceType);
case 2: case 2:
return $this->handleResource($context, $resourceType, $segments[1]); return $this->routeResource($context, $resourceType, $segments[1]);
case 3: case 3:
throw new NotImplementedException(); throw new NotImplementedException();
@ -165,63 +185,7 @@ final class JsonApi implements RequestHandlerInterface
} }
} }
private function validateRequest(Request $request): void private function routeCollection(Context $context, ResourceType $resourceType): Response
{
// 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
{ {
switch ($context->getRequest()->getMethod()) { switch ($context->getRequest()->getMethod()) {
case 'GET': 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()) { switch ($context->getRequest()->getMethod()) {
case 'PATCH': 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. * Convert an exception into a JSON:API error document response.
* *

View File

@ -19,7 +19,7 @@ use Tobyz\JsonApiServer\Schema\Field;
function json_api_response($document, int $status = 200): Response function json_api_response($document, int $status = 200): Response
{ {
return (new Response($status)) 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))); ->withBody(Stream::create(json_encode($document, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES)));
} }

View File

@ -11,15 +11,14 @@
namespace Tobyz\Tests\JsonApiServer\specification; namespace Tobyz\Tests\JsonApiServer\specification;
use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\Exception\NotAcceptableException; use Tobyz\JsonApiServer\Exception\NotAcceptableException;
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException; use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
use Tobyz\JsonApiServer\Schema\Type; use Tobyz\JsonApiServer\JsonApi;
use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\AbstractTestCase;
use Tobyz\Tests\JsonApiServer\MockAdapter; 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 class ContentNegotiationTest extends AbstractTestCase
{ {
@ -31,9 +30,7 @@ class ContentNegotiationTest extends AbstractTestCase
public function setUp(): void public function setUp(): void
{ {
$this->api = new JsonApi('http://example.com'); $this->api = new JsonApi('http://example.com');
$this->api->resourceType('users', new MockAdapter(), function (Type $type) { $this->api->resourceType('users', new MockAdapter());
// no fields
});
} }
public function test_json_api_content_type_is_returned() 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') $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->expectException(UnsupportedMediaTypeException::class);
$this->api->handle($request); $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') $request = $this->buildRequest('PATCH', '/users/1')
->withHeader('Accept', 'application/vnd.api+json;profile="http://example.com/last-modified", application/vnd.api+json;profile="http://example.com/versioning"'); ->withHeader('Content-Type', 'application/vnd.api+json; ext="http://example.com/extension"');
$this->expectException(NotAcceptableException::class); $this->expectException(UnsupportedMediaTypeException::class);
$this->api->handle($request); $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() public function test_success_when_accepts_wildcard()
{ {
$response = $this->api->handle( $response = $this->api->handle(
@ -87,4 +84,33 @@ class ContentNegotiationTest extends AbstractTestCase
$this->assertEquals(200, $response->getStatusCode()); $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'));
}
} }

View File

@ -1,72 +0,0 @@
<?php
/*
* This file is part of JSON-API.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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')
);
}
}