Browse Source

4.next: introduce HeaderUtility helper class (#17084)

* add getParsedLink method to Cake\Http\Response class

* add extended attribute value support

* introduce HeaderParser helper class

* add WWW-Authenticate to HeaderParser

* improve tests

* improve wwwAuthenticate parsing

* rename HeaderParser to HeaderUtility

* Rename methods to be more descriptive.

* Remove unnecessary array nesting.

---------

Co-authored-by: ADmad <admad.coder@gmail.com>
Kevin Pfeifer 3 years ago
parent
commit
b76e09d1e0

+ 5 - 11
src/Http/Client/Auth/Digest.php

@@ -17,6 +17,7 @@ namespace Cake\Http\Client\Auth;
 
 use Cake\Http\Client;
 use Cake\Http\Client\Request;
+use Cake\Http\HeaderUtility;
 use Cake\Utility\Hash;
 
 /**
@@ -156,19 +157,12 @@ class Digest
             ['auth' => ['type' => null]]
         );
 
-        if (!$response->getHeader('WWW-Authenticate')) {
+        $header = $response->getHeader('WWW-Authenticate');
+        if (!$header) {
             return [];
         }
-        preg_match_all(
-            '@(\w+)=(?:(?:")([^"]+)"|([^\s,$]+))@',
-            $response->getHeaderLine('WWW-Authenticate'),
-            $matches,
-            PREG_SET_ORDER
-        );
-
-        foreach ($matches as $match) {
-            $credentials[$match[1]] = $match[3] ?? $match[2];
-        }
+        $matches = HeaderUtility::parseWwwAuthenticate($header[0]);
+        $credentials = array_merge($credentials, $matches);
 
         if (($this->isSessAlgorithm || !empty($credentials['qop'])) && empty($credentials['nc'])) {
             $credentials['nc'] = 1;

+ 1 - 31
src/Http/ContentTypeNegotiation.php

@@ -51,37 +51,7 @@ class ContentTypeNegotiation
      */
     protected function parseQualifiers(string $header): array
     {
-        $accept = [];
-        if (!$header) {
-            return $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;
+        return HeaderUtility::parseAccept($header);
     }
 
     /**

+ 125 - 0
src/Http/HeaderUtility.php

@@ -0,0 +1,125 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\Http;
+
+/**
+ * Provides helper methods related to HTTP headers
+ */
+class HeaderUtility
+{
+    /**
+     * Get an array representation of the HTTP Link header values.
+     *
+     * @param array $linkHeaders An array of Link header strings.
+     * @return array
+     */
+    public static function parseLinks(array $linkHeaders): array
+    {
+        $result = [];
+        foreach ($linkHeaders as $linkHeader) {
+            $result[] = static::parseLinkItem($linkHeader);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Parses one item of the HTTP link header into an array
+     *
+     * @param string $value The HTTP Link header part
+     * @return array<string, mixed>
+     */
+    protected static function parseLinkItem(string $value): array
+    {
+        preg_match('/<(.*)>[; ]?[; ]?(.*)?/i', $value, $matches);
+
+        $url = $matches[1];
+        $parsedParams = ['link' => $url];
+
+        $params = $matches[2];
+        if ($params) {
+            $explodedParams = explode(';', $params);
+            foreach ($explodedParams as $param) {
+                $explodedParam = explode('=', $param);
+                $trimedKey = trim($explodedParam[0]);
+                $trimedValue = trim($explodedParam[1], '"');
+                if ($trimedKey === 'title*') {
+                    // See https://www.rfc-editor.org/rfc/rfc8187#section-3.2.3
+                    preg_match('/(.*)\'(.*)\'(.*)/i', $trimedValue, $matches);
+                    $trimedValue = [
+                        'language' => $matches[2],
+                        'encoding' => $matches[1],
+                        'value' => urldecode($matches[3]),
+                    ];
+                }
+                $parsedParams[$trimedKey] = $trimedValue;
+            }
+        }
+
+        return $parsedParams;
+    }
+
+    /**
+     * Parse the Accept header value into weight => value mapping.
+     *
+     * @param string $header The header value to parse
+     * @return array<string, array<string>>
+     */
+    public static function parseAccept(string $header): array
+    {
+        $accept = [];
+        if (!$header) {
+            return $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;
+    }
+
+    /**
+     * @param string $value The WWW-Authenticate header
+     * @return array
+     */
+    public static function parseWwwAuthenticate(string $value): array
+    {
+        preg_match_all(
+            '@(\w+)=(?:(?:")([^"]+)"|([^\s,$]+))@',
+            $value,
+            $matches,
+            PREG_SET_ORDER
+        );
+
+        $return = [];
+        foreach ($matches as $match) {
+            $return[$match[1]] = $match[3] ?? $match[2];
+        }
+
+        return $return;
+    }
+}

+ 128 - 0
tests/TestCase/Http/HeaderUtilityTest.php

@@ -0,0 +1,128 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\Test\TestCase\Http;
+
+use Cake\Http\HeaderUtility;
+use Cake\Http\Response;
+use Cake\Http\ServerRequest;
+use Cake\TestSuite\TestCase;
+
+class HeaderUtilityTest extends TestCase
+{
+    /**
+     * Tests getting a parsed representation of a Link header
+     */
+    public function testParseLinks(): void
+    {
+        $response = new Response();
+        $this->assertFalse($response->hasHeader('Link'));
+
+        $new = $response->withAddedLink('http://example.com');
+        $this->assertSame('<http://example.com>', $new->getHeaderLine('Link'));
+        $expected = [
+            ['link' => 'http://example.com'],
+        ];
+        $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link')));
+
+        $new = $response->withAddedLink('http://example.com/苗条');
+        $this->assertSame('<http://example.com/苗条>', $new->getHeaderLine('Link'));
+        $expected = [
+            ['link' => 'http://example.com/苗条'],
+        ];
+        $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link')));
+
+        $new = $response->withAddedLink('http://example.com', ['rel' => 'prev']);
+        $this->assertSame('<http://example.com>; rel="prev"', $new->getHeaderLine('Link'));
+        $expected = [
+            [
+                'link' => 'http://example.com',
+                'rel' => 'prev',
+            ],
+        ];
+        $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link')));
+
+        $new = $response->withAddedLink('http://example.com', ['rel' => 'prev', 'results' => 'true']);
+        $this->assertSame('<http://example.com>; rel="prev"; results="true"', $new->getHeaderLine('Link'));
+        $expected = [
+            [
+                'link' => 'http://example.com',
+                'rel' => 'prev',
+                'results' => 'true',
+            ],
+        ];
+        $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link')));
+
+        $new = $response
+            ->withAddedLink('http://example.com/1', ['rel' => 'prev'])
+            ->withAddedLink('http://example.com/3', ['rel' => 'next']);
+        $this->assertSame('<http://example.com/1>; rel="prev",<http://example.com/3>; rel="next"', $new->getHeaderLine('Link'));
+        $expected = [
+            [
+                'link' => 'http://example.com/1',
+                'rel' => 'prev',
+            ],
+            [
+                'link' => 'http://example.com/3',
+                'rel' => 'next',
+            ],
+        ];
+        $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link')));
+
+        $encodedLinkHeader = '</extended-attr-example>; rel=start; title*=UTF-8\'en\'%E2%91%A0%E2%93%AB%E2%85%93%E3%8F%A8%E2%99%B3%F0%9D%84%9E%CE%BB';
+        $new = $response
+            ->withHeader('Link', $encodedLinkHeader);
+        $this->assertSame($encodedLinkHeader, $new->getHeaderLine('Link'));
+        $expected = [
+            [
+                'link' => '/extended-attr-example',
+                'rel' => 'start',
+                'title*' => [
+                    'language' => 'en',
+                    'encoding' => 'UTF-8',
+                    'value' => '①⓫⅓㏨♳𝄞λ',
+                ],
+            ],
+        ];
+        $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link')));
+    }
+
+    public function testParseAccept(): void
+    {
+        $request = new ServerRequest([
+            'url' => '/dashboard',
+            'environment' => [
+                'HTTP_ACCEPT' => 'application/json;q=0.5,application/xml;q=0.6,application/pdf;q=0.3',
+            ],
+        ]);
+        $result = HeaderUtility::parseAccept($request->getHeaderLine('Accept'));
+        $expected = [
+            '0.6' => ['application/xml'],
+            '0.5' => ['application/json'],
+            '0.3' => ['application/pdf'],
+        ];
+        $this->assertEquals($expected, $result);
+    }
+
+    public function testParseWwwAuthenticate(): void
+    {
+        $result = HeaderUtility::parseWwwAuthenticate('Digest realm="The batcave",nonce="4cded326c6c51"');
+        $expected = [
+            'realm' => 'The batcave',
+            'nonce' => '4cded326c6c51',
+        ];
+        $this->assertEquals($expected, $result);
+    }
+
+    public function testWwwAuthenticateWithAlgo(): void
+    {
+        $result = HeaderUtility::parseWwwAuthenticate('Digest qop="auth", realm="shellyplus1pm-44179393e8a8", nonce="63f8c86f", algorithm=SHA-256');
+        $expected = [
+            'qop' => 'auth',
+            'realm' => 'shellyplus1pm-44179393e8a8',
+            'nonce' => '63f8c86f',
+            'algorithm' => 'SHA-256',
+        ];
+        $this->assertEquals($expected, $result);
+    }
+}