src/StackMiddleware/FormatSetter.php | 97 ++++++++++++++++++++++++++++++- tests/src/Functional/ResourceTestBase.php | 19 ++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/StackMiddleware/FormatSetter.php b/src/StackMiddleware/FormatSetter.php index a5c4d30..6dec6f5 100644 --- a/src/StackMiddleware/FormatSetter.php +++ b/src/StackMiddleware/FormatSetter.php @@ -2,9 +2,14 @@ namespace Drupal\jsonapi\StackMiddleware; +use Drupal\Component\Serialization\Json; use Drupal\jsonapi\JsonApiSpec; +use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer; use Drupal\jsonapi\Routing\Routes; +use Symfony\Component\HttpFoundation\AcceptHeader; +use Symfony\Component\HttpFoundation\AcceptHeaderItem; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; /** @@ -37,6 +42,30 @@ class FormatSetter implements HttpKernelInterface { public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) { if ($this->isJsonApiRequest($request)) { $request->setRequestFormat('api_json'); + // 400 if Accept header is present but requests unsupported profiles. + if (static::requestHasJsonApiAcceptHeader($request)) { + $unsupported_profiles = static::getUnsupportedProfiles($request); + if (!empty($unsupported_profiles)) { + // This duplicates/hardcodes some of the logic of + // \Drupal\jsonapi\Normalizer\HttpExceptionNormalizer, to avoid + // needing to initialize many services in this middleware. + return new Response(Json::encode([ + 'errors' => [ + [ + 'title' => 'Bad request', + 'status' => 400, + 'detail' => sprintf('The following requested profiles are not supported: %s.', implode(', ', $unsupported_profiles)), + 'links' => [ + 'info' => HttpExceptionNormalizer::getInfoUrl(400), + ], + 'code' => 0, + ] + ], + ]), 400, [ + 'Content-Type' => JsonApiSpec::MIME_TYPE_INCLUDING_PROFILE, + ]); + } + } } return $this->httpKernel->handle($request, $type, $catch); @@ -63,12 +92,74 @@ class FormatSetter implements HttpKernelInterface { // API route (but may not have because of an incorrect parameter or minor // typo). $jsonapi_route_intended = strpos($request->getPathInfo(), "/jsonapi/") !== FALSE; + return $is_jsonapi_route || ($jsonapi_route_intended && static::requestHasJsonApiAcceptHeader($request)); + } + + /** + * Checks if the 'Accept' header includes the JSON API MIME type. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * + * @return bool + * Has at least one JSON API Accept header value. + */ + protected static function requestHasJsonApiAcceptHeader(Request $request) { // Check if the 'Accept' header includes the JSON API MIME type. - $request_has_jsonapi_media_type = count(array_filter($request->getAcceptableContentTypes(), function ($accept) { + return count(array_filter($request->getAcceptableContentTypes(), function ($accept) { return strpos($accept, JsonApiSpec::MIME_TYPE) === 0; - })); - return $is_jsonapi_route || ($jsonapi_route_intended && $request_has_jsonapi_media_type); + } + + protected static function getUnsupportedProfiles(Request $request) { + $accept_items = static::acceptHeaderfromString($request->headers->get('Accept')); + $supported_profiles = static::parseProfileParameter(static::acceptHeaderfromString(JsonApiSpec::MIME_TYPE_INCLUDING_PROFILE)->first()); + foreach ($accept_items->all() as $accept_item) { + if ($accept_item->getValue() === JsonApiSpec::MIME_TYPE) { + $acceptable_profiles = static::parseProfileParameter($accept_item); + $unsupported_profiles = array_diff($acceptable_profiles, $supported_profiles); + if (!empty($unsupported_profiles)) { + return $unsupported_profiles; + } + } + } + return []; + } + + /** + * Identical to Symfony's implementation, except for the regexp. + * + * @param string $header_value + * An Accept header value to parse. + * + * @return \Symfony\Component\HttpFoundation\AcceptHeader + * The corresponding AcceptHeader value object. + * + * @see \Symfony\Component\HttpFoundation\AcceptHeader::fromString() + */ + protected static function acceptHeaderfromString($header_value) { + $index = 0; + return new AcceptHeader(array_map(function ($item_value) use (&$index) { + $item = AcceptHeaderItem::fromString($item_value); + $item->setIndex($index++); + return $item; + }, preg_split('/\s*(?:,*("[^"]+"),*(\'[^\']+\'),*|,+)\s*/', $header_value, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE))); + } + + /** + * Parses the "profile" parameter from an accept header value. + * + * @param \Symfony\Component\HttpFoundation\AcceptHeaderItem $accept_header_item + * A JSON API accept header value. + * + * @return array + */ + protected static function parseProfileParameter(AcceptHeaderItem $accept_header_item) { + assert($accept_header_item->getValue() === JsonApiSpec::MIME_TYPE); + if (!$accept_header_item->hasAttribute('profile')) { + return []; + } + return explode(' ', trim($accept_header_item->getAttribute('profile'), '"')); } } diff --git a/tests/src/Functional/ResourceTestBase.php b/tests/src/Functional/ResourceTestBase.php index dcc1ac6..a1962ac 100644 --- a/tests/src/Functional/ResourceTestBase.php +++ b/tests/src/Functional/ResourceTestBase.php @@ -879,6 +879,25 @@ abstract class ResourceTestBase extends BrowserTestBase { $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); + // DX: 400 when requesting unsupported profiles. + $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json;profile="https://example.com http://foobar.com'; + $response = $this->request('GET', $url, $request_options); + $expected_document = [ + 'errors' => [ + [ + 'title' => 'Bad request', + 'status' => 400, + 'detail' => "The following requested profiles are not supported: https://example.com, http://foobar.com.", + 'links' => [ + 'info' => HttpExceptionNormalizer::getInfoUrl(400), + ], + 'code' => 0, + ], + ], + ]; + $this->assertResourceResponse(400, $expected_document, $response); + $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; + // DX: 403 when unauthorized. $response = $this->request('GET', $url, $request_options); $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();