8000 feature #60420 [WebLink] Add class to parse Link headers from HTTP re… · symfony/symfony@298e56a · GitHub
[go: up one dir, main page]

Skip to content

Commit 298e56a

Browse files
committed
feature #60420 [WebLink] Add class to parse Link headers from HTTP responses (GromNaN)
This PR was squashed before being merged into the 7.4 branch. Discussion ---------- [WebLink] Add class to parse Link headers from HTTP responses | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT Some HTTP API expose a Link header for pagination (See [GitHub API](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28#using-link-headers) or [Sentry API](https://docs.sentry.io/api/pagination/)), so it's necessary to parse this header to consume the API. Since we already have a WebLink component, I think it's a good fit to add the logic for parsing the HTTP header into this component. The existing packages use simplified pattern and does not support all the spec features: - https://github.com/kelunik/link-header-rfc5988/blob/master/src/functions.php - https://github.com/libgraviton/link-header-rel-parser/blob/develop/src/LinkHeader.php Commits ------- 461f793 [WebLink] Add class to parse Link headers from HTTP responses
2 parents 3c2566c + 461f793 commit 298e56a

File tree

8 files changed

+234
-6
lines changed

8 files changed

+234
-6
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@
216216
use Symfony\Component\Validator\ObjectInitializerInterface;
217217
use Symfony\Component\Validator\Validation;
218218
use Symfony\Component\Webhook\Controller\WebhookController;
219+
use Symfony\Component\WebLink\HttpHeaderParser;
219220
use Symfony\Component\WebLink\HttpHeaderSerializer;
220221
use Symfony\Component\Workflow;
221222
use Symfony\Component\Workflow\WorkflowInterface;
@@ -497,6 +498,11 @@ public function load(array $configs, ContainerBuilder $container): void
497498
}
498499

499500
$loader->load('web_link.php');
501+
502+
// Require symfony/web-link 7.4
503+
if (!class_exists(HttpHeaderParser::class)) {
504+
$container->removeDefinition('web_link.http_header_parser');
505+
}
500506
}
501507

