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

Skip to content

Commit 5a7a0d4

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

File tree

5 files changed

+185
-0
lines changed
  • Tests
  • 5 files changed

    +185
    -0
    lines changed

    src/Symfony/Component/WebLink/CHANGELOG.md

    Lines 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

    Lines 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

    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: 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

    Lines 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