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

Skip to content

Commit 3a4e7fe

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

File tree

5 files changed

+195
-4
lines changed

5 files changed

+195
-4
lines changed

src/Symfony/Component/WebLink/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add `HttpHeaderParser` to read `Link` headers from HTTP responses
8+
* Add `Link::getAttribute($name)` to retrieve the value of a link attribute
9+
410
4.4.0
511
-----
612

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
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+
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+
$value = match (true) {
50+
// Quoted value, unescape quotes
51+
($pm[2] ?? '') !== '' => stripcslashes($pm[2]),
52+
($pm[3] ?? '') !== '' => $pm[3],
53+
default => true,
54+
};
55+
56+
if (is_array($params[$key] ?? null)) {
57+
$params[$key][] = $value;
58+
} elseif (isset($params[$key])) {
59+
$params[$key] = [$params[$key], $value];
60+
} else {
61+
$params[$key] = $value;
62+
}
63+
}
64+
}
65+
66+
if (!isset($params['rel'])) {
67+
continue;
68+
}
69+
$rels = preg_split('/\s+/', trim($params['rel']));
70+
unset($params['rel']);
71+
72+
$link = new Link(null, $href);
73+
foreach ($rels as $r) {
74+
$link = $link->withRel($r);
75+
}
76+
foreach ($params as $k => $v) {
77+
$link = $link->withAttribute($k, $v);
78+
}
79+
$links = $links->withLink($link);
80+
}
81+
82+
return $links;
83+
}
84+
}

src/Symfony/Component/WebLink/Link.php

Lines changed: 5 additions & 0 deletions
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;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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\Tests;
13+
14+
use PHPUnit\Framework\Attributes\DataProvider;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\WebLink\HttpHeaderParser;
17+
18+
class HttpHeaderParserTest extends TestCase
19+
{
20+
public function testParse()
21+
{
22+
$parser = new HttpHeaderParser();
23+
24+
$header = [
25+
'</1>; rel="prerender",</2>; rel="dns-prefetch"; pr="0.7",</3>; rel="preload"; as="script"',
26+
'</4>; rel="preload"; as="image"; nopush,</5>; rel="alternate next"; hreflang="fr"; hreflang="de"; title="Hello"'
27+
];
28+
$provider = $parser->parse($header);
29+
$links = $provider->getLinks();
30+
31+
self::assertCount(5, $links);
32+
33+
self::assertSame(['prerender'], $links[0]->getRels());
34+
self::assertSame('/1', $links[0]->getHref());
35+
self::assertSame(null, $links[0]->getAttribute('rel'));
36+
37+
self::assertSame(['dns-prefetch'], $links[1]->getRels());
38+
self::assertSame('/2', $links[1]->getHref());
39+
self::assertSame('0.7', $links[1]->getAttribute('pr'));
40+
41+
self::assertSame(['preload'], $links[2]->getRels());
42+
self::assertSame('/3', $links[2]->getHref());
43+
self::assertSame('script', $links[2]->getAttribute('as'));
44+
self::assertSame(null, $links[2]->getAttribute('nopush'));
45+
46+
self::assertSame(['preload'], $links[3]->getRels());
47+
self::assertSame('/4', $links[3]->getHref());
48+
self::assertSame('image', $links[3]->getAttribute('as'));
49+
self::assertSame(true, $links[3]->getAttribute('nopush'));
50+
51+
self::assertSame(['alternate', 'next'], $links[4]->getRels());
52+
self::assertSame('/5', $links[4]->getHref());
53+
self::assertSame(['fr', 'de'], $links[4]->getAttribute('hreflang'));
54+
self::assertSame('Hello', $links[4]->getAttribute('title'));
55+
}
56+
57+
public function testParseEmpty()
58+
{
59+
$parser = new HttpHeaderParser();
60+
$provider = $parser->parse('');
61+
self::assertCount(0, $provider->getLinks());
62+
}
63+
64+
/** @dataProvider provideHeaderParsingCases */
65+
#[DataProvider('provideHeaderParsingCases')]
66+
public function testParseVariousAttributes(string $header, array $expectedAttributes)
67+
{
68+
$parser = new HttpHeaderParser();
69+
$links = $parser->parse($header)->getLinks();
70+
71+
self::assertCount(1, $links);
72+
self::assertSame(['alternate'], $links[0]->getRels());
73+
self::assertSame('/foo', $links[0]->getHref());
74+
self::assertSame($expectedAttributes, $links[0]->getAttributes());
75+
}
76+
77+
public static function provideHeaderParsingCases()
78+
{
79+
yield 'double_quotes_in_attribute_value' => [
80+
'</foo>; rel="alternate"; title="\"escape me\" \"already escaped\" \"\"\""',
81+
['title' => '"escape me" "already escaped" """'],
82+
];
83+
84+
yield 'unquoted_attribute_value' => [
85+
'</foo>; rel=alternate; type=text/html',
86+
['type' => 'text/html'],
87+
];
88+
89+
yield 'attribute_with_punctuation' => [
90+
'</foo>; rel="alternate"; title=">; hello, world; test:case"',
91+
['title' => '>; hello, world; test:case'],
92+
];
93+
}
94+
}

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

Lines changed: 6 additions & 4 deletions
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