502508
if ($this->readConfigEnabled('uid', $container, $config['uid'])) {

src/Symfony/Bundle/FrameworkBundle/Resources/config/web_link.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

1414
use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener;
15+
use Symfony\Component\WebLink\HttpHeaderParser;
1516
use Symfony\Component\WebLink\HttpHeaderSerializer;
1617

1718
return static function (ContainerConfigurator $container) {
@@ -20,6 +21,9 @@
2021
->set('web_link.http_header_serializer', HttpHeaderSerializer::class)
2122
->alias(HttpHeaderSerializer::class, 'web_link.http_header_serializer')
2223

24+
->set('web_link.http_header_parser', HttpHeaderParser::class)
25+
->alias(HttpHeaderParser::class, 'web_link.http_header_parser')
26+
2327
->set('web_link.add_link_header_listener', AddLinkHeaderListener::class)
2428
->args([
2529
service('web_link.http_header_serializer'),

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+
* Make `HttpHeaderSerializer` non-final
9+
410
4.4.0
511
-----
612

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
use Psr\Link\EvolvableLinkProviderInterface;
15+
16+
/**
17+
* Parse a list of HTTP Link headers into a list of Link instances.
18+
*
19+
* @see https://tools.ietf.org/html/rfc5988
20+
*
21+
* @author Jérôme Tamarelle <jerome@tamarelle.net>
22+
*/
23+
class HttpHeaderParser
24+
{
25+
// Regex to match each link entry: <...>; param1=...; param2=...
26+
private const LINK_PATTERN = '/<([^>]*)>\s*((?:\s*;\s*[a-zA-Z0-9\-_]+(?:\s*=\s*(?:"(?:[^"\\\\]|\\\\.)*"|[^";,\s]+))?)*)/';
27+
28+
// Regex to match parameters: ; key[=value]
29+
private const PARAM_PATTERN = '/;\s*([a-zA-Z0-9\-_]+)(?:\s*=\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^";,\s]+)))?/';
30+
31+
/**
32+
* @param string|string[] $headers Value of the "Link" HTTP header
33+
*/
34+
public function parse(string|array $headers): EvolvableLinkProviderInterface
35+
{
36+
if (is_array($headers)) {
37+
$headers = implode(', ', $headers);
38+
}
39+
$links = new GenericLinkProvider();
40+
41+
if (!preg_match_all(self::LINK_PATTERN, $headers, $matches, \PREG_SET_ORDER)) {
42+
return $links;
43+
}
44+
45+
foreach ($matches as $match) {
46+
$href = $match[1];
47+
$attributesString = $match[2];
48+
49+
$attributes = [];
50+
if (preg_match_all(self::PARAM_PATTERN, $attributesString, $attributeMatches, \PREG_SET_ORDER)) {
51+
$rels = null;
52+
foreach ($attributeMatches as $pm) {
53+
$key = $pm[1];
54+
$value = match (true) {
55+
// Quoted value, unescape quotes
56+
($pm[2] ?? '') !== '' => stripcslashes($pm[2]),
57+
($pm[3] ?? '') !== '' => $pm[3],
58+
// No value
59+
default => true,
60+
};
61+
62+
if ($key === 'rel') {
63+
// Only the first occurrence of the "rel" attribute is read
64+
$rels ??= $value === true ? [] : preg_split('/\s+/', $value, 0, \PREG_SPLIT_NO_EMPTY);
65+
} elseif (is_array($attributes[$key] ?? null)) {
66+
$attributes[$key][] = $value;
67+
} elseif (isset($attributes[$key])) {
68+
$attributes[$key] = [$attributes[$key], $value];
69+
} else {
70+
$attributes[$key] = $value;
71+
}
72+
}
73+
}
74+
75+
$link = new Link(null, $href);
76+
foreach ($rels ?? [] as $rel) {
77+
$link = $link->withRel($rel);
78+
}
79+
foreach ($attributes as $k => $v) {
80+
$link = $link->withAttribute($k, $v);
81+
}
82+
$links = $links->withLink($link);
83+
}
84+
85+
return $links;
86+
}
87+
}

src/Symfony/Component/WebLink/HttpHeaderSerializer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
*
2121
* @author Kévin Dunglas <dunglas@gmail.com>
2222
*/
23-
final class HttpHeaderSerializer
23+
class HttpHeaderSerializer
2424
{
2525
/**
2626
* Builds the value of the "Link" HTTP header.

src/Symfony/Component/WebLink/Link.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ class Link implements EvolvableLinkInterface
153153
private array $rel = [];
154154

155155
/**
156-
* @var array<string, string|bool|string[]>
156+
* @var array<string, scalar|\Stringable|list<scalar|\Stringable>>
157157
*/
158158
private array $attributes = [];
159159

@@ -181,6 +181,11 @@ public function getRels(): array
181181
return array_values($this->rel);
182182
}
183183

184+
/**
185+
* Returns a list of attributes that describe the target URI.
186+
*
187+
* @return array<string, scalar|\Stringable|list<scalar|\Stringable>>
188+
*/
184189
public function getAttributes(): array
185190
{
186191
return $this->attributes;
@@ -210,6 +215,14 @@ public function withoutRel(string $rel): static
210215
return $that;
211216
}
212217

218+
/**
219+
* Returns an instance with the specified attribute added.
220+
*
221+
* If the specified attribute is already present, it will be overwritten
222+
* with the new value.
223+
*
224+
* @param scalar|\Stringable|list<scalar|\Stringable> $value
225+
*/
213226
public function withAttribute(string $attribute, string|\Stringable|int|float|bool|array $value): static
214227
{
215228
$that = clone $this;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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([], $links[0]->getAttributes());
36+
37+
self::assertSame(['dns-prefetch'], $links[1]->getRels());
38+
self::assertSame('/2', $links[1]->getHref());
39+
self::assertSame(['pr' => '0.7'], $links[1]->getAttributes());
40+
41+
self::assertSame(['preload'], $links[2]->getRels());
42+
self::assertSame('/3', $links[2]->getHref());
43+
self::assertSame(['as' => 'script'], $links[2]->getAttributes());
44+
45+
self::assertSame(['preload'], $links[3]->getRels());
46+
self::assertSame('/4', $links[3]->getHref());
47+
self::assertSame(['as' => 'image', 'nopush' => true], $links[3]->getAttributes());
48+
49+
self::assertSame(['alternate', 'next'], $links[4]->getRels());
50+
self::assertSame('/5', $links[4]->getHref());
51+
self::assertSame(['hreflang' => ['fr', 'de'], 'title' => 'Hello'], $links[4]->getAttributes());
52+
}
53+
54+
public function testParseEmpty()
55+
{
56+
$parser = new HttpHeaderParser();
57+
$provider = $parser->parse('');
58+
self::assertCount(0, $provider->getLinks());
59+
}
60+
61+
/** @dataProvider provideHeaderParsingCases */
62+
#[DataProvider('provideHeaderParsingCases')]
63+
public function testParseVariousAttributes(string $header, array $expectedRels, array $expectedAttributes)
64+
{
65+
$parser = new HttpHeaderParser();
66+
$links = $parser->parse($header)->getLinks();
67+
68+
self::assertCount(1, $links);
69+
self::assertSame('/foo', $links[0]->getHref());
70+
self::assertSame($expectedRels, $links[0]->getRels());
71+
self::assertSame($expectedAttributes, $links[0]->getAttributes());
72+
}
73+
74+
public static function provideHeaderParsingCases()
75+
{
76+
yield 'double_quotes_in_attribute_value' => [
77+
'</foo>; rel="alternate"; title="\"escape me\" \"already escaped\" \"\"\""',
78+
['alternate'],
79+
['title' => '"escape me" "already escaped" """'],
80+
];
81+
82+
yield 'unquoted_attribute_value' => [
83+
'</foo>; rel=alternate; type=text/html',
84+
['alternate'],
85+
['type' => 'text/html'],
86+
];
87+
88+
yield 'attribute_with_punctuation' => [
89+
'</foo>; rel="alternate"; title=">; hello, world; test:case"',
90+
['alternate'],
91+
['title' => '>; hello, world; test:case'],
92+
];
93+
94+
yield 'no_rel' => [
95+
'</foo>; type=text/html',
96+
[],
97+
['type' => 'text/html'],
98+
];
99+
100+
yield 'empty_rel' => [
101+
'</foo>; rel',
102+
[],
103+
[],
104+
];
105+
106+
yield 'multiple_rel_attributes_get_first' => [
107+
'</foo>; rel="alternate" rel="next"',
108+
['alternate'],
109+
[],
110+
];
111+
}
112+
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ 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']);
3434
}
3535

3636
public function testCanRemoveValues()
@@ -44,7 +44,7 @@ public function testCanRemoveValues()
4444
$link = $link->withoutAttribute('me')
4545
->withoutRel('next');
4646

47-
$this->assertEquals('http://www.google.com', $link->getHref());
47+
$this->assertSame('http://www.google.com', $link->getHref());
4848
$this->assertFalse(\in_array('next', $link->getRels(), true));
4949
$this->assertArrayNotHasKey('me', $link->getAttributes());
5050
}
@@ -65,7 +65,7 @@ public function testConstructor()
6565
{
6666
$link = new Link('next', 'http://www.google.com');
6767

68-
$this->assertEquals('http://www.google.com', $link->getHref());
68+
$this->assertSame('http://www.google.com', $link->getHref());
6969
$this->assertContains('next', $link->getRels());
7070
}
7171

0 commit comments

Comments
 (0)
0