Implement content negotiation
This commit is contained in:
parent
c1dc91c558
commit
8b37d47616
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
182
src/JsonApi.php
182
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue