Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: add url library * fix
- Loading branch information
Showing
4 changed files
with
207 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
final class UrlException extends \Exception | ||
{ | ||
} | ||
|
||
/** | ||
* Intentionally restrictive url parser | ||
*/ | ||
final class Url | ||
{ | ||
private string $scheme; | ||
private string $host; | ||
private int $port; | ||
private string $path; | ||
private ?string $queryString; | ||
|
||
private function __construct() | ||
{ | ||
} | ||
|
||
public static function fromString(string $url): self | ||
{ | ||
if (!self::validate($url)) { | ||
throw new UrlException(sprintf('Illegal url: "%s"', $url)); | ||
} | ||
|
||
$parts = parse_url($url); | ||
if ($parts === false) { | ||
throw new UrlException(sprintf('Invalid url %s', $url)); | ||
} | ||
|
||
return (new self()) | ||
->withScheme($parts['scheme'] ?? '') | ||
->withHost($parts['host']) | ||
->withPort($parts['port'] ?? 80) | ||
->withPath($parts['path'] ?? '/') | ||
->withQueryString($parts['query'] ?? null); | ||
} | ||
|
||
public static function validate(string $url): bool | ||
{ | ||
if (strlen($url) > 1500) { | ||
return false; | ||
} | ||
$pattern = '#^https?://' // scheme | ||
. '([a-z0-9-]+\.?)+' // one or more domain names | ||
. '(\.[a-z]{1,24})?' // optional global tld | ||
. '(:\d+)?' // optional port | ||
. '($|/|\?)#i'; // end of string or slash or question mark | ||
|
||
return preg_match($pattern, $url) === 1; | ||
} | ||
|
||
public function getScheme(): string | ||
{ | ||
return $this->scheme; | ||
} | ||
|
||
public function getHost(): string | ||
{ | ||
return $this->host; | ||
} | ||
|
||
public function getPort(): int | ||
{ | ||
return $this->port; | ||
} | ||
|
||
public function getPath(): string | ||
{ | ||
return $this->path; | ||
} | ||
|
||
public function getQueryString(): string | ||
{ | ||
return $this->queryString; | ||
} | ||
|
||
public function withScheme(string $scheme): self | ||
{ | ||
if (!in_array($scheme, ['http', 'https'])) { | ||
throw new UrlException(sprintf('Invalid scheme %s', $scheme)); | ||
} | ||
$clone = clone $this; | ||
$clone->scheme = $scheme; | ||
return $clone; | ||
} | ||
|
||
public function withHost(string $host): self | ||
{ | ||
$clone = clone $this; | ||
$clone->host = $host; | ||
return $clone; | ||
} | ||
|
||
public function withPort(int $port) | ||
{ | ||
$clone = clone $this; | ||
$clone->port = $port; | ||
return $clone; | ||
} | ||
|
||
public function withPath(string $path): self | ||
{ | ||
if (!str_starts_with($path, '/')) { | ||
throw new UrlException(sprintf('Path must start with forward slash: %s', $path)); | ||
} | ||
$clone = clone $this; | ||
$clone->path = $path; | ||
return $clone; | ||
} | ||
|
||
public function withQueryString(?string $queryString): self | ||
{ | ||
$clone = clone $this; | ||
$clone->queryString = $queryString; | ||
return $clone; | ||
} | ||
|
||
public function __toString() | ||
{ | ||
if ($this->port === 80) { | ||
$port = ''; | ||
} else { | ||
$port = ':' . $this->port; | ||
} | ||
if ($this->queryString) { | ||
$queryString = '?' . $this->queryString; | ||
} else { | ||
$queryString = ''; | ||
} | ||
|
||
return sprintf( | ||
'%s://%s%s%s%s', | ||
$this->scheme, | ||
$this->host, | ||
$port, | ||
$this->path, | ||
$queryString | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace RssBridge\Tests; | ||
|
||
use PHPUnit\Framework\TestCase; | ||
use Url; | ||
|
||
class UrlTest extends TestCase | ||
{ | ||
public function testBasicUsages() | ||
{ | ||
$urls = [ | ||
'http://example.com/', | ||
'http://example.com:9000/', | ||
'https://example.com/', | ||
'https://example.com/?foo', | ||
'https://example.com/?foo=bar', | ||
]; | ||
foreach ($urls as $url) { | ||
$this->assertSame($url, Url::fromString($url)->__toString()); | ||
} | ||
} | ||
|
||
public function testNormalization() | ||
{ | ||
$urls = [ | ||
'http://example.com' => 'http://example.com/', | ||
'https://example.com/?' => 'https://example.com/', | ||
'https://example.com/foo?' => 'https://example.com/foo', | ||
'http://example.com:80/' => 'http://example.com/', | ||
]; | ||
foreach ($urls as $from => $to) { | ||
$this->assertSame($to, Url::fromString($from)->__toString()); | ||
} | ||
} | ||
|
||
public function testMutation() | ||
{ | ||
$this->assertSame('http://example.com/foo', (Url::fromString('http://example.com/'))->withPath('/foo')->__toString()); | ||
$this->assertSame('http://example.com/foo?a=b', (Url::fromString('http://example.com/?a=b'))->withPath('/foo')->__toString()); | ||
$this->assertSame('http://example.com/', (Url::fromString('http://example.com/'))->withPath('/')->__toString()); | ||
$this->assertSame('http://example.com/qqq?foo=bar', (Url::fromString('http://example.com/qqq'))->withQueryString('foo=bar')->__toString()); | ||
$this->assertSame('http://example.net/qqq?foo=bar', (Url::fromString('http://example.com/qqq?foo=bar'))->withHost('example.net')->__toString()); | ||
} | ||
} |