From 30ca0d1de63fc67213afe4e5aa2aaaa6f400eff1 Mon Sep 17 00:00:00 2001
From: Nathaniel Catchpole <catch@35733.no-reply.drupal.org>
Date: Mon, 8 Jun 2015 11:11:09 +0100
Subject: [PATCH] Issue #2481453 by dawehner, pwolanin, rteijeiro, neclimdul,
 znerol: Implement query parameter based content negotiation as alternative to
 extensions

---
 core/core.services.yml                        |   8 +-
 core/lib/Drupal/Core/ContentNegotiation.php   |  22 +--
 .../DefaultExceptionSubscriber.php            |   8 +-
 .../HttpExceptionSubscriberBase.php           |   5 +-
 .../Drupal/Core/Routing/LazyRouteFilter.php   |   5 +-
 .../Core/Routing/RequestFormatRouteFilter.php |  57 +++++++
 .../StackMiddleware/NegotiationMiddleware.php |   4 +-
 core/misc/progress.js                         |  11 +-
 .../src/Tests/Views/DisplayBlockTest.php      |   2 +-
 .../src/Tests/Views/CommentRestExportTest.php |   2 +-
 .../views.view.test_comment_rest.yml          |   1 +
 .../src/Tests/ConfigTranslationUiTest.php     |   2 +-
 .../Tests/ContextualDynamicContextTest.php    |   2 +-
 .../src/Tests/Rest/DbLogResourceTest.php      |   5 +-
 .../editor/src/Tests/EditorSecurityTest.php   |   2 +-
 .../Tests/QuickEditIntegrationLoadingTest.php |   3 +-
 core/modules/hal/src/Encoder/JsonEncoder.php  |   2 +-
 core/modules/hal/src/HalServiceProvider.php   |   2 +-
 .../Normalizer/ContentEntityNormalizer.php    |  14 +-
 core/modules/hal/src/Tests/NormalizeTest.php  |   8 +-
 .../page_cache/src/Tests/PageCacheTest.php    |  23 +--
 .../Tests/QuickEditAutocompleteTermTest.php   |   2 +-
 .../src/Tests/QuickEditLoadingTest.php        |  23 +--
 .../src/Plugin/views/display/RestExport.php   |  11 +-
 .../src/Plugin/views/style/Serializer.php     |   6 +-
 core/modules/rest/src/Tests/AuthTest.php      |   6 +-
 core/modules/rest/src/Tests/NodeTest.php      |   4 +-
 core/modules/rest/src/Tests/PageCacheTest.php |   6 +-
 core/modules/rest/src/Tests/ReadTest.php      |  28 ++--
 core/modules/rest/src/Tests/ResourceTest.php  |  31 ++--
 .../src/Tests/Views/StyleSerializerTest.php   |  37 +++--
 core/modules/simpletest/src/WebTestBase.php   |  54 +++++-
 .../Routing/ContentNegotiationRoutingTest.php | 157 ++++++++++++++++++
 .../Tests/Routing/ExceptionHandlingTest.php   |  11 +-
 .../Tests/System/ResponseGeneratorTest.php    |   2 +-
 .../accept_header_routing_test.info.yml       |   3 +
 .../accept_header_routing_test.services.yml   |   5 +
 .../src/AcceptHeaderMiddleware.php            |  47 ++++++
 ...AcceptHeaderRoutingTestServiceProvider.php |  28 ++++
 .../src}/Routing/AcceptHeaderMatcher.php      |   5 +-
 .../tests/Unit}/AcceptHeaderMatcherTest.php   |  10 +-
 .../modules/conneg_test/conneg_test.info.yml  |   6 +
 .../conneg_test/conneg_test.routing.yml       |  32 ++++
 .../src/Controller/TestController.php         |  76 +++++++++
 .../PageCacheAcceptHeaderController.php       |   2 +-
 .../views_ui/src/Tests/DisplayTest.php        |   2 +-
 .../Tests/Core/ContentNegotiationTest.php     |  31 +---
 .../Routing/RequestFormatRouteFilterTest.php  |  85 ++++++++++
 48 files changed, 720 insertions(+), 178 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
 create mode 100644 core/modules/system/src/Tests/Routing/ContentNegotiationRoutingTest.php
 create mode 100644 core/modules/system/tests/modules/accept_header_routing_test/accept_header_routing_test.info.yml
 create mode 100644 core/modules/system/tests/modules/accept_header_routing_test/accept_header_routing_test.services.yml
 create mode 100644 core/modules/system/tests/modules/accept_header_routing_test/src/AcceptHeaderMiddleware.php
 create mode 100644 core/modules/system/tests/modules/accept_header_routing_test/src/AcceptHeaderRoutingTestServiceProvider.php
 rename core/{lib/Drupal/Core => modules/system/tests/modules/accept_header_routing_test/src}/Routing/AcceptHeaderMatcher.php (93%)
 rename core/{tests/Drupal/Tests/Core/Routing => modules/system/tests/modules/accept_header_routing_test/tests/Unit}/AcceptHeaderMatcherTest.php (91%)
 create mode 100644 core/modules/system/tests/modules/conneg_test/conneg_test.info.yml
 create mode 100644 core/modules/system/tests/modules/conneg_test/conneg_test.routing.yml
 create mode 100644 core/modules/system/tests/modules/conneg_test/src/Controller/TestController.php
 create mode 100644 core/tests/Drupal/Tests/Core/Routing/RequestFormatRouteFilterTest.php

diff --git a/core/core.services.yml b/core/core.services.yml
index 7b41a1d2c64a..86c3dad4eb54 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -560,10 +560,6 @@ services:
   http_middleware.negotiation:
     class: Drupal\Core\StackMiddleware\NegotiationMiddleware
     arguments: ['@http_negotiation.format_negotiator']
-    calls:
-      - [registerFormat, ['drupal_ajax', ['application/vnd.drupal-ajax']]]
-      - [registerFormat, ['drupal_dialog', ['application/vnd.drupal-dialog']]]
-      - [registerFormat, ['drupal_modal', ['application/vnd.drupal-modal']]]
     tags:
       - { name: http_middleware, priority: 400 }
   http_middleware.reverse_proxy:
@@ -758,8 +754,8 @@ services:
   password:
     class: Drupal\Core\Password\PhpassHashedPassword
     arguments: [16]
-  accept_header_matcher:
-    class: Drupal\Core\Routing\AcceptHeaderMatcher
+  request_format_route_filter:
+    class: Drupal\Core\Routing\RequestFormatRouteFilter
     tags:
       - { name: route_filter }
   content_type_header_matcher:
diff --git a/core/lib/Drupal/Core/ContentNegotiation.php b/core/lib/Drupal/Core/ContentNegotiation.php
index e45c92dccdd5..1074b816e03c 100644
--- a/core/lib/Drupal/Core/ContentNegotiation.php
+++ b/core/lib/Drupal/Core/ContentNegotiation.php
@@ -10,10 +10,7 @@
 use Symfony\Component\HttpFoundation\Request;
 
 /**
- * This class is a central library for content type negotiation.
- *
- * @todo Replace this class with a real content negotiation library based on
- *   mod_negotiation. Development of that is a work in progress.
+ * Provides content negotation based upon query parameters.
  */
 class ContentNegotiation {
 
@@ -36,21 +33,8 @@ public function getContentType(Request $request) {
       return 'iframeupload';
     }
 
-    // Check all formats, if priority format is found return it.
-    $first_found_format = FALSE;
-    foreach ($request->getAcceptableContentTypes() as $mime_type) {
-      $format = $request->getFormat($mime_type);
-      if ($format === 'html') {
-        return $format;
-      }
-      if (!is_null($format) && !$first_found_format) {
-        $first_found_format = $format;
-      }
-    }
-
-    // No HTML found, return first found.
-    if ($first_found_format) {
-      return $first_found_format;
+    if ($request->query->has('_format')) {
+      return $request->query->get('_format');
     }
 
     if ($request->isXmlHttpRequest()) {
diff --git a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionSubscriber.php
index 883927ce23fd..46d6c4605c55 100644
--- a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionSubscriber.php
+++ b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionSubscriber.php
@@ -198,13 +198,7 @@ public function onException(GetResponseForExceptionEvent $event) {
    *   The format as which to treat the exception.
    */
   protected function getFormat(Request $request) {
-    // @todo We are trying to switch to a more robust content negotiation
-    // library in https://www.drupal.org/node/1505080 that will make
-    // $request->getRequestFormat() reliable as a better alternative
-    // to this code. We therefore use this style for now on the expectation
-    // that it will get replaced with better code later. This approach makes
-    // that change easier when we get to it.
-    $format = \Drupal::service('http_negotiation.format_negotiator')->getContentType($request);
+    $format = $request->query->get(MainContentViewSubscriber::WRAPPER_FORMAT, $request->getRequestFormat());
 
     // These are all JSON errors for our purposes. Any special handling for
     // them can/should happen in earlier listeners if desired.
diff --git a/core/lib/Drupal/Core/EventSubscriber/HttpExceptionSubscriberBase.php b/core/lib/Drupal/Core/EventSubscriber/HttpExceptionSubscriberBase.php
index a14d327b16c4..6fcacc9443ca 100644
--- a/core/lib/Drupal/Core/EventSubscriber/HttpExceptionSubscriberBase.php
+++ b/core/lib/Drupal/Core/EventSubscriber/HttpExceptionSubscriberBase.php
@@ -86,11 +86,12 @@ public function onException(GetResponseForExceptionEvent $event) {
     $exception = $event->getException();
 
     // Make the exception available for example when rendering a block.
-    $event->getRequest()->attributes->set('exception', $exception);
+    $request = $event->getRequest();
+    $request->attributes->set('exception', $exception);
 
     $handled_formats = $this->getHandledFormats();
 
-    $format = $event->getRequest()->getRequestFormat();
+    $format = $request->query->get(MainContentViewSubscriber::WRAPPER_FORMAT, $request->getRequestFormat());
 
     if ($exception instanceof HttpExceptionInterface && (empty($handled_formats) || in_array($format, $handled_formats))) {
       $method = 'on' . $exception->getStatusCode();
diff --git a/core/lib/Drupal/Core/Routing/LazyRouteFilter.php b/core/lib/Drupal/Core/Routing/LazyRouteFilter.php
index 288e09b694a7..5cb85a72d81f 100644
--- a/core/lib/Drupal/Core/Routing/LazyRouteFilter.php
+++ b/core/lib/Drupal/Core/Routing/LazyRouteFilter.php
@@ -10,6 +10,7 @@
 use Symfony\Cmf\Component\Routing\NestedMatcher\RouteFilterInterface as BaseRouteFilterInterface;
 use Symfony\Component\DependencyInjection\ContainerAwareInterface;
 use Symfony\Component\DependencyInjection\ContainerAwareTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\RouteCollection;
 
@@ -95,7 +96,9 @@ public function filter(RouteCollection $collection, Request $request) {
 
     if (isset($filter_ids)) {
       foreach ($filter_ids as $filter_id) {
-        $collection = $this->container->get($filter_id)->filter($collection, $request);
+        if ($filter = $this->container->get($filter_id, ContainerInterface::NULL_ON_INVALID_REFERENCE)) {
+          $collection = $filter->filter($collection, $request);
+        }
       }
     }
     return $collection;
diff --git a/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
new file mode 100644
index 000000000000..4d5189a2d303
--- /dev/null
+++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
@@ -0,0 +1,57 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Routing\RequestFormatRouteFilter.
+ */
+
+namespace Drupal\Core\Routing;
+
+use Drupal\Component\Utility\SafeMarkup;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Provides a route filter, which filters by the request format.
+ */
+class RequestFormatRouteFilter implements RouteFilterInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(Route $route) {
+    return $route->hasRequirement('_format');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function filter(RouteCollection $collection, Request $request) {
+    $format = $request->getRequestFormat('html');
+    /** @var \Symfony\Component\Routing\Route $route */
+    foreach ($collection as $name => $route) {
+      // If the route has no _format specification, we move it to the end. If it
+      // does, then no match means the route is removed entirely.
+      if ($supported_formats = array_filter(explode('|', $route->getRequirement('_format')))) {
+        if (!in_array($format, $supported_formats)) {
+          $collection->remove($name);
+        }
+      }
+      else {
+        $collection->add($name, $route);
+      }
+    }
+
+    if (count($collection)) {
+      return $collection;
+    }
+
+    // We do not throw a
+    // \Symfony\Component\Routing\Exception\ResourceNotFoundException here
+    // because we don't want to return a 404 status code, but rather a 406.
+    throw new NotAcceptableHttpException(SafeMarkup::format('No route found for the specified format @format.', ['@format' => $format]));
+  }
+
+}
diff --git a/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php b/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php
index 295857d626c5..3b0bf7627052 100644
--- a/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php
+++ b/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php
@@ -38,7 +38,7 @@ class NegotiationMiddleware implements HttpKernelInterface {
    *
    * @var array
    */
-  protected $formats;
+  protected $formats = [];
 
   /**
    * Constructs a new NegotiationMiddleware.
@@ -57,10 +57,12 @@ public function __construct(HttpKernelInterface $app, ContentNegotiation $negoti
    * {@inheritdoc}
    */
   public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true) {
+    // Register available mime types.
     foreach ($this->formats as $format => $mime_type) {
       $request->setFormat($format, $mime_type);
     }
 
+    // Determine the request format using the negotiator.
     $request->setRequestFormat($this->negotiator->getContentType($request));
     return $this->app->handle($request, $type, $catch);
   }
diff --git a/core/misc/progress.js b/core/misc/progress.js
index 7305d24a2033..e3d1223d0b18 100644
--- a/core/misc/progress.js
+++ b/core/misc/progress.js
@@ -83,11 +83,18 @@
         var pb = this;
         // When doing a post request, you need non-null data. Otherwise a
         // HTTP 411 or HTTP 406 (with Apache mod_security) error may result.
+        var uri = this.uri;
+        if (uri.indexOf('?') === -1) {
+          uri += '?';
+        }
+        else {
+          uri += '&';
+        }
+        uri += '_format=json';
         $.ajax({
           type: this.method,
-          url: this.uri,
+          url: uri,
           data: '',
-          dataType: 'json',
           success: function (progress) {
             // Display errors.
             if (progress.status === 0) {
diff --git a/core/modules/block/src/Tests/Views/DisplayBlockTest.php b/core/modules/block/src/Tests/Views/DisplayBlockTest.php
index 56e7354791ed..3f96ab10f82b 100644
--- a/core/modules/block/src/Tests/Views/DisplayBlockTest.php
+++ b/core/modules/block/src/Tests/Views/DisplayBlockTest.php
@@ -285,7 +285,7 @@ public function testBlockContextualLinks() {
     // Get server-rendered contextual links.
     // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks()
     $post = array('ids[0]' => $id, 'ids[1]' => $cached_id);
-    $response = $this->drupalPost('contextual/render', 'application/json', $post, array('query' => array('destination' => 'test-page')));
+    $response = $this->drupalPostWithFormat('contextual/render', 'json', $post, array('query' => array('destination' => 'test-page')));
     $this->assertResponse(200);
     $json = Json::decode($response);
     $this->assertIdentical($json[$id], '<ul class="contextual-links"><li class="block-configure"><a href="' . base_path() . 'admin/structure/block/manage/' . $block->id() . '">Configure block</a></li><li class="entityviewedit-form"><a href="' . base_path() . 'admin/structure/views/view/test_view_block/edit/block_1">Edit view</a></li></ul>');
diff --git a/core/modules/comment/src/Tests/Views/CommentRestExportTest.php b/core/modules/comment/src/Tests/Views/CommentRestExportTest.php
index ecf1a546b2e6..9737b4fbd021 100644
--- a/core/modules/comment/src/Tests/Views/CommentRestExportTest.php
+++ b/core/modules/comment/src/Tests/Views/CommentRestExportTest.php
@@ -55,7 +55,7 @@ protected function setUp() {
    * Test comment row.
    */
   public function testCommentRestExport() {
-    $this->drupalGet(sprintf('node/%d/comments', $this->nodeUserCommented->id()), [], ['Accept' => 'application/hal+json']);
+    $this->drupalGetWithFormat(sprintf('node/%d/comments', $this->nodeUserCommented->id()), 'hal_json');
     $this->assertResponse(200);
     $contents = Json::decode($this->getRawContent());
     $this->assertEqual($contents[0]['subject'], 'How much wood would a woodchuck chuck');
diff --git a/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_rest.yml b/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_rest.yml
index 2de8cdb6a9e4..6350455000d4 100644
--- a/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_rest.yml
+++ b/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_rest.yml
@@ -386,6 +386,7 @@ display:
           uses_fields: false
           formats:
             json: json
+            hal_json: hal_json
       row:
         type: data_field
         options:
diff --git a/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php b/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php
index 00b36c526df3..c7218dffcf9a 100644
--- a/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php
+++ b/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php
@@ -904,7 +904,7 @@ protected function renderContextualLinks($ids, $current_path) {
     for ($i = 0; $i < count($ids); $i++) {
       $post['ids[' . $i . ']'] = $ids[$i];
     }
-    return $this->drupalPost('contextual/render', 'application/json', $post, array('query' => array('destination' => $current_path)));
+    return $this->drupalPostWithFormat('contextual/render', 'json', $post, array('query' => array('destination' => $current_path)));
   }
 
   /**
diff --git a/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php b/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php
index 01eb4f57d0f2..8846ab935888 100644
--- a/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php
+++ b/core/modules/contextual/src/Tests/ContextualDynamicContextTest.php
@@ -181,6 +181,6 @@ protected function renderContextualLinks($ids, $current_path) {
     for ($i = 0; $i < count($ids); $i++) {
       $post['ids[' . $i . ']'] = $ids[$i];
     }
-    return $this->drupalPost('contextual/render', 'application/json', $post, array('query' => array('destination' => $current_path)));
+    return $this->drupalPostWithFormat('contextual/render', 'json', $post, array('query' => array('destination' => $current_path)));
   }
 }
diff --git a/core/modules/dblog/src/Tests/Rest/DbLogResourceTest.php b/core/modules/dblog/src/Tests/Rest/DbLogResourceTest.php
index 25c7a901bde4..91097757369e 100644
--- a/core/modules/dblog/src/Tests/Rest/DbLogResourceTest.php
+++ b/core/modules/dblog/src/Tests/Rest/DbLogResourceTest.php
@@ -8,6 +8,7 @@
 namespace Drupal\dblog\Tests\Rest;
 
 use Drupal\Component\Serialization\Json;
+use Drupal\Core\Url;
 use Drupal\rest\Tests\RESTTestBase;
 
 /**
@@ -45,7 +46,7 @@ public function testWatchdog() {
     $account = $this->drupalCreateUser(array('restful get dblog'));
     $this->drupalLogin($account);
 
-    $response = $this->httpRequest("dblog/$id", 'GET', NULL, $this->defaultMimeType);
+    $response = $this->httpRequest(Url::fromRoute('rest.dblog.GET.' . $this->defaultFormat, ['id' => $id, '_format' => $this->defaultFormat]), 'GET');
     $this->assertResponse(200);
     $this->assertHeader('content-type', $this->defaultMimeType);
     $log = Json::decode($response);
@@ -54,7 +55,7 @@ public function testWatchdog() {
     $this->assertEqual($log['message'], 'Test message', 'Log message text is correct.');
 
     // Request an unknown log entry.
-    $response = $this->httpRequest("dblog/9999", 'GET', NULL, $this->defaultMimeType);
+    $response = $this->httpRequest(Url::fromRoute('rest.dblog.GET.' . $this->defaultFormat, ['id' => 9999, '_format' => $this->defaultFormat]), 'GET');
     $this->assertResponse(404);
     $decoded = Json::decode($response);
     $this->assertEqual($decoded['error'], 'Log entry with ID 9999 was not found', 'Response message is correct.');
diff --git a/core/modules/editor/src/Tests/EditorSecurityTest.php b/core/modules/editor/src/Tests/EditorSecurityTest.php
index 305692dca22c..703c65bdbbd4 100644
--- a/core/modules/editor/src/Tests/EditorSecurityTest.php
+++ b/core/modules/editor/src/Tests/EditorSecurityTest.php
@@ -409,7 +409,7 @@ function testSwitchingSecurity() {
           'value' => self::$sampleContent,
           'original_format_id' => $case['format'],
         );
-        $response = $this->drupalPost('editor/filter_xss/' . $format, 'application/json', $post);
+        $response = $this->drupalPostWithFormat('editor/filter_xss/' . $format, 'json', $post);
         $this->assertResponse(200);
         $json = Json::decode($response);
         $this->assertIdentical($json, $expected_filtered_value, 'The value was correctly filtered for XSS attack vectors.');
diff --git a/core/modules/editor/src/Tests/QuickEditIntegrationLoadingTest.php b/core/modules/editor/src/Tests/QuickEditIntegrationLoadingTest.php
index c4b81eadcdf4..9204f2876e55 100644
--- a/core/modules/editor/src/Tests/QuickEditIntegrationLoadingTest.php
+++ b/core/modules/editor/src/Tests/QuickEditIntegrationLoadingTest.php
@@ -8,6 +8,7 @@
 namespace Drupal\editor\Tests;
 
 use Drupal\Component\Serialization\Json;
+use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
 use Drupal\simpletest\WebTestBase;
 
 /**
@@ -88,7 +89,7 @@ public function testUsersWithoutPermission() {
       $this->assertRaw('<p>Do you also love Drupal?</p><figure class="caption caption-img"><img src="druplicon.png" /><figcaption>Druplicon</figcaption></figure>');
 
       // Retrieving the untransformed text should result in an empty 403 response.
-      $response = $this->drupalPost('editor/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', array());
+      $response = $this->drupalPost('editor/' . 'node/1/body/en/full', '', array(), array('query' => array(MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax')));
       $this->assertResponse(403);
       $this->assertIdentical('{}', $response);
     }
diff --git a/core/modules/hal/src/Encoder/JsonEncoder.php b/core/modules/hal/src/Encoder/JsonEncoder.php
index 4d932ff8a0bd..897bc4496a2d 100644
--- a/core/modules/hal/src/Encoder/JsonEncoder.php
+++ b/core/modules/hal/src/Encoder/JsonEncoder.php
@@ -12,7 +12,7 @@
 /**
  * Encodes HAL data in JSON.
  *
- * Simply respond to application/hal+json requests using the JSON encoder.
+ * Simply respond to hal_json format requests using the JSON encoder.
  */
 class JsonEncoder extends SymfonyJsonEncoder {
 
diff --git a/core/modules/hal/src/HalServiceProvider.php b/core/modules/hal/src/HalServiceProvider.php
index d8ac47f0f08d..afe8d2d20fee 100644
--- a/core/modules/hal/src/HalServiceProvider.php
+++ b/core/modules/hal/src/HalServiceProvider.php
@@ -19,7 +19,7 @@ class HalServiceProvider implements ServiceModifierInterface {
    * {@inheritdoc}
    */
   public function alter(ContainerBuilder $container) {
-    if ($container->has('http_middleware.negotiation')) {
+    if ($container->has('http_middleware.negotiation') && is_a($container->getDefinition('http_middleware.negotiation')->getClass(), '\Drupal\Core\StackMiddleware\NegotiationMiddleware', TRUE)) {
       $container->getDefinition('http_middleware.negotiation')->addMethodCall('registerFormat', ['hal_json', ['application/hal+json']]);
     }
   }
diff --git a/core/modules/hal/src/Normalizer/ContentEntityNormalizer.php b/core/modules/hal/src/Normalizer/ContentEntityNormalizer.php
index 14fdb0b44066..a4761b356087 100644
--- a/core/modules/hal/src/Normalizer/ContentEntityNormalizer.php
+++ b/core/modules/hal/src/Normalizer/ContentEntityNormalizer.php
@@ -8,6 +8,7 @@
 namespace Drupal\hal\Normalizer;
 
 use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityManagerInterface;
 use Drupal\Core\Extension\ModuleHandlerInterface;
 use Drupal\rest\LinkManager\LinkManagerInterface;
@@ -195,14 +196,19 @@ public function denormalize($data, $class, $format = NULL, array $context = arra
   /**
    * Constructs the entity URI.
    *
-   * @param $entity
+   * @param \Drupal\Core\Entity\EntityInterface
    *   The entity.
-   *
    * @return string
    *   The entity URI.
    */
-  protected function getEntityUri($entity) {
-    return $entity->url('canonical', array('absolute' => TRUE));
+  protected function getEntityUri(EntityInterface $entity) {
+    // Some entity types don't provide a canonical link template, at least call
+    // out to ->url().
+    if ($entity->isNew() || !$entity->hasLinkTemplate('canonical')) {
+      return $entity->url('canonical', []);
+    }
+    $url = $entity->urlInfo('canonical', ['absolute' => TRUE]);
+    return $url->setRouteParameter('_format', 'hal_json')->toString();
   }
 
   /**
diff --git a/core/modules/hal/src/Tests/NormalizeTest.php b/core/modules/hal/src/Tests/NormalizeTest.php
index 9525f056ca13..44d7f242b1d2 100644
--- a/core/modules/hal/src/Tests/NormalizeTest.php
+++ b/core/modules/hal/src/Tests/NormalizeTest.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\hal\Tests;
 
+use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Url;
 
 /**
@@ -169,14 +170,15 @@ public function testNormalize() {
   /**
    * Constructs the entity URI.
    *
-   * @param $entity
+   * @param \Drupal\Core\Entity\EntityInterface $entity
    *   The entity.
    *
    * @return string
    *   The entity URI.
    */
-  protected function getEntityUri($entity) {
-    return $entity->url('canonical', array('absolute' => TRUE));
+  protected function getEntityUri(EntityInterface $entity) {
+    $url = $entity->urlInfo('canonical', ['absolute' => TRUE]);
+    return $url->setRouteParameter('_format', 'hal_json')->toString();
   }
 
 }
diff --git a/core/modules/page_cache/src/Tests/PageCacheTest.php b/core/modules/page_cache/src/Tests/PageCacheTest.php
index 7faa45e3b4e2..37fd06b7f7a1 100644
--- a/core/modules/page_cache/src/Tests/PageCacheTest.php
+++ b/core/modules/page_cache/src/Tests/PageCacheTest.php
@@ -79,15 +79,16 @@ function testPageCacheTags() {
   }
 
   /**
-   * Tests support for different cache items with different Accept headers.
+   * Tests support for different cache items with different request formats
+   * specified via a query parameter.
    */
-  function testAcceptHeaderRequests() {
+  function testQueryParameterFormatRequests() {
     $config = $this->config('system.performance');
     $config->set('cache.page.max_age', 300);
     $config->save();
 
     $accept_header_cache_url = Url::fromRoute('system_test.page_cache_accept_header');
-    $json_accept_header = array('Accept: application/json');
+    $accept_header_cache_url_with_json = Url::fromRoute('system_test.page_cache_accept_header', ['_format' => 'json']);
 
     $this->drupalGet($accept_header_cache_url);
     $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS', 'HTML page was not yet cached.');
@@ -95,9 +96,9 @@ function testAcceptHeaderRequests() {
     $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', 'HTML page was cached.');
     $this->assertRaw('<p>oh hai this is html.</p>', 'The correct HTML response was returned.');
 
-    $this->drupalGet($accept_header_cache_url, array(), $json_accept_header);
+    $this->drupalGet($accept_header_cache_url_with_json);
     $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS', 'Json response was not yet cached.');
-    $this->drupalGet($accept_header_cache_url, array(), $json_accept_header);
+    $this->drupalGet($accept_header_cache_url_with_json);
     $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', 'Json response was cached.');
     $this->assertRaw('{"content":"oh hai this is json"}', 'The correct Json response was returned.');
 
@@ -105,8 +106,8 @@ function testAcceptHeaderRequests() {
     \Drupal::service('module_installer')->install(['node', 'rest', 'hal']);
     $this->drupalCreateContentType(['type' => 'article']);
     $node = $this->drupalCreateNode(['type' => 'article']);
-    $node_uri = 'node/' . $node->id();
-    $hal_json_accept_header = ['Accept: application/hal+json'];
+    $node_uri = $node->urlInfo();
+    $node_url_with_hal_json_format = $node->urlInfo('canonical')->setRouteParameter('_format', 'hal_json');
     /** @var \Drupal\user\RoleInterface $role */
     $role = Role::load('anonymous');
     $role->grantPermission('restful get entity:node');
@@ -121,20 +122,20 @@ function testAcceptHeaderRequests() {
 
     // Now request a HAL page, we expect that the first request is a cache miss
     // and it serves HTML.
-    $this->drupalGet($node_uri, [], $hal_json_accept_header);
+    $this->drupalGet($node_url_with_hal_json_format);
     $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS');
     $this->assertEqual($this->drupalGetHeader('Content-Type'), 'application/hal+json');
-    $this->drupalGet($node_uri, [], $hal_json_accept_header);
+    $this->drupalGet($node_url_with_hal_json_format);
     $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT');
     $this->assertEqual($this->drupalGetHeader('Content-Type'), 'application/hal+json');
 
     // Clear the page cache. After that request a HAL request, followed by an
     // ordinary HTML one.
     \Drupal::cache('render')->deleteAll();
-    $this->drupalGet($node_uri, [], $hal_json_accept_header);
+    $this->drupalGet($node_url_with_hal_json_format);
     $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS');
     $this->assertEqual($this->drupalGetHeader('Content-Type'), 'application/hal+json');
-    $this->drupalGet($node_uri, [], $hal_json_accept_header);
+    $this->drupalGet($node_url_with_hal_json_format);
     $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT');
     $this->assertEqual($this->drupalGetHeader('Content-Type'), 'application/hal+json');
 
diff --git a/core/modules/quickedit/src/Tests/QuickEditAutocompleteTermTest.php b/core/modules/quickedit/src/Tests/QuickEditAutocompleteTermTest.php
index 467a66c8616e..81eaa973de5a 100644
--- a/core/modules/quickedit/src/Tests/QuickEditAutocompleteTermTest.php
+++ b/core/modules/quickedit/src/Tests/QuickEditAutocompleteTermTest.php
@@ -181,7 +181,7 @@ public function testAutocompleteQuickEdit() {
 
       // Save the entity.
       $post = array('nocssjs' => 'true');
-      $response = $this->drupalPost('quickedit/entity/node/' . $this->node->id(), 'application/json', $post);
+      $response = $this->drupalPostWithFormat('quickedit/entity/node/' . $this->node->id(), 'json', $post);
       $this->assertResponse(200);
 
       // The full node display should now link to all entities, with the new
diff --git a/core/modules/quickedit/src/Tests/QuickEditLoadingTest.php b/core/modules/quickedit/src/Tests/QuickEditLoadingTest.php
index 6cb092409b1f..1747b1937d9f 100644
--- a/core/modules/quickedit/src/Tests/QuickEditLoadingTest.php
+++ b/core/modules/quickedit/src/Tests/QuickEditLoadingTest.php
@@ -12,7 +12,8 @@
 use Drupal\block_content\Entity\BlockContent;
 use Drupal\field\Entity\FieldConfig;
 use Drupal\field\Entity\FieldStorageConfig;
-use Drupal\file\Entity\File;
+use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
+use Drupal\Core\Url;
 use Drupal\node\Entity\Node;
 use Drupal\node\Entity\NodeType;
 use Drupal\simpletest\WebTestBase;
@@ -106,7 +107,7 @@ public function testUserWithoutPermission() {
 
     // Retrieving the metadata should result in an empty 403 response.
     $post = array('fields[0]' => 'node/1/body/en/full');
-    $response = $this->drupalPost('quickedit/metadata', 'application/json', $post);
+    $response = $this->drupalPostWithFormat(Url::fromRoute('quickedit.metadata'), 'json', $post);
     $this->assertIdentical('{"message":""}', $response);
     $this->assertResponse(403);
 
@@ -114,11 +115,11 @@ public function testUserWithoutPermission() {
     // was empty as above, but we need to make sure that malicious users aren't
     // able to use any of the other endpoints either.
     $post = array('editors[0]' => 'form') + $this->getAjaxPageStatePostData();
-    $response = $this->drupalPost('quickedit/attachments', 'application/vnd.drupal-ajax', $post);
+    $response = $this->drupalPost('quickedit/attachments', '', $post, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
     $this->assertIdentical('{}', $response);
     $this->assertResponse(403);
     $post = array('nocssjs' => 'true') + $this->getAjaxPageStatePostData();
-    $response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', $post);
+    $response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', '', $post, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
     $this->assertIdentical('{}', $response);
     $this->assertResponse(403);
     $edit = array();
@@ -129,11 +130,11 @@ public function testUserWithoutPermission() {
     $edit['body[0][value]'] = '<p>Malicious content.</p>';
     $edit['body[0][format]'] = 'filtered_html';
     $edit['op'] = t('Save');
-    $response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', $edit);
+    $response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', '', $edit, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
     $this->assertIdentical('{}', $response);
     $this->assertResponse(403);
     $post = array('nocssjs' => 'true');
-    $response = $this->drupalPost('quickedit/entity/' . 'node/1', 'application/json', $post);
+    $response = $this->drupalPostWithFormat('quickedit/entity/' . 'node/1', 'json', $post);
     $this->assertIdentical('{"message":""}', $response);
     $this->assertResponse(403);
   }
@@ -166,7 +167,7 @@ public function testUserWithPermission() {
     // Retrieving the metadata should result in a 200 JSON response.
     $htmlPageDrupalSettings = $this->drupalSettings;
     $post = array('fields[0]' => 'node/1/body/en/full');
-    $response = $this->drupalPost('quickedit/metadata', 'application/json', $post);
+    $response = $this->drupalPostWithFormat('quickedit/metadata', 'json', $post);
     $this->assertResponse(200);
     $expected = array(
       'node/1/body/en/full' => array(
@@ -239,7 +240,7 @@ public function testUserWithPermission() {
 
       // Save the entity by moving the PrivateTempStore values to entity storage.
       $post = array('nocssjs' => 'true');
-      $response = $this->drupalPost('quickedit/entity/' . 'node/1', 'application/json', $post);
+      $response = $this->drupalPostWithFormat('quickedit/entity/' . 'node/1', 'json', $post);
       $this->assertResponse(200);
       $ajax_commands = Json::decode($response);
       $this->assertIdentical(1, count($ajax_commands), 'The entity submission HTTP request results in one AJAX command.');
@@ -293,7 +294,7 @@ public function testUserWithPermission() {
 
       // Save the entity.
       $post = array('nocssjs' => 'true');
-      $response = $this->drupalPost('quickedit/entity/' . 'node/1', 'application/json', $post);
+      $response = $this->drupalPostWithFormat('quickedit/entity/' . 'node/1', 'json', $post);
       $this->assertResponse(200);
       $ajax_commands = Json::decode($response);
       $this->assertIdentical(1, count($ajax_commands));
@@ -325,7 +326,7 @@ public function testTitleBaseField() {
     // Retrieving the metadata should result in a 200 JSON response.
     $htmlPageDrupalSettings = $this->drupalSettings;
     $post = array('fields[0]' => 'node/1/title/en/full');
-    $response = $this->drupalPost('quickedit/metadata', 'application/json', $post);
+    $response = $this->drupalPostWithFormat('quickedit/metadata', 'json', $post);
     $this->assertResponse(200);
     $expected = array(
       'node/1/title/en/full' => array(
@@ -383,7 +384,7 @@ public function testTitleBaseField() {
 
       // Save the entity by moving the PrivateTempStore values to entity storage.
       $post = array('nocssjs' => 'true');
-      $response = $this->drupalPost('quickedit/entity/' . 'node/1', 'application/json', $post);
+      $response = $this->drupalPostWithFormat('quickedit/entity/' . 'node/1', 'json', $post);
       $this->assertResponse(200);
       $ajax_commands = Json::decode($response);
       $this->assertIdentical(1, count($ajax_commands), 'The entity submission HTTP request results in one AJAX command.');
diff --git a/core/modules/rest/src/Plugin/views/display/RestExport.php b/core/modules/rest/src/Plugin/views/display/RestExport.php
index 0bce1becb768..2886079cd9cb 100644
--- a/core/modules/rest/src/Plugin/views/display/RestExport.php
+++ b/core/modules/rest/src/Plugin/views/display/RestExport.php
@@ -266,10 +266,13 @@ public function collectRoutes(RouteCollection $collection) {
       $route->setMethods(['GET']);
 
       // Format as a string using pipes as a delimiter.
-      $requirements['_format'] = implode('|', $style_plugin->getFormats());
-
-      // Add the new requirements to the route.
-      $route->addRequirements($requirements);
+      if ($formats = $style_plugin->getFormats()) {
+        // Allow a REST Export View to be returned with an HTML-only accept
+        // format. That allows browsers or other non-compliant systems to access
+        // the view, as it is unlikely to have a conflicting HTML representation
+        // anyway.
+        $route->setRequirement('_format', implode('|', $formats + ['html']));
+      }
     }
   }
 
diff --git a/core/modules/rest/src/Plugin/views/style/Serializer.php b/core/modules/rest/src/Plugin/views/style/Serializer.php
index ba3ca6f07359..ceeb39f6636f 100644
--- a/core/modules/rest/src/Plugin/views/style/Serializer.php
+++ b/core/modules/rest/src/Plugin/views/style/Serializer.php
@@ -146,11 +146,7 @@ public function render() {
    *   An array of formats.
    */
   public function getFormats() {
-    if (!empty($this->options['formats'])) {
-      return $this->options['formats'];
-    }
-
-    return $this->formats;
+    return $this->options['formats'];
   }
 
 }
diff --git a/core/modules/rest/src/Tests/AuthTest.php b/core/modules/rest/src/Tests/AuthTest.php
index c222b26e354b..4552fc681a12 100644
--- a/core/modules/rest/src/Tests/AuthTest.php
+++ b/core/modules/rest/src/Tests/AuthTest.php
@@ -38,7 +38,7 @@ public function testRead() {
     $entity->save();
 
     // Try to read the resource as an anonymous user, which should not work.
-    $this->httpRequest($entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+    $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
     $this->assertResponse('401', 'HTTP response code is 401 when the request is not authenticated and the user is anonymous.');
     $this->assertRaw(json_encode(['message' => 'A fatal error occurred: No authentication credentials provided.']));
 
@@ -55,7 +55,7 @@ public function testRead() {
 
     // Try to read the resource with session cookie authentication, which is
     // not enabled and should not work.
-    $this->httpRequest($entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+    $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
     $this->assertResponse('403', 'HTTP response code is 403 when the request was authenticated by the wrong authentication provider.');
 
     // Ensure that cURL settings/headers aren't carried over to next request.
@@ -63,7 +63,7 @@ public function testRead() {
 
     // Now read it with the Basic authentication which is enabled and should
     // work.
-    $this->basicAuthGet($entity->urlInfo(), $account->getUsername(), $account->pass_raw, $this->defaultMimeType);
+    $this->basicAuthGet($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), $account->getUsername(), $account->pass_raw);
     $this->assertResponse('200', 'HTTP response code is 200 for successfully authenticated requests.');
     $this->curlClose();
   }
diff --git a/core/modules/rest/src/Tests/NodeTest.php b/core/modules/rest/src/Tests/NodeTest.php
index 7169102eebde..2c8de1e47417 100644
--- a/core/modules/rest/src/Tests/NodeTest.php
+++ b/core/modules/rest/src/Tests/NodeTest.php
@@ -51,14 +51,14 @@ public function testNodes() {
 
     $node = $this->entityCreate('node');
     $node->save();
-    $this->httpRequest($node->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+    $this->httpRequest($node->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
     $this->assertResponse(200);
     $this->assertHeader('Content-type', $this->defaultMimeType);
 
     // Also check that JSON works and the routing system selects the correct
     // REST route.
     $this->enableService('entity:node', 'GET', 'json');
-    $this->httpRequest($node->urlInfo(), 'GET', NULL, 'application/json');
+    $this->httpRequest($node->urlInfo()->setRouteParameter('_format', 'json'), 'GET');
     $this->assertResponse(200);
     $this->assertHeader('Content-type', 'application/json');
 
diff --git a/core/modules/rest/src/Tests/PageCacheTest.php b/core/modules/rest/src/Tests/PageCacheTest.php
index 9cf2859ec37b..573238f810ac 100644
--- a/core/modules/rest/src/Tests/PageCacheTest.php
+++ b/core/modules/rest/src/Tests/PageCacheTest.php
@@ -35,14 +35,14 @@ public function testConfigChangePageCache() {
     $entity = $this->entityCreate('entity_test');
     $entity->save();
     // Read it over the REST API.
-    $this->httpRequest($entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+    $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET', NULL, $this->defaultMimeType);
     $this->assertResponse(200, 'HTTP response code is correct.');
     $this->assertHeader('x-drupal-cache', 'MISS');
     $this->assertCacheTag('config:rest.settings');
     $this->assertCacheTag('entity_test:1');
 
     // Read it again, should be page-cached now.
-    $this->httpRequest($entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+    $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET', NULL, $this->defaultMimeType);
     $this->assertResponse(200, 'HTTP response code is correct.');
     $this->assertHeader('x-drupal-cache', 'HIT');
     $this->assertCacheTag('config:rest.settings');
@@ -51,7 +51,7 @@ public function testConfigChangePageCache() {
     // Trigger a config save which should clear the page cache, so we should get
     // a cache miss now for the same request.
     $this->config('rest.settings')->save();
-    $this->httpRequest($entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+    $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET', NULL, $this->defaultMimeType);
     $this->assertResponse(200, 'HTTP response code is correct.');
     $this->assertHeader('x-drupal-cache', 'MISS');
     $this->assertCacheTag('config:rest.settings');
diff --git a/core/modules/rest/src/Tests/ReadTest.php b/core/modules/rest/src/Tests/ReadTest.php
index 3be342ee865c..983b54742419 100644
--- a/core/modules/rest/src/Tests/ReadTest.php
+++ b/core/modules/rest/src/Tests/ReadTest.php
@@ -8,6 +8,7 @@
 namespace Drupal\rest\Tests;
 
 use Drupal\Component\Serialization\Json;
+use Drupal\Core\Url;
 use Drupal\rest\Tests\RESTTestBase;
 
 /**
@@ -44,7 +45,7 @@ public function testRead() {
       $entity = $this->entityCreate($entity_type);
       $entity->save();
       // Read it over the REST API.
-      $response = $this->httpRequest($entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+      $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
       $this->assertResponse('200', 'HTTP response code is correct.');
       $this->assertHeader('content-type', $this->defaultMimeType);
       $data = Json::decode($response);
@@ -53,12 +54,12 @@ public function testRead() {
       $this->assertEqual($data['uuid'][0]['value'], $entity->uuid(), 'Entity UUID is correct');
 
       // Try to read the entity with an unsupported mime format.
-      $response = $this->httpRequest($entity->urlInfo(), 'GET', NULL, 'application/wrongformat');
-      $this->assertResponse(200);
-      $this->assertHeader('Content-type', 'text/html; charset=UTF-8');
+      $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', 'wrongformat'), 'GET');
+      $this->assertResponse(406);
+      $this->assertHeader('Content-type', 'application/json');
 
       // Try to read an entity that does not exist.
-      $response = $this->httpRequest($entity_type . '/9999', 'GET', NULL, $this->defaultMimeType);
+      $response = $this->httpRequest(Url::fromUri('base://' . $entity_type . '/9999', ['query' => ['_format' => $this->defaultFormat]]), 'GET');
       $this->assertResponse(404);
       $path = $entity_type == 'node' ? '/node/{node}' : '/entity_test/{entity_test}';
       $expected_message = Json::encode(['message' => 'The "' . $entity_type . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . $entity_type . '.GET.hal_json")']);
@@ -70,7 +71,7 @@ public function testRead() {
       if ($entity_type == 'entity_test') {
         $entity->field_test_text->value = 'no access value';
         $entity->save();
-        $response = $this->httpRequest($entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+        $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
         $this->assertResponse(200);
         $this->assertHeader('content-type', $this->defaultMimeType);
         $data = Json::decode($response);
@@ -79,18 +80,19 @@ public function testRead() {
 
       // Try to read an entity without proper permissions.
       $this->drupalLogout();
-      $response = $this->httpRequest($entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
+      $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
       $this->assertResponse(403);
       $this->assertIdentical('{"message":""}', $response);
     }
     // Try to read a resource which is not REST API enabled.
     $account = $this->drupalCreateUser();
     $this->drupalLogin($account);
-    $response = $this->httpRequest($account->urlInfo(), 'GET', NULL, $this->defaultMimeType);
-    // AcceptHeaderMatcher considers the canonical, non-REST route a match, but
-    // a lower quality one: no format restrictions means there's always a match,
-    // and hence when there is no matching REST route, the non-REST route is
-    // used, but it can't render into application/hal+json, so it returns a 406.
+    $response = $this->httpRequest($account->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
+    // \Drupal\Core\Routing\RequestFormatRouteFilter considers the canonical,
+    // non-REST route a match, but a lower quality one: no format restrictions
+    // means there's always a match and hence when there is no matching REST
+    // route, the non-REST route is used, but can't render into
+    // application/hal+json, so it returns a 406.
     $this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.');
     $this->assertEqual($response, Json::encode([
       'message' => 'Not acceptable',
@@ -115,7 +117,7 @@ public function testResourceStructure() {
     $entity->save();
 
     // Read it over the REST API.
-    $response = $this->httpRequest($entity->urlInfo(), 'GET', NULL, 'application/json');
+    $response = $this->httpRequest($entity->urlInfo()->setRouteParameter('_format', 'json'), 'GET');
     $this->assertResponse('200', 'HTTP response code is correct.');
   }
 
diff --git a/core/modules/rest/src/Tests/ResourceTest.php b/core/modules/rest/src/Tests/ResourceTest.php
index 5144ae325a06..af2208575cce 100644
--- a/core/modules/rest/src/Tests/ResourceTest.php
+++ b/core/modules/rest/src/Tests/ResourceTest.php
@@ -7,8 +7,6 @@
 
 namespace Drupal\rest\Tests;
 
-use Drupal\rest\Tests\RESTTestBase;
-
 /**
  * Tests the structure of a REST resource.
  *
@@ -23,6 +21,13 @@ class ResourceTest extends RESTTestBase {
    */
   public static $modules = array('hal', 'rest', 'entity_test');
 
+  /**
+   * The entity.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
   /**
    * {@inheritdoc}
    */
@@ -55,11 +60,12 @@ public function testFormats() {
     $this->rebuildCache();
 
     // Verify that accessing the resource returns 406.
-    $response = $this->httpRequest($this->entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
-    // AcceptHeaderMatcher considers the canonical, non-REST route a match, but
-    // a lower quality one: no format restrictions means there's always a match,
-    // and hence when there is no matching REST route, the non-REST route is
-    // used, but it can't render into application/hal+json, so it returns a 406.
+    $response = $this->httpRequest($this->entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
+    // \Drupal\Core\Routing\RequestFormatRouteFilter considers the canonical,
+    // non-REST route a match, but a lower quality one: no format restrictions
+    // means there's always a match and hence when there is no matching REST
+    // route, the non-REST route is used, but can't render into
+    // application/hal+json, so it returns a 406.
     $this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.');
     $this->curlClose();
   }
@@ -84,11 +90,12 @@ public function testAuthentication() {
     $this->rebuildCache();
 
     // Verify that accessing the resource returns 401.
-    $response = $this->httpRequest($this->entity->urlInfo(), 'GET', NULL, $this->defaultMimeType);
-    // AcceptHeaderMatcher considers the canonical, non-REST route a match, but
-    // a lower quality one: no format restrictions means there's always a match,
-    // and hence when there is no matching REST route, the non-REST route is
-    // used, but it can't render into application/hal+json, so it returns a 406.
+    $response = $this->httpRequest($this->entity->urlInfo()->setRouteParameter('_format', $this->defaultFormat), 'GET');
+    // \Drupal\Core\Routing\RequestFormatRouteFilter considers the canonical,
+    // non-REST route a match, but a lower quality one: no format restrictions
+    // means there's always a match and hence when there is no matching REST
+    // route, the non-REST route is used, but can't render into
+    // application/hal+json, so it returns a 406.
     $this->assertResponse('406', 'HTTP response code is 406 when the resource does not define formats, because it falls back to the canonical, non-REST route.');
     $this->curlClose();
   }
diff --git a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
index 0a14c3ccfbc8..9ca4c5c98c3d 100644
--- a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
+++ b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
@@ -76,7 +76,7 @@ public function testSerializerResponses() {
     $view->initDisplay();
     $this->executeView($view);
 
-    $actual_json = $this->drupalGet('test/serialize/field', array(), array('Accept: application/json'));
+    $actual_json = $this->drupalGetWithFormat('test/serialize/field', 'json');
     $this->assertResponse(200);
     $this->assertCacheTags($view->getCacheTags());
     // @todo Due to https://www.drupal.org/node/2352009 we can't yet test the
@@ -125,7 +125,7 @@ public function testSerializerResponses() {
 
     $expected = $serializer->serialize($entities, 'json');
 
-    $actual_json = $this->drupalGet('test/serialize/entity', array(), array('Accept: application/json'));
+    $actual_json = $this->drupalGetWithFormat('test/serialize/entity', 'json');
     $this->assertResponse(200);
     $this->assertIdentical($actual_json, $expected, 'The expected JSON output was found.');
     $expected_cache_tags = $view->getCacheTags();
@@ -137,7 +137,7 @@ public function testSerializerResponses() {
     $this->assertCacheTags($expected_cache_tags);
 
     $expected = $serializer->serialize($entities, 'hal_json');
-    $actual_json = $this->drupalGet('test/serialize/entity', array(), array('Accept: application/hal+json'));
+    $actual_json = $this->drupalGetWithFormat('test/serialize/entity', 'hal_json');
     $this->assertIdentical($actual_json, $expected, 'The expected HAL output was found.');
     $this->assertCacheTags($expected_cache_tags);
 
@@ -171,10 +171,10 @@ public function testSerializerResponses() {
     ));
     $view->save();
     $expected = $serializer->serialize($entities, 'json');
-    $actual_json = $this->drupalGet('test/serialize/entity', array(), array('Accept: application/json'));
+    $actual_json = $this->drupalGetWithFormat('test/serialize/entity', 'json');
     $this->assertIdentical($actual_json, $expected, 'The expected JSON output was found.');
     $expected = $serializer->serialize($entities, 'xml');
-    $actual_xml = $this->drupalGet('test/serialize/entity', array(), array('Accept: application/xml'));
+    $actual_xml = $this->drupalGetWithFormat('test/serialize/entity', 'xml');
     $this->assertIdentical($actual_xml, $expected, 'The expected XML output was found.');
   }
 
@@ -191,10 +191,10 @@ public function testResponseFormatConfiguration() {
     $this->drupalPostForm(NULL, array(), t('Save'));
 
     // Should return a 406.
-    $this->drupalGet('test/serialize/field', array(), array('Accept: application/json'));
+    $this->drupalGetWithFormat('test/serialize/field', 'json');
     $this->assertResponse(406, 'A 406 response was returned when JSON was requested.');
      // Should return a 200.
-    $this->drupalGet('test/serialize/field', array(), array('Accept: application/xml'));
+    $this->drupalGetWithFormat('test/serialize/field', 'xml');
     $this->assertResponse(200, 'A 200 response was returned when XML was requested.');
 
     // Add 'json' as an accepted format, so we have multiple.
@@ -203,7 +203,7 @@ public function testResponseFormatConfiguration() {
 
     // Should return a 200.
     // @todo This should be fixed when we have better content negotiation.
-    $this->drupalGet('test/serialize/field', array(), array('Accept: */*'));
+    $this->drupalGet('test/serialize/field');
     $this->assertResponse(200, 'A 200 response was returned when any format was requested.');
 
     // Should return a 200. Emulates a sample Firefox header.
@@ -211,14 +211,27 @@ public function testResponseFormatConfiguration() {
     $this->assertResponse(200, 'A 200 response was returned when a browser accept header was requested.');
 
     // Should return a 200.
-    $this->drupalGet('test/serialize/field', array(), array('Accept: application/json'));
+    $this->drupalGetWithFormat('test/serialize/field', 'json');
     $this->assertResponse(200, 'A 200 response was returned when JSON was requested.');
     // Should return a 200.
-    $this->drupalGet('test/serialize/field', array(), array('Accept: application/xml'));
+    $this->drupalGetWithFormat('test/serialize/field', 'xml');
     $this->assertResponse(200, 'A 200 response was returned when XML was requested');
     // Should return a 406.
-    $this->drupalGet('test/serialize/field', array(), array('Accept: application/html'));
-    $this->assertResponse(406, 'A 406 response was returned when HTML was requested.');
+    $this->drupalGetWithFormat('test/serialize/field', 'html');
+    $this->assertResponse(200, 'A 200 response was returned when HTML was requested.');
+
+    // Now configure now format, so all of them should be allowed.
+    $this->drupalPostForm($style_options, array('style_options[formats][json]' => '0', 'style_options[formats][xml]' => '0'), t('Apply'));
+
+    // Should return a 200.
+    $this->drupalGetWithFormat('test/serialize/field', 'json');
+    $this->assertResponse(200, 'A 200 response was returned when JSON was requested.');
+    // Should return a 200.
+    $this->drupalGetWithFormat('test/serialize/field', 'xml');
+    $this->assertResponse(200, 'A 200 response was returned when XML was requested');
+    // Should return a 200.
+    $this->drupalGetWithFormat('test/serialize/field', 'html');
+    $this->assertResponse(200, 'A 200 response was returned when HTML was requested.');
   }
 
   /**
diff --git a/core/modules/simpletest/src/WebTestBase.php b/core/modules/simpletest/src/WebTestBase.php
index 2cf859af2b53..77a872368709 100644
--- a/core/modules/simpletest/src/WebTestBase.php
+++ b/core/modules/simpletest/src/WebTestBase.php
@@ -1395,8 +1395,27 @@ protected function drupalGet($path, array $options = array(), array $headers = a
    * Requests a Drupal path in JSON format, and JSON decodes the response.
    */
   protected function drupalGetJSON($path, array $options = array(), array $headers = array()) {
-    $headers[] = 'Accept: application/json';
-    return Json::decode($this->drupalGet($path, $options, $headers));
+    return Json::decode($this->drupalGetWithFormat($path, 'json', $options, $headers));
+  }
+
+  /**
+   * Retrieves a Drupal path or an absolute path for a given format.
+   *
+   * @param string $path
+   *   Path to request AJAX from.
+   * @param string $format
+   *   The wanted request format.
+   * @param array $options
+   *   Array of URL options.
+   * @param array $headers
+   *   Array of headers.
+   *
+   * @return mixed
+   *   The result of the request.
+   */
+  protected function drupalGetWithFormat($path, $format, array $options = [], array $headers = []) {
+    $options += ['query' => ['_format' => $format]];
+    return $this->drupalGet($path, $options, $headers);
   }
 
   /**
@@ -1890,6 +1909,34 @@ protected function drupalPost($path, $accept, array $post, $options = array()) {
     ));
   }
 
+  /**
+   * Performs a POST HTTP request with a specific format.
+   *
+   * @param string $path
+   *   Drupal path where the request should be POSTed to. Will be transformed
+   *   into an absolute path automatically.
+   * @param string $format
+   *   The request format.
+   * @param array $post
+   *   The POST data. When making a 'application/vnd.drupal-ajax' request, the
+   *   Ajax page state data should be included. Use getAjaxPageStatePostData()
+   *   for that.
+   * @param array $options
+   *   (optional) Options to be forwarded to the url generator. The 'absolute'
+   *   option will automatically be enabled.
+   *
+   * @return string
+   *   The content returned from the call to curl_exec().
+   *
+   * @see WebTestBase::drupalPost
+   * @see WebTestBase::getAjaxPageStatePostData()
+   * @see WebTestBase::curlExec()
+   */
+  protected function drupalPostWithFormat($path, $format, array $post, $options = []) {
+    $options['query']['_format'] = $format;
+    return $this->drupalPost($path, '', $post, $options);
+  }
+
   /**
    * Get the Ajax page state from drupalSettings and prepare it for POSTing.
    *
@@ -2616,6 +2663,9 @@ protected function prepareRequestForGenerator($clean_urls = TRUE, $override_serv
    */
   protected function buildUrl($path, array $options = array()) {
     if ($path instanceof Url) {
+      $url_options = $path->getOptions();
+      $options = $url_options + $options;
+      $path->setOptions($options);
       return $path->setAbsolute()->toString();
     }
     // The URL generator service is not necessarily available yet; e.g., in
diff --git a/core/modules/system/src/Tests/Routing/ContentNegotiationRoutingTest.php b/core/modules/system/src/Tests/Routing/ContentNegotiationRoutingTest.php
new file mode 100644
index 000000000000..e9430b0861f2
--- /dev/null
+++ b/core/modules/system/src/Tests/Routing/ContentNegotiationRoutingTest.php
@@ -0,0 +1,157 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\conneg_test\Tests\ContentNegotiationRoutingTest.
+ */
+
+namespace Drupal\system\Tests\Routing;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\simpletest\KernelTestBase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Tests content negotiation routing variations.
+ *
+ * @group ContentNegotiation
+ */
+class ContentNegotiationRoutingTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['system', 'conneg_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    \Drupal::unsetContainer();
+    parent::setUp();
+
+    $this->installSchema('system', ['router', 'url_alias']);
+    \Drupal::service('router.builder')->rebuild();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function containerBuild(ContainerBuilder $container) {
+    parent::containerBuild($container);
+
+    // \Drupal\simpletest\KernelTestBase::containerBuild() removes the alias path
+    // processor.
+    if ($container->hasDefinition('path_processor_alias')) {
+      $definition = $container->getDefinition('path_processor_alias');
+      $definition->addTag('path_processor_inbound', ['priority' => 100])->addTag('path_processor_outbound', ['priority' => 300]);
+    }
+  }
+
+  /**
+   * Tests the content negotiation aspect of routing.
+   */
+  function testContentRouting() {
+    /** @var \Drupal\Core\Path\AliasStorageInterface $path_alias_storage */
+    $path_alias_storage = $this->container->get('path.alias_storage');
+    // Alias with extension pointing to no extension/constant content-type.
+    $path_alias_storage->save('conneg/html', 'alias.html');
+
+    // Alias with extension pointing to dynamic extension/linked content-type.
+    $path_alias_storage->save('conneg/html?_format=json', 'alias.json');
+
+    $tests = [
+      // ['path', 'accept', 'content-type'],
+
+      // Extension is part of the route path. Constant Content-type.
+      ['conneg/simple.json', '', 'application/json'],
+      ['conneg/simple.json', 'application/xml', 'application/json'],
+      ['conneg/simple.json', 'application/json', 'application/json'],
+      // No extension. Constant Content-type.
+      ['conneg/html', '', 'text/html'],
+      ['conneg/html', '*/*', 'text/html'],
+      ['conneg/html', 'application/xml', 'text/html'],
+      ['conneg/html', 'text/xml', 'text/html'],
+      ['conneg/html', 'text/html', 'text/html'],
+      // Dynamic extension. Linked Content-type.
+      ['conneg/html?_format=json', '', 'application/json'],
+      ['conneg/html?_format=json', '*/*', 'application/json'],
+      ['conneg/html?_format=json', 'application/xml', 'application/json'],
+      ['conneg/html?_format=json', 'application/json', 'application/json'],
+      ['conneg/html?_format=xml', '', 'application/xml'],
+      ['conneg/html?_format=xml', '*/*', 'application/xml'],
+      ['conneg/html?_format=xml', 'application/json', 'application/xml'],
+      ['conneg/html?_format=xml', 'application/xml', 'application/xml'],
+
+      // Path with a variable. Variable contains a period.
+      ['conneg/plugin/plugin.id', '', 'text/html'],
+      ['conneg/plugin/plugin.id', '*/*', 'text/html'],
+      ['conneg/plugin/plugin.id', 'text/xml', 'text/html'],
+      ['conneg/plugin/plugin.id', 'text/html', 'text/html'],
+
+      // Alias with extension pointing to no extension/constant content-type.
+      ['alias.html', '', 'text/html'],
+      ['alias.html', '*/*', 'text/html'],
+      ['alias.html', 'text/xml', 'text/html'],
+      ['alias.html', 'text/html', 'text/html'],
+    ];
+
+    foreach ($tests as $test) {
+      $path = $test[0];
+      $accept_header = $test[1];
+      $content_type = $test[2];
+      $message = "Testing path:$path Accept:$accept_header Content-type:$content_type";
+      $request = Request::create($path);
+      if ($accept_header) {
+        $request->headers->set('Accept', $accept_header);
+      }
+
+      /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */
+      $kernel = \Drupal::getContainer()->get('http_kernel');
+      $response = $kernel->handle($request);
+      // Verbose message since simpletest doesn't let us provide a message and
+      // see the error.
+      $this->assertTrue(TRUE, $message);
+      $this->assertEqual($response->getStatusCode(), Response::HTTP_OK);
+      $this->assertTrue(strpos($response->headers->get('Content-type'), $content_type) !== FALSE);
+    }
+  }
+
+  /**
+   * Full negotiation by header only.
+   */
+  public function testFullNegotiation() {
+    $this->enableModules(['accept_header_routing_test']);
+    \Drupal::service('router.builder')->rebuild();
+    $tests = [
+      // ['path', 'accept', 'content-type'],
+
+      ['conneg/negotiate', '', 'text/html'], // 406?
+      ['conneg/negotiate', '', 'text/html'], // 406?
+      // ['conneg/negotiate', '*/*', '??'],
+      ['conneg/negotiate', 'application/json', 'application/json'],
+      ['conneg/negotiate', 'application/xml', 'application/xml'],
+      ['conneg/negotiate', 'application/json', 'application/json'],
+      ['conneg/negotiate', 'application/xml', 'application/xml'],
+    ];
+
+    foreach ($tests as $test) {
+      $path = $test[0];
+      $accept_header = $test[1];
+      $content_type = $test[2];
+      $message = "Testing path:$path Accept:$accept_header Content-type:$content_type";
+      $request = Request::create($path);
+      $request->headers->set('Accept', $accept_header);
+
+      /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */
+      $kernel = \Drupal::getContainer()->get('http_kernel');
+      $response = $kernel->handle($request);
+      // Verbose message since simpletest doesn't let us provide a message and
+      // see the error.
+      $this->pass($message);
+      $this->assertEqual($response->getStatusCode(), Response::HTTP_OK);
+    }
+  }
+
+}
diff --git a/core/modules/system/src/Tests/Routing/ExceptionHandlingTest.php b/core/modules/system/src/Tests/Routing/ExceptionHandlingTest.php
index 401420ca73b6..ee3c7630865f 100644
--- a/core/modules/system/src/Tests/Routing/ExceptionHandlingTest.php
+++ b/core/modules/system/src/Tests/Routing/ExceptionHandlingTest.php
@@ -39,8 +39,8 @@ protected function setUp() {
    */
   public function testJson403() {
     $request = Request::create('/router_test/test15');
-    $request->headers->set('Accept', 'application/json');
-    $request->setFormat('json', ['application/json']);
+    $request->query->set('_format', 'json');
+    $request->setRequestFormat('json');
 
     /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */
     $kernel = \Drupal::getContainer()->get('http_kernel');
@@ -56,8 +56,8 @@ public function testJson403() {
    */
   public function testJson404() {
     $request = Request::create('/not-found');
-    $request->headers->set('Accept', 'application/json');
-    $request->setFormat('json', ['application/json']);
+    $request->query->set('_format', 'json');
+    $request->setRequestFormat('json');
 
     /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */
     $kernel = \Drupal::getContainer()->get('http_kernel');
@@ -73,7 +73,6 @@ public function testJson404() {
    */
   public function testHtml403() {
     $request = Request::create('/router_test/test15');
-    $request->headers->set('Accept', 'text/html');
     $request->setFormat('html', ['text/html']);
 
     /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */
@@ -89,7 +88,6 @@ public function testHtml403() {
    */
   public function testHtml404() {
     $request = Request::create('/not-found');
-    $request->headers->set('Accept', 'text/html');
     $request->setFormat('html', ['text/html']);
 
     /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */
@@ -108,7 +106,6 @@ public function testBacktraceEscaping() {
     $this->config('system.logging')->set('error_level', ERROR_REPORTING_DISPLAY_VERBOSE)->save();
 
     $request = Request::create('/router_test/test17');
-    $request->headers->set('Accept', 'text/html');
     $request->setFormat('html', ['text/html']);
 
     /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */
diff --git a/core/modules/system/src/Tests/System/ResponseGeneratorTest.php b/core/modules/system/src/Tests/System/ResponseGeneratorTest.php
index 061f7f9bfeda..8684624b3e9b 100644
--- a/core/modules/system/src/Tests/System/ResponseGeneratorTest.php
+++ b/core/modules/system/src/Tests/System/ResponseGeneratorTest.php
@@ -62,7 +62,7 @@ function testGeneratorHeaderAdded() {
     $this->enableService('entity:node', 'GET', 'json');
 
     // Tests to see if this also works for a non-html request
-    $this->httpRequest($node->urlInfo(), 'GET', NULL, 'application/json');
+    $this->httpRequest($node->urlInfo()->setOption('query', ['_format' => 'json']), 'GET');
     $this->assertResponse(200);
     $this->assertEqual('application/json', $this->drupalGetHeader('Content-Type'));
     $this->assertEqual($expectedGeneratorHeader, $this->drupalGetHeader('X-Generator'));
diff --git a/core/modules/system/tests/modules/accept_header_routing_test/accept_header_routing_test.info.yml b/core/modules/system/tests/modules/accept_header_routing_test/accept_header_routing_test.info.yml
new file mode 100644
index 000000000000..d93e67b2e32f
--- /dev/null
+++ b/core/modules/system/tests/modules/accept_header_routing_test/accept_header_routing_test.info.yml
@@ -0,0 +1,3 @@
+name: Accept header based routing test
+core: 8.x
+type: module
diff --git a/core/modules/system/tests/modules/accept_header_routing_test/accept_header_routing_test.services.yml b/core/modules/system/tests/modules/accept_header_routing_test/accept_header_routing_test.services.yml
new file mode 100644
index 000000000000..dc0ce1087ebd
--- /dev/null
+++ b/core/modules/system/tests/modules/accept_header_routing_test/accept_header_routing_test.services.yml
@@ -0,0 +1,5 @@
+services:
+  accept_header_matcher:
+    class: Drupal\accept_header_routing_test\Routing\AcceptHeaderMatcher
+    tags:
+      - { name: route_filter }
diff --git a/core/modules/system/tests/modules/accept_header_routing_test/src/AcceptHeaderMiddleware.php b/core/modules/system/tests/modules/accept_header_routing_test/src/AcceptHeaderMiddleware.php
new file mode 100644
index 000000000000..51daedb7e79e
--- /dev/null
+++ b/core/modules/system/tests/modules/accept_header_routing_test/src/AcceptHeaderMiddleware.php
@@ -0,0 +1,47 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\accept_header_routing_test\AcceptHeaderMiddleware.
+ */
+
+namespace Drupal\accept_header_routing_test;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+
+/**
+ * Example implementation of accept header based content negotation.
+ */
+class AcceptHeaderMiddleware implements HttpKernelInterface {
+
+  /**
+   * Constructs a new AcceptHeaderMiddleware instance.
+   *
+   * @param \Symfony\Component\HttpKernel\HttpKernelInterface $app
+   *   The app.
+   */
+  public function __construct(HttpKernelInterface $app) {
+    $this->app = $app;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
+    $mapping = [
+      'application/json' => 'json',
+      'application/hal+json' => 'hal_json',
+      'application/xml' => 'xml',
+      'text/html' => 'html',
+    ];
+
+    $accept = $request->headers->get('Accept') ?: ['text/html'];
+    if (isset($mapping[$accept[0]])) {
+      $request->setRequestFormat($mapping[$accept[0]]);
+    }
+
+    return $this->app->handle($request, $type, $catch);
+  }
+
+}
diff --git a/core/modules/system/tests/modules/accept_header_routing_test/src/AcceptHeaderRoutingTestServiceProvider.php b/core/modules/system/tests/modules/accept_header_routing_test/src/AcceptHeaderRoutingTestServiceProvider.php
new file mode 100644
index 000000000000..e1cab64743ad
--- /dev/null
+++ b/core/modules/system/tests/modules/accept_header_routing_test/src/AcceptHeaderRoutingTestServiceProvider.php
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\accept_header_routing_test\AcceptHeaderRoutingTestServiceProvider.
+ */
+
+namespace Drupal\accept_header_routing_test;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceModifierInterface;
+
+/**
+ * Service provider for the accept_header_routing_test module.
+ */
+class AcceptHeaderRoutingTestServiceProvider implements ServiceModifierInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    // Remove the basic content negotation middleware and replace it with a
+    // basic header based one.
+    $container->register('http_middleware.negotiation', 'Drupal\accept_header_routing_test\AcceptHeaderMiddleware')
+      ->addTag('http_middleware', ['priority' => 400]);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Routing/AcceptHeaderMatcher.php b/core/modules/system/tests/modules/accept_header_routing_test/src/Routing/AcceptHeaderMatcher.php
similarity index 93%
rename from core/lib/Drupal/Core/Routing/AcceptHeaderMatcher.php
rename to core/modules/system/tests/modules/accept_header_routing_test/src/Routing/AcceptHeaderMatcher.php
index 779480063f88..0f52fba23e44 100644
--- a/core/lib/Drupal/Core/Routing/AcceptHeaderMatcher.php
+++ b/core/modules/system/tests/modules/accept_header_routing_test/src/Routing/AcceptHeaderMatcher.php
@@ -2,12 +2,13 @@
 
 /**
  * @file
- * Contains Drupal\Core\Routing\AcceptHeaderMatcher.
+ * Contains \Drupal\accept_header_routing_test\Routing\AcceptHeaderMatcher.
  */
 
-namespace Drupal\Core\Routing;
+namespace Drupal\accept_header_routing_test\Routing;
 
 use Drupal\Component\Utility\SafeMarkup;
+use Drupal\Core\Routing\RouteFilterInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException;
 use Symfony\Component\Routing\Route;
diff --git a/core/tests/Drupal/Tests/Core/Routing/AcceptHeaderMatcherTest.php b/core/modules/system/tests/modules/accept_header_routing_test/tests/Unit/AcceptHeaderMatcherTest.php
similarity index 91%
rename from core/tests/Drupal/Tests/Core/Routing/AcceptHeaderMatcherTest.php
rename to core/modules/system/tests/modules/accept_header_routing_test/tests/Unit/AcceptHeaderMatcherTest.php
index d0c811cafc78..4dc9ddf5fbd6 100644
--- a/core/tests/Drupal/Tests/Core/Routing/AcceptHeaderMatcherTest.php
+++ b/core/modules/system/tests/modules/accept_header_routing_test/tests/Unit/AcceptHeaderMatcherTest.php
@@ -2,12 +2,12 @@
 
 /**
  * @file
- * Contains Drupal\Tests\Core\Routing\AcceptHeaderMatcherTest.
+ * Contains \Drupal\Tests\accept_header_routing_test\Unit\Routing\AcceptHeaderMatcherTest.
  */
 
-namespace Drupal\Tests\Core\Routing;
+namespace Drupal\Tests\accept_header_routing_teste\Unit\Routing;
 
-use Drupal\Core\Routing\AcceptHeaderMatcher;
+use Drupal\accept_header_routing_test\Routing\AcceptHeaderMatcher;
 use Drupal\Tests\Core\Routing\RoutingFixtures;
 use Drupal\Tests\UnitTestCase;
 use Symfony\Component\HttpFoundation\Request;
@@ -22,14 +22,14 @@ class AcceptHeaderMatcherTest extends UnitTestCase {
   /**
    * A collection of shared fixture data for tests.
    *
-   * @var RoutingFixtures
+   * @var \Drupal\Tests\Core\Routing\RoutingFixtures
    */
   protected $fixtures;
 
   /**
    * The matcher object that is going to be tested.
    *
-   * @var \Drupal\Core\Routing\AcceptHeaderMatcher
+   * @var \Drupal\accept_header_routing_test\Routing\AcceptHeaderMatcher
    */
   protected $matcher;
 
diff --git a/core/modules/system/tests/modules/conneg_test/conneg_test.info.yml b/core/modules/system/tests/modules/conneg_test/conneg_test.info.yml
new file mode 100644
index 000000000000..07f4600b5c0d
--- /dev/null
+++ b/core/modules/system/tests/modules/conneg_test/conneg_test.info.yml
@@ -0,0 +1,6 @@
+name: Content negotiation test
+type: module
+description: 'Support testing content negotiation variations.'
+package: Core
+version: VERSION
+core: 8.x
diff --git a/core/modules/system/tests/modules/conneg_test/conneg_test.routing.yml b/core/modules/system/tests/modules/conneg_test/conneg_test.routing.yml
new file mode 100644
index 000000000000..bedd52efbb8f
--- /dev/null
+++ b/core/modules/system/tests/modules/conneg_test/conneg_test.routing.yml
@@ -0,0 +1,32 @@
+# Tests
+conneg.simpletest:
+  path: conneg/simple.json
+  defaults:
+    _controller: '\Drupal\conneg_test\Controller\TestController::simple'
+  requirements:
+    _access: 'TRUE'
+conneg.html:
+  path: conneg/html
+  defaults:
+    _controller: '\Drupal\conneg_test\Controller\TestController::html'
+  requirements:
+    _access: 'TRUE'
+conneg.simple_conneg:
+  path: conneg/html
+  defaults:
+    _controller: '\Drupal\conneg_test\Controller\TestController::format'
+  requirements:
+    _access: 'TRUE'
+    _format: 'json|xml'
+conneg.variable_with_period:
+  path: conneg/plugin/{plugin_id}
+  defaults:
+    _controller: '\Drupal\conneg_test\Controller\TestController::variable'
+  requirements:
+    _access: 'TRUE'
+conneg.full_content_negotiation:
+  path: conneg/negotiate
+  defaults:
+    _controller: '\Drupal\conneg_test\Controller\TestController::format'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/conneg_test/src/Controller/TestController.php b/core/modules/system/tests/modules/conneg_test/src/Controller/TestController.php
new file mode 100644
index 000000000000..c93a5a1d29af
--- /dev/null
+++ b/core/modules/system/tests/modules/conneg_test/src/Controller/TestController.php
@@ -0,0 +1,76 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\conneg_test\Controller\Test.
+ */
+
+namespace Drupal\conneg_test\Controller;
+
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Test controller for content negotation tests.
+ */
+class TestController {
+
+  /**
+   * Returns a json response.
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   */
+  public function simple() {
+    return new JsonResponse(['some' => 'data']);
+  }
+
+  /**
+   * Returns a simple render array.
+   *
+   * @return array
+   */
+  public function html() {
+    return [
+      '#markup' => 'here',
+    ];
+  }
+
+  /**
+   * Returns different responses dependening on the request format.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   The response.
+   */
+  public function format(Request $request) {
+    switch ($request->getRequestFormat()) {
+      case 'json':
+        return new JsonResponse(['some' => 'data']);
+
+      case 'xml':
+        return new Response('<xml></xml>', Response::HTTP_OK, ['Content-Type' => 'application/xml']);
+
+      default:
+        return new Response($request->getRequestFormat());
+    }
+  }
+
+  /**
+   * Returns a render array depending on some passed in value.
+   *
+   * @param string $plugin_id
+   *   The plugin ID.
+   *
+   * @return array
+   *   The render array
+   */
+  public function variable($plugin_id) {
+    return [
+      '#markup' => $plugin_id,
+    ];
+  }
+
+}
diff --git a/core/modules/system/tests/modules/system_test/src/Controller/PageCacheAcceptHeaderController.php b/core/modules/system/tests/modules/system_test/src/Controller/PageCacheAcceptHeaderController.php
index 43cded1d98c1..c24d7c51429b 100644
--- a/core/modules/system/tests/modules/system_test/src/Controller/PageCacheAcceptHeaderController.php
+++ b/core/modules/system/tests/modules/system_test/src/Controller/PageCacheAcceptHeaderController.php
@@ -25,7 +25,7 @@ class PageCacheAcceptHeaderController {
    * @return mixed
    */
   public function content(Request $request) {
-    if ($request->headers->get('Accept') == 'application/json') {
+    if ($request->getRequestFormat() === 'json') {
       return new JsonResponse(array('content' => 'oh hai this is json'));
     }
     else {
diff --git a/core/modules/views_ui/src/Tests/DisplayTest.php b/core/modules/views_ui/src/Tests/DisplayTest.php
index a735c219bf4f..20667de6f7fc 100644
--- a/core/modules/views_ui/src/Tests/DisplayTest.php
+++ b/core/modules/views_ui/src/Tests/DisplayTest.php
@@ -188,7 +188,7 @@ public function testPageContextualLinks() {
     // Get server-rendered contextual links.
     // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks()
     $post = array('ids[0]' => $id);
-    $response = $this->drupalPost('contextual/render', 'application/json', $post, array('query' => array('destination' => 'test-display')));
+    $response = $this->drupalPostWithFormat('contextual/render', 'json', $post, array('query' => array('destination' => 'test-display')));
     $this->assertResponse(200);
     $json = Json::decode($response);
     $this->assertIdentical($json[$id], '<ul class="contextual-links"><li class="entityviewedit-form"><a href="' . base_path() . 'admin/structure/views/view/test_display/edit/page_1">Edit view</a></li></ul>');
diff --git a/core/tests/Drupal/Tests/Core/ContentNegotiationTest.php b/core/tests/Drupal/Tests/Core/ContentNegotiationTest.php
index d5f3081a9740..9c54eae2b4b9 100644
--- a/core/tests/Drupal/Tests/Core/ContentNegotiationTest.php
+++ b/core/tests/Drupal/Tests/Core/ContentNegotiationTest.php
@@ -44,36 +44,13 @@ public function testAjaxIframeUpload() {
   }
 
   /**
-   * Tests the getContentType() method when a priority format is found.
-   *
-   * @dataProvider priorityFormatProvider
-   * @covers ::getContentType
-   */
-  public function testAPriorityFormatIsFound($priority, $format) {
-    $request = new Request();
-    $request->setFormat($format['format'], $format['mime_type']);
-    $request->headers->set('Accept', sprintf('%s,application/json', $format['mime_type']));
-
-    $this->assertSame($priority, $this->contentNegotiation->getContentType($request));
-  }
-
-  public function priorityFormatProvider()
-  {
-    return [
-      ['html', ['format' => 'html', 'mime_type' => 'text/html']],
-    ];
-  }
-
-  /**
-   * Tests the getContentType() method when no priority format is found but a valid one is found.
-   *
-   * @covers ::getContentType
+   * Tests the specifying a format via query parameters gets used.
    */
-  public function testNoPriorityFormatIsFoundButReturnsTheFirstValidOne() {
+  public function testFormatViaQueryParameter() {
     $request = new Request();
-    $request->headers->set('Accept', 'application/rdf+xml');
+    $request->query->set('_format', 'bob');
 
-    $this->assertSame('rdf', $this->contentNegotiation->getContentType($request));
+    $this->assertSame('bob', $this->contentNegotiation->getContentType($request));
   }
 
   /**
diff --git a/core/tests/Drupal/Tests/Core/Routing/RequestFormatRouteFilterTest.php b/core/tests/Drupal/Tests/Core/Routing/RequestFormatRouteFilterTest.php
new file mode 100644
index 000000000000..7c1db9661011
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Routing/RequestFormatRouteFilterTest.php
@@ -0,0 +1,85 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\Core\Routing\RequestFormatRouteFilterTest.
+ */
+
+namespace Drupal\Tests\Core\Routing;
+
+use Drupal\Core\Routing\RequestFormatRouteFilter;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Routing\RequestFormatRouteFilter
+ * @group Routing
+ */
+class RequestFormatRouteFilterTest extends UnitTestCase {
+
+  /**
+   * @covers ::applies
+   */
+  public function testAppliesWithoutFormat() {
+    $route_filter = new RequestFormatRouteFilter();
+    $route = new Route('/test');
+    $this->assertFalse($route_filter->applies($route));
+  }
+
+  /**
+   * @covers ::applies
+   */
+  public function testAppliesWithFormat() {
+    $route_filter = new RequestFormatRouteFilter();
+    $route = new Route('/test');
+    $route->setRequirement('_format', 'json');
+    $this->assertTrue($route_filter->applies($route));
+  }
+
+  /**
+   * @covers ::filter
+   */
+  public function testFilter() {
+    $route_filter = new RequestFormatRouteFilter();
+
+    $route_without_format = new Route('/test');
+    $route_with_format = $route = new Route('/test');
+    $route_with_format->setRequirement('_format', 'json');
+    $route_with_multiple_formats = $route = new Route('/test');
+    $route_with_multiple_formats->setRequirement('_format', 'json|xml');
+
+    $collection = new RouteCollection();
+    $collection->add('test_0', $route_without_format);
+    $collection->add('test_1', $route_with_format);
+    $collection->add('test_2', $route_with_multiple_formats);
+
+    $request = new Request();
+    $request->setRequestFormat('xml');
+    $collection = $route_filter->filter($collection, $request);
+
+    $this->assertCount(2, $collection);
+    $this->assertEquals(array_keys($collection->all())[0], 'test_2');
+    $this->assertEquals(array_keys($collection->all())[1], 'test_0');
+  }
+
+  /**
+   * @covers ::filter
+   * @expectedException \Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException
+   * @expectedExceptionMessage No route found for the specified format xml.
+   */
+  public function testNoRouteFound() {
+    $collection = new RouteCollection();
+    $route_with_format = $route = new Route('/test');
+    $route_with_format->setRequirement('_format', 'json');
+    $collection->add('test_0', $route_with_format);
+    $collection->add('test_1', clone $route_with_format);
+
+    $request = Request::create('test?_format=xml', 'GET');
+    $request->setRequestFormat('xml');
+    $route_filter = new RequestFormatRouteFilter();
+    $route_filter->filter($collection, $request);
+  }
+
+}
-- 
GitLab