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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy