Browse Source

Merge pull request #16474 from cakephp/negotitation-required

Add fallbacks for `viewClasses` and a view to require negotiation.
othercorey 4 years ago
parent
commit
e663fd338c

+ 5 - 3
src/Controller/Controller.php

@@ -34,6 +34,7 @@ use Cake\Http\ServerRequest;
 use Cake\Log\LogTrait;
 use Cake\ORM\Locator\LocatorAwareTrait;
 use Cake\Routing\Router;
+use Cake\View\View;
 use Cake\View\ViewVarsTrait;
 use Closure;
 use InvalidArgumentException;
@@ -830,11 +831,12 @@ class Controller implements EventListenerInterface, EventDispatcherInterface
         // Use accept header based negotiation.
         $contentType = new ContentTypeNegotiation();
         $preferredType = $contentType->preferredType($request, array_keys($typeMap));
-        if (!$preferredType) {
-            return null;
+        if ($preferredType) {
+            return $typeMap[$preferredType];
         }
 
-        return $typeMap[$preferredType];
+        // Use the match-all view if available or null for no decision.
+        return $typeMap[View::TYPE_MATCH_ALL] ?? null;
     }
 
     /**

+ 62 - 0
src/View/NegotiationRequiredView.php

@@ -0,0 +1,62 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\View;
+
+/**
+ * A view class that responds to any content-type and can be used to create
+ * an empty body 406 status code response.
+ *
+ * This is most useful when using content-type negotiation via `viewClasses()`
+ * in your controller. Add this View at the end of the acceptable View classes
+ * to require clients to pick an available content-type and that you have no
+ * default type.
+ */
+class NegotiationRequiredView extends View
+{
+    /**
+     * Get the content-type
+     *
+     * @return string
+     */
+    public static function contentType(): string
+    {
+        return static::TYPE_MATCH_ALL;
+    }
+
+    /**
+     * Initialization hook method.
+     *
+     * @return void
+     */
+    public function initialize(): void
+    {
+        $response = $this->getResponse()->withStatus(406);
+        $this->setResponse($response);
+    }
+
+    /**
+     * Renders view with no body and a 406 status code.
+     *
+     * @param string|null $template Name of template file to use
+     * @param string|false|null $layout Layout to use. False to disable.
+     * @return string Rendered content.
+     */
+    public function render(?string $template = null, $layout = null): string
+    {
+        return '';
+    }
+}

+ 9 - 1
src/View/View.php

@@ -315,6 +315,14 @@ class View implements EventDispatcherInterface
     public const PLUGIN_TEMPLATE_FOLDER = 'plugin';
 
     /**
+     * The magic 'match-all' content type that views can use to
+     * behave as a fallback during content-type negotiation.
+     *
+     * @var string
+     */
+    public const TYPE_MATCH_ALL = '_match_all_';
+
+    /**
      * Constructor
      *
      * @param \Cake\Http\ServerRequest|null $request Request instance.
@@ -375,7 +383,7 @@ class View implements EventDispatcherInterface
     protected function setContentType(): void
     {
         $viewContentType = $this->contentType();
-        if (!$viewContentType) {
+        if (!$viewContentType || $viewContentType == static::TYPE_MATCH_ALL) {
             return;
         }
         $response = $this->getResponse();

+ 18 - 0
tests/TestCase/Controller/ControllerTest.php

@@ -336,6 +336,24 @@ class ControllerTest extends TestCase
         $this->assertStringContainsString('hello world', $response->getBody() . '');
     }
 
+    /**
+     * Test that render() will do content negotiation when supported
+     * by the controller.
+     */
+    public function testRenderViewClassesContentNegotiationMatchAllType()
+    {
+        $request = new ServerRequest([
+            'url' => '/',
+            'environment' => ['HTTP_ACCEPT' => 'text/html'],
+        ]);
+        $controller = new ContentTypesController($request, new Response());
+        $controller->matchAll();
+        $response = $controller->render();
+        $this->assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type'), 'Default response type');
+        $this->assertEmpty($response->getBody() . '', 'Body should be empty');
+        $this->assertSame(406, $response->getStatusCode(), 'status code is wrong');
+    }
+
     public function testRenderViewClassesSetContentTypeHeader()
     {
         $request = new ServerRequest([

+ 8 - 0
tests/test_app/TestApp/Controller/ContentTypesController.php

@@ -17,6 +17,7 @@ declare(strict_types=1);
 namespace TestApp\Controller;
 
 use Cake\View\JsonView;
+use Cake\View\NegotiationRequiredView;
 use Cake\View\XmlView;
 use TestApp\View\PlainTextView;
 
@@ -42,6 +43,13 @@ class ContentTypesController extends AppController
         $this->viewBuilder()->setOption('serialize', ['data']);
     }
 
+    public function matchAll()
+    {
+        $this->viewClasses = [JsonView::class, XmlView::class, NegotiationRequiredView::class];
+        $this->set('data', ['hello', 'world']);
+        $this->viewBuilder()->setOption('serialize', ['data']);
+    }
+
     public function plain()
     {
         $this->viewClasses = [PlainTextView::class];