Browse Source

Add ContentTypeNegotiation

This new utility library extracts logic from ServerRequest and
RequestHandlerComponent into a more re-usable form. This is also
necessary work to unlock moving view class selection into the
Controller.
Mark Story 4 years ago
parent
commit
604506995d
2 changed files with 231 additions and 0 deletions
  1. 110 0
      src/Http/ContentTypeNegotiation.php
  2. 121 0
      tests/TestCase/Http/ContentTypeNegotiationTest.php

+ 110 - 0
src/Http/ContentTypeNegotiation.php

@@ -0,0 +1,110 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\Http;
+
+use Psr\Http\Message\RequestInterface;
+
+/**
+ * Negotiates the prefered content type from what the application
+ * provides and what the request has in its Accept header.
+ */
+class ContentTypeNegotiation
+{
+    /**
+     * Get the most preferred content type from a request.
+     *
+     * Parse the Accept header preferences and return the most
+     * preferred type. If multiple types are tied in preference
+     * the first type of that preference value will be returned.
+     *
+     * You can expect null when the request has no Accept header.
+     *
+     * @param Psr\Http\Message\RequestInterface $request
+     * @return string|null The prefered type.
+     */
+    public function prefers(RequestInterface $request): ?string
+    {
+        $accept = $request->getHeaderLine('Accept');
+        if (!$accept) {
+            return null;
+        }
+        $parsed = $this->parseAcceptWithQualifier($accept);
+        if (empty($parsed)) {
+            return null;
+        }
+        $types = array_shift($parsed);
+
+        return $types[0];
+    }
+
+    /**
+     * Perform content type negotiation with a list of valid choices.
+     *
+     * Choose a content-type from a list of values the application
+     * can provide. If there are no matches then `null` will be
+     * returned. You can also expect null when the request has
+     * no Accept header.
+     *
+     * @param Psr\Http\Message\RequestInterface $request The request to read an `Accept` header from.
+     * @param string[] $types The types that the application can respond with for the provided request.
+     * @return string|null Either the resolved type or `null` if no decision can be made.
+     */
+    public function prefersChoice(RequestInterface $request, array $types): ?string
+    {
+        $accept = $request->getHeaderLine('Accept');
+        if (!$accept) {
+            return null;
+        }
+        $parsed = $this->parseAcceptWithQualifier($accept);
+        foreach ($parsed as $qual => $acceptTypes) {
+            $common = array_intersect($acceptTypes, $types);
+            if ($common) {
+                return $common[0];
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Parse Accept* headers with qualifier options.
+     *
+     * Only qualifiers will be extracted, any other accept extensions will be
+     * discarded as they are not frequently used.
+     *
+     * @param string $header Header to parse.
+     * @return array
+     */
+    protected function parseAcceptWithQualifier(string $header): array
+    {
+        $accept = [];
+        $headers = explode(',', $header);
+        foreach (array_filter($headers) as $value) {
+            $prefValue = '1.0';
+            $value = trim($value);
+
+            $semiPos = strpos($value, ';');
+            if ($semiPos !== false) {
+                $params = explode(';', $value);
+                $value = trim($params[0]);
+                foreach ($params as $param) {
+                    $qPos = strpos($param, 'q=');
+                    if ($qPos !== false) {
+                        $prefValue = substr($param, $qPos + 2);
+                    }
+                }
+            }
+
+            if (!isset($accept[$prefValue])) {
+                $accept[$prefValue] = [];
+            }
+            if ($prefValue) {
+                $accept[$prefValue][] = $value;
+            }
+        }
+        krsort($accept);
+
+        return $accept;
+    }
+}

+ 121 - 0
tests/TestCase/Http/ContentTypeNegotiationTest.php

@@ -0,0 +1,121 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\Test\TestCase\Http;
+
+use Cake\Http\ContentTypeNegotiation;
+use Cake\Http\ServerRequest;
+use Cake\TestSuite\TestCase;
+
+class ContentTypeNegotiationTest extends TestCase
+{
+    public function testPrefersNoAccept()
+    {
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+        ]);
+        $content = new ContentTypeNegotiation();
+        $this->assertNull($content->prefers($request));
+
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => '',
+            ],
+        ]);
+        $this->assertNull($content->prefers($request));
+    }
+
+    public function testPrefersFirstMatch()
+    {
+        $content = new ContentTypeNegotiation();
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => 'application/json',
+            ],
+        ]);
+        $this->assertEquals('application/json', $content->prefers($request));
+
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => 'application/json,application/xml',
+            ],
+        ]);
+        $this->assertEquals('application/json', $content->prefers($request));
+    }
+
+    public function testPrefersQualValue()
+    {
+        $content = new ContentTypeNegotiation();
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5',
+            ],
+        ]);
+        $this->assertEquals('text/xml', $content->prefers($request));
+
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => 'text/plain;q=0.8,application/json;q=0.9',
+            ],
+        ]);
+        $this->assertEquals('application/json', $content->prefers($request));
+    }
+
+    public function testPrefersChoiceNoMatch()
+    {
+        $content = new ContentTypeNegotiation();
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => 'application/json',
+            ],
+        ]);
+        $this->assertNull($content->prefersChoice($request, ['text/html']));
+    }
+
+    public function testPrefersChoiceSimple()
+    {
+        $content = new ContentTypeNegotiation();
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => 'application/json',
+            ],
+        ]);
+        $this->assertEquals(
+            'application/json',
+            $content->prefersChoice($request, ['text/html', 'application/json'])
+        );
+    }
+
+    public function testPrefersChoiceQualValue()
+    {
+        $content = new ContentTypeNegotiation();
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => 'application/json;q=0.5,application/xml;q=0.6,application/pdf;q=0.3',
+            ],
+        ]);
+        $this->assertEquals(
+            'application/json',
+            $content->prefersChoice($request, ['text/html', 'application/json'])
+        );
+        $this->assertEquals(
+            'application/pdf',
+            $content->prefersChoice($request, ['text/html', 'application/pdf'])
+        );
+        $this->assertEquals(
+            'application/json',
+            $content->prefersChoice($request, ['application/json', 'application/pdf'])
+        );
+        $this->assertNull(
+            $content->prefersChoice($request, ['image/png', 'text/html'])
+        );
+    }
+}