Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 5a7a0d4

Browse filesBrowse files
committed
[WebLink] Add class to parse Link headers from HTTP responses
1 parent 0b4d21c commit 5a7a0d4
Copy full SHA for 5a7a0d4

File tree

5 files changed

+185
-0
lines changed
Filter options

5 files changed

+185
-0
lines changed

‎src/Symfony/Component/WebLink/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Component/WebLink/CHANGELOG.md
+5Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

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

+88Lines changed: 88 additions & 0 deletions
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+
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

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

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

Copy file name to clipboardExpand all lines: src/Symfony/Component/WebLink/Tests/LinkTest.php
+2Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function testCanSetAndRetrieveValues()
3131
$this->assertContains('next', $link->getRels());
3232
$this->assertArrayHasKey('me', $link->getAttributes());
3333
$this->assertEquals('you', $link->getAttributes()['me']);
34+
$this->assertEquals('you', $link->getAttribute('me'));
3435
}
3536

3637
public function testCanRemoveValues()
@@ -47,6 +48,7 @@ public function testCanRemoveValues()
4748
$this->assertEquals('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()

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.