10000 [WebLink] Add class to parse Link headers from HTTP responses · symfony/symfony@cb63475 · GitHub
[go: up one dir, main page]

Skip to content
8000

Commit cb63475

Browse files
committed
[WebLink] Add class to parse Link headers from HTTP responses
1 parent 0b4d21c commit cb63475

File tree

5 files changed

+189
-4
lines changed

5 files changed

+189
-4
lines changed

src/Symfony/Component/WebLink/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add `HttpHeaderParser` to read `Link` headers from HTTP responses
8+
49
4.4.0
510
-----
611

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\WebLink;
13+
14+
/**
15+
* Parse a list of HTTP Link headers into a list of Link instances.
16+
*
17+
* @see https://tools.ietf.org/html/rfc5988
18+
*
19+
* @author Jérôme Tamarelle <jerome@tamarelle.net>
20+
*/
21+
final class HttpHeaderParser
22+
{
23+
// Regex to match each link entry: <...>; param1=...; param2=...
24+
private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
25< 8000 code class="diff-text syntax-highlighted-line addition">+
26+
// Regex to match parameters: ; key[=value]
27+
private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
28+
29+
/**
30+
* @param string|string[] $headers Value of the "Link" HTTP header
31+
*/
32+
public function parse(string|array $headers): GenericLinkProvider
33+
{
34+
$headerString = is_array($headers) ? implode(',', $headers) : $headers;
35+
$links = new GenericLinkProvider();
36+
37+
if (!preg_match_all(self::LINK_PATTERN, $headerString, $matches, PREG_SET_ORDER)) {
38+
return $links;
39+
}
40+
41+
foreach ($matches as $match) {
42+
$href = $match[1];
43+
$paramsString = $match[2];
44+
45+
$params = [];
46+
if (preg_match_all(self::PARAM_PATTERN, $paramsString, $paramMatches, PREG_SET_ORDER)) {
47+
foreach ($paramMatches as $pm) {
48+
$key = $pm[1];
49+
if (isset($pm[2]) && $pm[2] !== '') {
50+
// Quoted value, unescape quotes
51+
$value = stripcslashes($pm[2]);
52+
} elseif (isset($pm[3]) && $pm[3] !== '') {
53+
$value = $pm[3];
54+
} else {
55+
$value = true;
56+
}
57+
// Handle multiple attributes with same name
58+
if (isset($params[$key])) {
59+
if (!is_array($params[$key])) {
60+
$params[$key] = [$params[$key]];
61+
}
62+
$params[$key][] = $value;
63+
} else {
64+
$params[$key] = $value;
65+
}
66+
}
67+
}
68+
69+
if (!isset($params['rel'])) {
70+
continue;
71+
}
72+
$rels = preg_split('/\s+/', trim($params['rel']));
73+
unset($params['rel']);
74+
75+
$link = new Link(array_shift($rels), $href);
76+
foreach ($rels as $r) {
77+
$link = $link->withRel($r);
78+
}
79+
foreach ($params as $k => $v) {
80+
$link = $link->withAttribute($k, $v);
81+
}
82+
$links = $links->withLink($link);
83+
}
84+
85+
return $links;
86+
}
87+
}
88+

src/Symfony/Component/WebLink/Link.php

+5
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ public function getAttributes(): array
180180
return $this->attributes;
181181
}
182182

