From e234ab2a295e608dd11ee58681f3c36c8353d55d Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Thu, 23 Jan 2025 23:08:57 +0100 Subject: [PATCH] Improve UriRenderer interface --- interfaces/Contracts/UriRenderer.php | 4 +- uri/FactoryTest.php | 93 ++++++++++++++++++++++++++++ uri/Uri.php | 59 +++++++++++++----- 3 files changed, 140 insertions(+), 16 deletions(-) diff --git a/interfaces/Contracts/UriRenderer.php b/interfaces/Contracts/UriRenderer.php index 838cb940..92777841 100644 --- a/interfaces/Contracts/UriRenderer.php +++ b/interfaces/Contracts/UriRenderer.php @@ -72,7 +72,7 @@ public function toHtmlAnchor(?string $linkTextTemplate = null, iterable $attribu /** * Returns the Link tag content for the current instance. * - * @param iterable $attributes an ordered map of key value. you must quote the value if needed + * @param iterable $attributes an ordered map of key value * * @throws DOMException */ @@ -81,7 +81,7 @@ public function toHtmlLink(iterable $attributes = []): string; /** * Returns the Link header content for a single item. * - * @param iterable $parameters an ordered map of key value. you must quote the value if needed + * @param iterable $parameters an ordered map of key value. * * @see https://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.6 */ diff --git a/uri/FactoryTest.php b/uri/FactoryTest.php index 794bcdc1..94d0eb73 100644 --- a/uri/FactoryTest.php +++ b/uri/FactoryTest.php @@ -637,4 +637,97 @@ public static function provideAnchorTagMarkdown(): iterable 'expected' => 'https://example.com/foobar', ]; } + + #[Test] + #[DataProvider('provideInvalidMarkdown')] + public function it_fails_to_parse_an_invalid_markdown(string $html): void + { + $this->expectException(InvalidArgumentException::class); + Uri::fromMarkdownAnchor($html); + } + + public static function provideInvalidMarkdown(): iterable + { + yield 'missing markdown placeholder' => ['html' => 'this is **markdown**']; + yield 'invalid markdown placeholder; missing URI part' => ['html' => '[this is an imcomplete] http://example.com markdown']; + yield 'invalid markdown placeholder; missing content part' => ['html' => 'this is an imcomplete(http://example.com) markdown']; + } + + #[Test] + #[DataProvider('provideHeaderLinkValue')] + public function it_parses_uri_string_from_an_link_header_value(string $html, ?string $baseUri, string $expected): void + { + self::assertSame($expected, Uri::fromHeaderLinkValue($html, $baseUri)->toString()); + } + + public static function provideHeaderLinkValue(): iterable + { + yield 'empty string' => [ + 'html' => '<>; rel="previous"', + 'baseUri' => null, + 'expected' => '', + ]; + + yield 'empty string with base URI' => [ + 'html' => '<>; rel="next"', + 'baseUri' => 'https://example.com/', + 'expected' => 'https://example.com/', + ]; + + yield 'URI with base URI' => [ + 'html' => '; rel="stylesheet"', + 'baseUri' => 'https://www.example.com', + 'expected' => 'https://www.example.com/style.css', + ]; + + yield 'multiple anchor tag' => [ + 'html' => '; rel="stylesheet", ; rel="stylesheet"', + 'baseUri' => 'https://example.com/', + 'expected' => 'https://example.com/style.css', + ]; + } + + #[Test] + #[DataProvider('provideInvalidHeaderLinkValue')] + public function it_fails_to_parse_an_invalid_http_header_link_with_invalid_characters(string $html): void + { + $this->expectException(InvalidArgumentException::class); + + Uri::fromHeaderLinkValue($html); + } + + public static function provideInvalidHeaderLinkValue(): iterable + { + yield 'header value with invalid characters' => ['html' => '; title="stylesheet"'."\r"]; + yield 'header value with missing URI part' => ['html' => '; rel="stylesheet"']; + yield 'header value with missing semicolon' => ['html' => ' title="stylesheet"']; + yield 'header value with missing parameters' => ['html' => '']; + yield 'header value with missing rel parameter' => ['html' => ' title="stylesheet"']; + } + + #[Test] + #[DataProvider('provideInvalidUri')] + public function it_fails_to_parse_with_new(Stringable|string|null $uri): void + { + self::assertNull(Uri::tryNew($uri)); + } + + public static function provideInvalidUri(): iterable + { + yield 'null value' => ['uri' => null]; + yield 'invalid URI' => ['uri' => 'http://example.com/ ']; + } + + #[Test] + #[DataProvider('provideInvalidUriForResolution')] + public function it_fails_to_parse_with_parse(Stringable|string $uri, Stringable|string|null $baseUri): void + { + self::assertNull(Uri::parse($uri, $baseUri)); + } + + public static function provideInvalidUriForResolution(): iterable + { + yield 'invalid URI' => ['uri' => ':', 'baseUri' => null]; + yield 'invalid resolution with a non absolute URI' => ['uri' => '', 'baseUri' => '/absolute/path']; + } } diff --git a/uri/Uri.php b/uri/Uri.php index f71c0d54..c4eecfcd 100644 --- a/uri/Uri.php +++ b/uri/Uri.php @@ -240,6 +240,8 @@ final class Uri implements Conditionable, UriInterface, UriRenderer, UriInspecto /** @var array */ private const WHATWG_SPECIAL_SCHEMES = ['ftp' => 1, 'http' => 1, 'https' => 1, 'ws' => 1, 'wss' => 1]; + private const ABOUT_BLANK = 'about:blank'; + private readonly ?string $scheme; private readonly ?string $user; private readonly ?string $pass; @@ -666,8 +668,8 @@ public static function fromUnixPath(Stringable|string $path): self */ public static function fromWindowsPath(Stringable|string $path): self { - $path = (string) $path; $root = ''; + $path = (string) $path; if (1 === preg_match(self::REGEXP_WINDOW_PATH, $path, $matches)) { $root = substr($matches['root'], 0, -1).':'; $path = substr($path, strlen($root)); @@ -731,26 +733,51 @@ public static function fromMarkdownAnchor(Stringable|string $markdown, Stringabl throw new SyntaxError('The markdown string `'.$markdown.'` is not valid anchor markdown tag.'); } - if (null === $baseUri) { - return self::new($matches['uri']); + if (null !== $baseUri) { + $baseUri = (string) $baseUri; } - return self::fromBaseUri($matches['uri'], $baseUri); + return match ($baseUri) { + self::ABOUT_BLANK, null => self::new($matches['uri']), + default => self::fromBaseUri($matches['uri'], $baseUri), + }; } public static function fromHeaderLinkValue(Stringable|string $headerValue, Stringable|string|null $baseUri = null): self { - static $regexp = '/<(?.*?)>.*/'; - $headerValue = rtrim((string) $headerValue); + $headerValue = (string) $headerValue; + if ( + 1 === preg_match("/(?:(?:(?.*?)>(?.*)/'; if (1 !== preg_match($regexp, $headerValue, $matches)) { - throw new SyntaxError('As per RFC8288, the URI must be defined inside two `<>` characters.'); + throw new InvalidArgumentException('As per RFC8288, the URI must be defined inside two `<>` characters.'); } - if (null === $baseUri) { - return self::new($matches['uri']); + $attributes = []; + if (false !== preg_match_all('/;\s*(?\w*)\*?="(?[^"]*)"/', $matches['parameters'], $attrMatches, PREG_SET_ORDER)) { + foreach ($attrMatches as $attrMatch) { + $attributes[$attrMatch['name']] = $attrMatch['value']; + } + } + + if (!isset($attributes['rel'])) { + throw new SyntaxError('The `rel` attribute must be defined.'); + } + + if (null !== $baseUri) { + $baseUri = (string) $baseUri; } - return self::fromBaseUri($matches['uri'], $baseUri); + return match ($baseUri) { + self::ABOUT_BLANK, null => self::new($matches['uri']), + default => self::fromBaseUri($matches['uri'], $baseUri), + }; } /** @@ -1181,7 +1208,7 @@ public function toHtmlLink(iterable $attributes = []): string /** * Returns the Link header content for a single item. * - * @param iterable $parameters an ordered map of key value. you must quote the value if needed + * @param iterable $parameters an ordered map of key value. * * @see https://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.6 */ @@ -1189,7 +1216,7 @@ public function toHeaderLinkValue(iterable $parameters = []): string { $attributes = []; foreach ($parameters as $name => $val) { - if (null !== $val) { + if (null !== $val && false !== $val) { $attributes[] = $this->formatHeaderValueParameter($name, $val); } } @@ -1929,9 +1956,13 @@ private static function parseHtml( $uri = $tag->getAttribute($attributeName); + if (null !== $baseUri) { + $baseUri = (string) $baseUri; + } + return match (true) { - null !== $baseUri => self::fromBaseUri($uri, $baseUri), - null !== $dom->documentURI && 'about:blank' !== $dom->documentURI => self::fromBaseUri($uri, $dom->documentURI), + null !== $baseUri && self::ABOUT_BLANK !== $baseUri => self::fromBaseUri($uri, $baseUri), + null !== $dom->documentURI && self::ABOUT_BLANK !== $dom->documentURI => self::fromBaseUri($uri, $dom->documentURI), default => self::new($uri), }; }