183+
public function getAttribute(string $attribute): string|\Stringable|int|float|bool|array|null
184+
{
185+
return $this->attributes[$attribute] ?? null;
186+
}
187+
183188
public function withHref(string|\Stringable $href): static
184189
{
185190
$that = clone $this;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace Symfony\Component\WebLink\Tests;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use PHPUnit\Framework\TestCase;
7+
use Symfony\Component\WebLink\HttpHeaderParser;
8+
9+
class HttpHeaderParserTest extends TestCase
10+
{
11+
public function testParse()
12+
{
13+
$parser = new HttpHeaderParser();
14+
15+
$header = [
16+
'</1>; rel="prerender",</2>; rel="dns-prefetch"; pr="0.7",</3>; rel="preload"; as="script"',
17+
'</4>; rel="preload"; as="image"; nopush,</5>; rel="alternate next"; hreflang="fr"; hreflang="de"; title="Hello"'
18+
];
19+
$provider = $parser->parse($header);
20+
$links = $provider->getLinks();
21+
22+
self::assertCount(5, $links);
23+
24+
self::assertSame(['prerender'], $links[0]->getRels());
25+
self::assertSame('/1', $links[0]->getHref());
26+
self::assertSame(null, $links[0]->getAttribute('rel'));
27+
28+
self::assertSame(['dns-prefetch'], $links[1]->getRels());
29+
self::assertSame('/2', $links[1]->getHref());
30+
self::assertSame('0.7', $links[1]->getAttribute('pr'));
31+
32+
self::assertSame(['preload'], $links[2]->getRels());
33+
self::assertSame('/3', $links[2]->getHref());
34+
self::assertSame('script', $links[2]->getAttribute('as'));
35+
self::assertSame(null, $links[2]->getAttribute('nopush'));
36+
37+
self::assertSame(['preload'], $links[3]->getRels());
38+
self::assertSame('/4', $links[3]->getHref());
39+
self::assertSame('image', $links[3]->getAttribute('as'));
40+
self::assertSame(true, $links[3]->getAttribute('nopush'));
41+
42+
self::assertSame(['alternate', 'next'], $links[4]->getRels());
43+
self::assertSame('/5', $links[4]->getHref());
44+
self::assertSame(['fr', 'de'], $links[4]->getAttribute('hreflang'));
45+
self::assertSame('Hello', $links[4]->getAttribute('title'));
46+
}
47+
48+
public function testParseEmpty()
49+
{
50+
$parser = new HttpHeaderParser();
51+
$provider = $parser->parse('');
52+
self::assertCount(0, $provider->getLinks());
53+
}
54+
55+
/** @dataProvider provideHeaderParsingCases */
56+
#[DataProvider('provideHeaderParsingCases')]
57+
public function testParseVariousAttributes(string $header, array $expectedAttributes)
58+
{
59+
$parser = new HttpHeaderParser();
60+
$links = $parser->parse($header)->getLinks();
61+
62+
self::assertCount(1, $links);
63+
self::assertSame(['alternate'], $links[0]->getRels());
64+
self::assertSame('/foo', $links[0]->getHref());
65+
self::assertSame($expectedAttributes, $links[0]->getAttributes());
66+
}
67+
68+
public static function provideHeaderParsingCases()
69+
{
70+
yield 'double_quotes_in_attribute_value' => [
71+
'</foo>; rel="alternate"; title="\"escape me\" \"already escaped\" \"\"\""',
72+
['title' => '"escape me" "already escaped" """'],
73+
];
74+
75+
yield 'unquoted_attribute_value' => [
76+
'</foo>; rel=alternate; type=text/html',
77+
['type' => 'text/html'],
78+
];
79+
80+
yield 'attribute_with_punctuation' => [
81+
'</foo>; rel="alternate"; title=">; hello, world; test:case"',
82+
['title' => '>; hello, world; test:case'],
83+
];
84+
}
85+
}

src/Symfony/Component/WebLink/Tests/LinkTest.php

+6-4
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ public function testCanSetAndRetrieveValues()
2727
->withAttribute('me', 'you')
2828
;
2929

30-
$this->assertEquals('http://www.google.com', $link->getHref());
30+
$this->assertSame('http://www.google.com', $link->getHref());
3131
$this->assertContains('next', $link->getRels());
3232
$this->assertArrayHasKey('me', $link->getAttributes());
33-
$this->assertEquals('you', $link->getAttributes()['me']);
33+
$this->assertSame('you', $link->getAttributes()['me']);
34+
$this->assertSame('you', $link->getAttribute('me'));
3435
}
3536

3637
public function testCanRemoveValues()
@@ -44,9 +45,10 @@ public function testCanRemoveValues()
4445
$link = $link->withoutAttribute('me')
4546
->withoutRel('next');
4647

47-
$this->assertEquals('http://www.google.com', $link->getHref());
48+
$this->assertSame('http://www.google.com', $link->getHref());
4849
$this->assertFalse(\in_array('next', $link->getRels(), true));
4950
$this->assertArrayNotHasKey('me', $link->getAttributes());
51+
$this->assertNull($link->getAttribute('me'));
5052
}
5153

5254
public function testMultipleRels()
@@ -65,7 +67,7 @@ public function testConstructor()
6567
{
6668
$link = new Link('next', 'http://www.google.com');
6769

68-
$this->assertEquals('http://www.google.com', $link->getHref());
70+
$this->assertSame('http://www.google.com', $link->getHref());
6971
$this->assertContains('next', $link->getRels());
7072
}
7173

0 commit comments

Comments
 (0)
0