Version 1.0.0

This commit is contained in:
2025-07-13 00:10:45 +03:00
commit 42da8af363
35 changed files with 3468 additions and 0 deletions

22
.editorconfig Normal file
View File

@@ -0,0 +1,22 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[*.php]
indent_style = space
indent_size = 4
[*.yml]
indent_size = 2
[*.json]
indent_size = 2

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
vendor/
.phpunit.cache/

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright 2025 Viktor S. <thinlineseverywhere@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the “Software”),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

171
README.md Normal file
View File

@@ -0,0 +1,171 @@
# PHP Url
## Description
A simple library for interacting with URLs using an object-oriented approach.
Provides tools to build, modify, and parse URLs.
Can be used in API client classes or in dependency injection contexts.
Requires PHP 8.2 or higher.
* [Objects API](./docs/OBJECTS.md)
## Components
* **Builder** - URL instance builder
* **Facade** - Facade simplify usage
* **Parser** - Raw string url parser
* **Serializer** - URL instance serializer
* **Url** - URL instance representation
* **Util** - Inner utilities
#### Builders
* **HostRelative** - Builds a url relative to the hostname, setup scheme and port
* **ReplaceAttributes** - Builds new url instance with passed params replacement
#### Serializers
* **RFC3986** - Serializes url instance to RFC3986 string
## Installation
```bash
composer require diffhead/php-url
composer test
```
## Usage
```php
use Diffhead\PHP\Url\Facade;
use Diffhead\PHP\Url\Dto\Replace;
/**
* @var \Diffhead\PHP\Url\Url $url
*/
$url = Facade::parse('www.google.com');
/**
* @var string
*/
$string = Facade::toRfc3986String($url);
/**
* Parameters are optionally.
* If null passed then will not
* be replaced.
*/
$dto = new Replace(
scheme: 'https',
hostname: 'www.github.com',
port: 443,
path: '/',
parameters: []
);
/**
* @var \Diffhead\PHP\Url\Url $replaced
*/
$replaced = Facade::replace($url, $dto);
```
#### Parsing URL
```php
use Diffhead\PHP\Url\Parser;
use Diffhead\PHP\Url\Exception\UrlNotContainsPort;
$parser = new Parser('ftp://localhost/some/entity?public=1');
/**
* @var string $scheme
*/
$scheme = $parser->getScheme();
/**
* @var string $hostname
*/
$hostname = $parser->getHostname();
/**
* @var int $port
*/
try {
$port = $parser->getPort();
} catch (UrlNotContainsPort $e) {
$port = 0;
}
/**
* @var string $path
*/
$path = $parser->getPath();
/**
* @var array{public:string}
*/
$query = $parser->getParameters();
```
#### Using with the DI container
```php
use Diffhead\PHP\Url\Builder;
use Diffhead\PHP\Url\Builder\HostRelative;
use Diffhead\PHP\Url\Port;
use Diffhead\PHP\Url\Scheme;
use Diffhead\PHP\Url\Serializer;
use Diffhead\PHP\Url\Serializer\RFC3986;
use GuzzleHttp\Client;
class Url
{
public function __construct(
private Builder $builder,
private Serializer $serializer
) {}
public function get(string $path, array $query = []): string
{
return $this->serializer->serialize(
$this->builder->build($path, $query)
);
}
}
class Client
{
public function __construct(
private Client $http,
private Url $url
) {}
public function items(FetchItemsRequest $request): FetchItemsResponse
{
$response = $this->http->post(
$this->url->get('/v1/things'),
$this->getJsonRequestOptions($request)
);
return FetchItemsResponse::fromArray(
json_decode((string) $response->getBody(), true)
);
}
}
/**
* @var \DI\Container $container
*/
$container->set(Url::class, function (): Url {
$domain = getenv('API_DOMAIN');
$scheme = Scheme::Https->value;
$port = Port::WebSecure->value;
$builder = new HostRelative($domain, $scheme, $port);
$serializer = new RFC3986();
return new Url($builder, $serializer);
});
$container->get(Client::class);
```

29
composer.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "diffhead/php-url",
"description": "PHP url interaction library using object-oriented style",
"type": "library",
"license": "MIT",
"version": "1.0.0",
"autoload": {
"psr-4": {
"Diffhead\\PHP\\Url\\": "src/",
"Diffhead\\PHP\\Url\\Tests\\": "tests/"
}
},
"require": {
"php": "^8.2"
},
"require-dev": {
"phpunit/phpunit": "^12.2"
},
"scripts": {
"test": "phpunit"
},
"minimum-stability": "stable",
"authors": [
{
"name": "Viktor S.",
"email": "thinlineseverywhere@gmail.com"
}
]
}

1635
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

152
docs/OBJECTS.md Normal file
View File

@@ -0,0 +1,152 @@
# Objects API
#### Diffhead\PHP\Url\Facade
```php
class Facade
{
public static function parse(string $url): Url;
public static function toRfc3986String(Url $url): string;
public static function replace(Url $url, Replace $replacements): Url;
}
```
#### Diffhead\PHP\Url\Url
```php
class Url
{
public function __construct(
string $scheme,
string $hostname,
string $path,
int $port,
array $parameters
);
public function scheme(): string;
public function hostname(): string;
public function path(): string;
public function port(): int;
public function parameters(): array;
}
```
#### Diffhead\PHP\Url\Builder
```php
namespace Diffhead\PHP\Url;
use Diffhead\PHP\Url\Url;
interface Builder
{
public function build(string $resource, array $parameters = []): Url;
}
```
#### Diffhead\PHP\Url\Builder\HostRelative
```php
namespace Diffhead\PHP\Url\Builder;
use Diffhead\PHP\Url\Url;
use Diffhead\PHP\Url\Builder;
class HostRelative implements Builder
{
public function __construct(string $hostname, string $scheme, int $port);
public function build(string $path, array $query = []): Url;
}
```
#### Diffhead\PHP\Url\Builder\ReplaceAttributes
```php
namespace Diffhead\PHP\Url\Builder;
use Diffhead\PHP\Url\Url;
use Diffhead\PHP\Url\Builder;
class ReplaceAttributes implements Builder
{
public function __construct(Url $url);
/**
* Pass the hostname and/or another params
* to replace it in the URL instance.
*
* Empty hostname argument means
* it will not be replaced.
*
* @param string $hostname
* @param array{scheme?:string,port?:int,path?:string,parameters?:array} $parameters
*/
public function build(string $hostname = '', array $parameters = []): Url;
}
```
#### Diffhead\PHP\Url\Parser
```php
namespace Diffhead\PHP\Url;
class Parser
{
public function __construct(string $url);
/**
* @return string
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsScheme
*/
public function getScheme(): string;
/**
* @return string
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsHostname
*/
public function getHostname(): string;
/**
* @return int
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsPort
*/
public function getPort(): int;
/**
* @return string
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsPath
*/
public function getPath(): string;
/**
* @return array<string,string>
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsQuery
*/
public function getParameters(): array;
}
```
#### Diffhead\PHP\Url\Serializer
```php
namespace Diffhead\PHP\Url;
use Diffhead\PHP\Url\Url;
interface Serializer
{
public function toString(Url $url): string;
}
```
#### Diffhead\PHP\Url\Serializer\RFC3986
```php
namespace Diffhead\PHP\Url;
use Diffhead\PHP\Url\Serializer;
use Diffhead\PHP\Url\Url;
class RFC3986 implements Serializer
{
public function toString(Url $url): string;
}
```

27
phpunit.xml Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
displayDetailsOnPhpunitDeprecations="true"
failOnPhpunitDeprecation="true"
failOnRisky="true"
failOnWarning="true"
testdox="true"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

8
src/Builder.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
namespace Diffhead\PHP\Url;
interface Builder
{
public function build(string $resource, array $parameters = []): Url;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Builder;
use Diffhead\PHP\Url\Builder;
use Diffhead\PHP\Url\Url;
class HostRelative implements Builder
{
public function __construct(
private string $hostname,
private string $scheme = '',
private int $port = 0
) {}
public function build(string $path, array $parameters = []): Url
{
return new Url(
$this->scheme,
$this->hostname,
$path,
$this->port,
$parameters
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Builder;
use Diffhead\PHP\Url\Builder;
use Diffhead\PHP\Url\Url;
class ReplaceAttributes implements Builder
{
public function __construct(
private Url $url
) {}
/**
* Pass the hostname and/or another params
* to replace it in the URL instance.
*
* Empty hostname argument means
* it will not be replaced.
*
* @param string $hostname
* @param array{scheme?:string,port?:int,path?:string,parameters?:array} $parameters
*/
public function build(string $hostname = '', array $parameters = []): Url
{
$scheme = $parameters['scheme'] ?? $this->url->scheme();
$hostname = $hostname ?: $this->url->hostname();
$path = $parameters['path'] ?? $this->url->path();
$port = $parameters['port'] ?? $this->url->port();
$params = $parameters['parameters'] ?? $this->url->parameters();
return Url::create($scheme, $hostname, $path, $port, $params);
}
}

41
src/Dto/Replace.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Dto;
class Replace
{
public function __construct(
private ?string $scheme = null,
private ?string $hostname = null,
private ?string $path = null,
private ?int $port = null,
private ?array $parameters = null,
) {}
public function scheme(): ?string
{
return $this->scheme;
}
public function hostname(): ?string
{
return $this->hostname;
}
public function path(): ?string
{
return $this->path;
}
public function port(): ?int
{
return $this->port;
}
public function parameters(): ?array
{
return $this->parameters;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Diffhead\PHP\Url\Exception;
class UrlNotContainsHostname extends UrlRuntimeException
{
public function __construct(string $url)
{
parent::__construct($url, 'not contains hostname');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Diffhead\PHP\Url\Exception;
class UrlNotContainsPath extends UrlRuntimeException
{
public function __construct(string $url)
{
parent::__construct($url, 'not contains path');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Diffhead\PHP\Url\Exception;
class UrlNotContainsPort extends UrlRuntimeException
{
public function __construct(string $url)
{
parent::__construct($url, 'not contains port');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Diffhead\PHP\Url\Exception;
class UrlNotContainsQuery extends UrlRuntimeException
{
public function __construct(string $url)
{
parent::__construct($url, 'not contains query');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Diffhead\PHP\Url\Exception;
class UrlNotContainsScheme extends UrlRuntimeException
{
public function __construct(string $url)
{
parent::__construct($url, 'not contains scheme');
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Diffhead\PHP\Url\Exception;
use RuntimeException;
class UrlRuntimeException extends RuntimeException
{
public function __construct(string $url, string $description)
{
parent::__construct(sprintf('"%s" %s', $url, $description));
}
}

73
src/Facade.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url;
use Closure;
use Diffhead\PHP\Url\Builder\ReplaceAttributes;
use Diffhead\PHP\Url\Dto\Replace;
use Diffhead\PHP\Url\Exception\UrlRuntimeException;
use Diffhead\PHP\Url\Serializer\RFC3986;
class Facade
{
public static function parse(string $url): Url
{
$parser = new Parser($url);
return new Url(
self::valueOrDefault(fn () => $parser->getScheme(), ''),
self::valueOrDefault(fn () => $parser->getHostname(), ''),
self::valueOrDefault(fn () => $parser->getPath(), ''),
self::valueOrDefault(fn () => $parser->getPort(), 0),
self::valueOrDefault(fn () => $parser->getParameters(), []),
);
}
public static function toRfc3986String(Url $url): string
{
$serializer = new RFC3986();
return $serializer->toString($url);
}
public static function replace(Url $url, Replace $replacements): Url
{
$builder = new ReplaceAttributes($url);
$parameters = [];
if ($replacements->scheme() !== null) {
$parameters['scheme'] = $replacements->scheme();
}
if ($replacements->path() !== null) {
$parameters['path'] = $replacements->path();
}
if ($replacements->port() !== null) {
$parameters['port'] = $replacements->port();
}
if ($replacements->parameters() !== null) {
$parameters['parameters'] = $replacements->parameters();
}
return $builder->build($replacements->hostname() ?? '', $parameters);
}
/**
* @param Closure():mixed $getter
* @param mixed $default
*
* @return mixed
*/
private static function valueOrDefault(Closure $getter, mixed $default = null): mixed
{
try {
return $getter();
} catch (UrlRuntimeException $exception) {
return $default;
}
}
}

200
src/Parser.php Normal file
View File

@@ -0,0 +1,200 @@
<?php
namespace Diffhead\PHP\Url;
use Diffhead\PHP\Url\Exception\UrlNotContainsHostname;
use Diffhead\PHP\Url\Exception\UrlNotContainsPath;
use Diffhead\PHP\Url\Exception\UrlNotContainsPort;
use Diffhead\PHP\Url\Exception\UrlNotContainsQuery;
use Diffhead\PHP\Url\Exception\UrlNotContainsScheme;
class Parser
{
private string $url;
private ?string $scheme = null;
private ?string $hostname = null;
private ?int $port = null;
private ?string $path = null;
private ?array $parameters = null;
public function __construct(string $url)
{
$this->url = urldecode($url);
}
/**
* @return string
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsScheme
*/
public function getScheme(): string
{
if (is_null($this->scheme)) {
if (! preg_match(Regex::Scheme->value, $this->url, $matches)) {
throw new UrlNotContainsScheme($this->url);
}
$this->scheme = strtolower($matches[1]);
}
return $this->scheme;
}
/**
* @return string
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsHostname
*/
public function getHostname(): string
{
if (is_null($this->hostname)) {
$matched = preg_match(Regex::Hostname->value, $this->url, $matches);
switch (true) {
case $matched === false:
case empty($matches[1]):
case str_starts_with($this->url, sprintf('%s://', $matches[1])):
$this->throwUrlNotContainsHostname();
}
$this->hostname = $matches[1];
}
return $this->hostname;
}
/**
* @return int
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsPort
*/
public function getPort(): int
{
if (is_null($this->port)) {
$matched = preg_match(
Regex::Port->value,
$this->url,
$matches,
PREG_UNMATCHED_AS_NULL
);
if (! $matched) {
throw new UrlNotContainsPort($this->url);
}
$this->port = (int) $matches[2];
}
return $this->port;
}
/**
* @return string
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsPath
*/
public function getPath(): string
{
if (is_null($this->path)) {
if (! preg_match(Regex::Path->value, $this->url, $matches)) {
$this->throwUrlNotContainsPath();
}
if (empty($matches[1])) {
$this->throwUrlNotContainsPath();
}
$this->path = $matches[1];
}
return $this->path;
}
/**
* @return array<string,string>
*
* @throws \Diffhead\PHP\Url\Exception\UrlNotContainsQuery
*/
public function getParameters(): array
{
if (is_null($this->parameters)) {
if (! preg_match(Regex::Query->value, $this->url, $matches)) {
throw new UrlNotContainsQuery($this->url);
}
/**
* @var array<int,string> $parameters
*/
$parametersRaw = explode('&', $matches[1]);
$parameters = [];
foreach ($parametersRaw as $parameter) {
[$key, $value] = explode('=', $parameter);
if ($this->isNestedKey($key)) {
$keys = $this->splitNestedKey($key);
$this->setNestedValue($keys, $value, $parameters);
} else {
$this->setFlatValue($key, $value, $parameters);
}
}
$this->parameters = $parameters;
}
return $this->parameters;
}
private function isNestedKey(string $key): bool
{
return is_numeric(strpos($key, '['));
}
private function splitNestedKey(string $nestedKey): array
{
preg_match_all(Regex::QueryArrayKeys->value, $nestedKey, $matches);
return array_map(fn (string $key) => $key, $matches[1]);
}
/**
* @param array<int,string> $keys
* @param string $value
* @param array<string|int,string|array> $parameters
*
* @return void
*/
private function setNestedValue(array $keys, string $value, array &$parameters): void
{
$current = array_shift($keys);
if (! empty($keys)) {
$temporary = [];
$this->setNestedValue($keys, $value, $temporary);
$persisted = $parameters[$current] ?? [];
$parameters[$current] = array_merge($persisted, $temporary);
} else if (empty($current)) {
$parameters[] = $value;
} else {
$parameters[$current] = $value;
}
}
private function setFlatValue(string $key, string $value, array &$parameters): void
{
$parameters[$key] = $value;
}
private function throwUrlNotContainsHostname(): void
{
throw new UrlNotContainsHostname($this->url);
}
private function throwUrlNotContainsPath(): void
{
throw new UrlNotContainsPath($this->url);
}
}

31
src/Port.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace Diffhead\PHP\Url;
enum Port: int
{
case Cassandra = 9042;
case CouchDb = 5984;
case ElasticSearch = 9200;
case FileTransfer = 21;
case FileTransferData = 20;
case FileTransferOverSsh = 22;
case FileTransferSecure = 990;
case Imap = 143;
case ImapSecure = 993;
case MailTo = 25;
case MongoDb = 27017;
case MySql = 3306;
case OracleDb = 1521;
case Pop3 = 110;
case Pop3Secure = 995;
case PostgreSql = 5432;
case RabbitMQ = 5672;
case RabbitMQManagement = 15672;
case Redis = 6379;
case SqlServer = 1433;
case Smtp = 587;
case SmtpSecure = 465;
case Web = 80;
case WebSecure = 443;
}

13
src/Regex.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
namespace Diffhead\PHP\Url;
enum Regex: string
{
case Scheme = '/^(\w+):\/\//';
case Hostname = '/^(?:(?:[A-Za-z][A-Za-z0-9+\-.]*):\/\/)?(?:(?:[A-Za-z0-9\-._~!$&\'()*+,;=:]|%[0-9A-Fa-f]{2})*@)?(\[(?:[0-9A-Fa-f:.]+|v[0-9A-Fa-f]+\.[A-Za-z0-9\-._~!$&\'()*+,;=:]+)\]|(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2}|[!$&\'()*+,;=])+)(?=[:\/?#]|$)/';
case Port = '/^(\w+:\/\/){0,1}[\w\d]+[\w\d\.-_]{0,}?:?(\d+)[\/]?/';
case Path = '/^(?:[a-z][a-z0-9+\-.]*:\/\/)?[^\/\?#]+(?:\:\d+)?(\/[^?\#]*)?/';
case Query = '/\?(.*?)?(?=#|$)/';
case QueryArrayKeys = '/\[([^\]]*)\]/';
}

20
src/Scheme.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace Diffhead\PHP\Url;
enum Scheme: string
{
case File = 'file';
case FileTransfer = 'ftp';
case FileTransferOverSsh = 'sftp';
case FileTransferSecure = 'ftps';
case Http = 'http';
case Https = 'https';
case Imap = 'imap';
case MailTo = 'mailto';
case Pop3 = 'pop3';
case SecureCopy = 'scp';
case Smtp = 'smtp';
case WebSocket = 'ws';
case WebSocketSecure = 'wss';
}

8
src/Serializer.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
namespace Diffhead\PHP\Url;
interface Serializer
{
public function toString(Url $url): string;
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Serializer;
use Diffhead\PHP\Url\Serializer;
use Diffhead\PHP\Url\Url;
use Diffhead\PHP\Url\Util;
class RFC3986 implements Serializer
{
public function toString(Url $url): string
{
return sprintf(
'%s://%s%s%s',
$url->scheme(),
$this->getHost($url),
$this->getPath($url),
$this->getQueryString($url)
);
}
private function getHost(Url $url): string
{
switch (true) {
/**
* If http protocol with 80 port
* or https protocol with 443 port
*/
case Util::isDefaultHttpUrl($url):
/**
* If ws protocol with 80 port
* or wss protocol with 443 port
*/
case Util::isDefaultWebSocketUrl($url):
return $url->hostname();
default:
return sprintf('%s:%d', $url->hostname(), $url->port());
}
}
private function getPath(Url $url): string
{
$pathIsEmpty = empty($url->path());
$pathStartsWithSlash = str_starts_with($url->path(), '/');
if ($pathIsEmpty || $pathStartsWithSlash) {
return $url->path();
}
return sprintf('/%s', $url->path());
}
private function getQueryString(Url $url): string
{
if ($parameters = $url->parameters()) {
return '?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
}
return '';
}
}

61
src/Url.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url;
class Url
{
public static function create(
string $scheme,
string $hostname,
string $path,
int $port,
array $parameters
): Url {
return new static($scheme, $hostname, $path, $port, $parameters);
}
/**
* @param string $scheme
* @param string $hostname
* @param string $path
* @param int $port
* @param array<string,string|int|float|array> $parameters
*/
public function __construct(
private string $scheme,
private string $hostname,
private string $path,
private int $port,
private array $parameters
) {}
public function scheme(): string
{
return $this->scheme;
}
public function hostname(): string
{
return $this->hostname;
}
public function path(): string
{
return $this->path;
}
public function port(): int
{
return $this->port;
}
/**
* @return array<string,string|array>
*/
public function parameters(): array
{
return $this->parameters;
}
}

54
src/Util.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url;
class Util
{
public static function isDefaultHttpUrl(Url $url): bool
{
return static::httpWithoutTls($url)
|| static::httpWithTls($url);
}
public static function isDefaultWebSocketUrl(Url $url): bool
{
return static::webSocketWithoutTls($url)
|| static::webSocketWithTls($url);
}
private static function httpWithoutTls(Url $url): bool
{
return $url->scheme() === Scheme::Http->value
&& static::webOrEmptyPort($url);
}
private static function httpWithTls(Url $url): bool
{
return $url->scheme() === Scheme::Https->value
&& static::webSecureOrEmptyPort($url);
}
private static function webSocketWithoutTls(Url $url): bool
{
return $url->scheme() === Scheme::WebSocket->value
&& static::webOrEmptyPort($url);
}
private static function webSocketWithTls(Url $url): bool
{
return $url->scheme() === Scheme::WebSocketSecure->value
&& static::webSecureOrEmptyPort($url);
}
private static function webOrEmptyPort(Url $url): bool
{
return $url->port() === Port::Web->value || empty($url->port());
}
private static function webSecureOrEmptyPort(Url $url): bool
{
return $url->port() === Port::WebSecure->value || empty($url->port());
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Tests\Builder;
use Diffhead\PHP\Url\Builder\HostRelative;
use Diffhead\PHP\Url\Port;
use Diffhead\PHP\Url\Scheme;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
#[CoversClass(HostRelative::class)]
#[CoversMethod(HostRelative::class, 'build')]
class HostRelativeTest extends TestCase
{
public function testUrlBuilding(): void
{
$hostname = 'www.google.com';
$scheme = Scheme::Https;
$port = Port::WebSecure;
$path = '/api/users';
$query = ['active' => true, 'page' => 2, 'perPage' => 100];
$builder = new HostRelative($hostname, $scheme->value, $port->value);
$url = $builder->build($path, $query);
$this->assertSame($hostname, $url->hostname());
$this->assertSame($scheme->value, $url->scheme());
$this->assertSame($port->value, $url->port());
$this->assertSame($path, $url->path());
$this->assertEquals($query, $url->parameters());
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Tests\Builder;
use Diffhead\PHP\Url\Builder\ReplaceAttributes;
use Diffhead\PHP\Url\Url;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
use TypeError;
#[CoversClass(ReplaceAttributes::class)]
#[CoversMethod(ReplaceAttributes::class, 'build')]
class ReplaceAttributesTest extends TestCase
{
public function testReplacementOnEmptyData(): void
{
$origin = Url::create('http', 'example.com', '/path', 80, ['a' => '1']);
$builder = new ReplaceAttributes($origin);
$replaced = $builder->build();
$this->assertSame('http', $replaced->scheme());
$this->assertSame('example.com', $replaced->hostname());
$this->assertSame('/path', $replaced->path());
$this->assertSame(80, $replaced->port());
$this->assertSame(['a' => '1'], $replaced->parameters());
}
public function testPartiallyReplacement(): void
{
$origin = Url::create('http', 'example.com', '/path', 80, ['a' => '1']);
$builder = new ReplaceAttributes($origin);
$replaced = $builder->build('new-example.com', [
'scheme' => 'https',
'port' => 443,
]);
$this->assertSame('https', $replaced->scheme());
$this->assertSame('new-example.com', $replaced->hostname());
$this->assertSame('/path', $replaced->path());
$this->assertSame(443, $replaced->port());
$this->assertSame(['a' => '1'], $replaced->parameters());
}
public function testFullReplacement(): void
{
$origin = Url::create('http', 'example.com', '/path', 80, ['a' => '1']);
$builder = new ReplaceAttributes($origin);
$replaced = $builder->build('new-example.com', [
'scheme' => 'https',
'path' => '/new-path',
'port' => 443,
'parameters' => ['b' => '2'],
]);
$this->assertSame('https', $replaced->scheme());
$this->assertSame('new-example.com', $replaced->hostname());
$this->assertSame('/new-path', $replaced->path());
$this->assertSame(443, $replaced->port());
$this->assertSame(['b' => '2'], $replaced->parameters());
}
public function testInvalidParametersTypes(): void
{
$this->expectException(TypeError::class);
$origin = Url::create('http', 'example.com', '/path', 80, ['a' => '1']);
$builder = new ReplaceAttributes($origin);
$builder->build('new-example.com', [
'scheme' => 123,
]);
}
}

36
tests/Dto/ReplaceTest.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Tests\Dto;
use Diffhead\PHP\Url\Dto\Replace;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
#[CoversClass(Replace::class)]
#[CoversMethod(Replace::class, 'scheme')]
#[CoversMethod(Replace::class, 'hostname')]
#[CoversMethod(Replace::class, 'path')]
#[CoversMethod(Replace::class, 'port')]
#[CoversMethod(Replace::class, 'parameters')]
class ReplaceTest extends TestCase
{
public function testReplaceDtoProperlyInitializes(): void
{
$replace = new Replace(
scheme: 'https',
hostname: 'example.com',
path: '/path',
port: 443,
parameters: ['param' => 'value'],
);
$this->assertSame('https', $replace->scheme());
$this->assertSame('example.com', $replace->hostname());
$this->assertSame('/path', $replace->path());
$this->assertSame(443, $replace->port());
$this->assertSame(['param' => 'value'], $replace->parameters());
}
}

86
tests/FacadeTest.php Normal file
View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Tests;
use Diffhead\PHP\Url\Dto\Replace;
use Diffhead\PHP\Url\Facade;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
#[CoversClass(Facade::class)]
#[CoversMethod(Facade::class, 'parse')]
#[CoversMethod(Facade::class, 'toRfc3986String')]
#[CoversMethod(Facade::class, 'replace')]
class FacadeTest extends TestCase
{
public function testParsingOnEmptyString(): void
{
$url = Facade::parse('');
$this->assertSame('', $url->scheme());
$this->assertSame('', $url->hostname());
$this->assertSame('', $url->path());
$this->assertSame(0, $url->port());
$this->assertSame([], $url->parameters());
}
public function testParsingOnProperlyWebUrl(): void
{
$url = Facade::parse('https://example.com:8080/path/to/resource?param1=value1&param2=value2');
$this->assertSame('https', $url->scheme());
$this->assertSame('example.com', $url->hostname());
$this->assertSame('/path/to/resource', $url->path());
$this->assertSame(8080, $url->port());
$this->assertSame(['param1' => 'value1', 'param2' => 'value2'], $url->parameters());
}
public function testParsingOnDsn(): void
{
$url = Facade::parse('mysql://user:password@localhost:3306/database_name');
$this->assertSame('mysql', $url->scheme());
$this->assertSame('localhost', $url->hostname());
$this->assertSame('/database_name', $url->path());
$this->assertSame(3306, $url->port());
$this->assertSame([], $url->parameters());
}
public function testStringConversionToRfc3986(): void
{
$originUrl = 'https://example.com:8080/path/to/resource?param1=value1&param2=value2';
$url = Facade::parse($originUrl);
$convertedUrl = Facade::toRfc3986String($url);
$this->assertSame($originUrl, $convertedUrl);
}
public function testUrlAttributesReplacement(): void
{
$raw = 'http://example.com/path?param=oldValue';
$origin = Facade::parse($raw);
$replace = new Replace(
scheme: 'https',
hostname: 'new-example.com',
path: '/new-path',
port: 443,
parameters: ['param' => 'newValue', 'addedParam' => 'addedValue'],
);
$replaced = Facade::replace($origin, $replace);
$this->assertSame('https', $replaced->scheme());
$this->assertSame('new-example.com', $replaced->hostname());
$this->assertSame('/new-path', $replaced->path());
$this->assertSame(443, $replaced->port());
$this->assertSame(
['param' => 'newValue', 'addedParam' => 'addedValue'],
$replaced->parameters()
);
}
}

118
tests/ParserTest.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Tests;
use Diffhead\PHP\Url\Exception\UrlNotContainsHostname;
use Diffhead\PHP\Url\Exception\UrlNotContainsPath;
use Diffhead\PHP\Url\Exception\UrlNotContainsPort;
use Diffhead\PHP\Url\Exception\UrlNotContainsQuery;
use Diffhead\PHP\Url\Exception\UrlNotContainsScheme;
use Diffhead\PHP\Url\Parser;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
#[CoversClass(Parser::class)]
#[CoversMethod(Parser::class, 'getScheme')]
#[CoversMethod(Parser::class, 'getHostname')]
#[CoversMethod(Parser::class, 'getPath')]
#[CoversMethod(Parser::class, 'getPort')]
#[CoversMethod(Parser::class, 'getParameters')]
class ParserTest extends TestCase
{
public function testExistingSchemeParsing(): void
{
$this->assertSame('http', $this->getParser('http://example.info')->getScheme());
$this->assertSame('https', $this->getParser('https://example.info')->getScheme());
}
public function testEmptySchemeParsing(): void
{
$this->expectException(UrlNotContainsScheme::class);
$this->getParser('example.info')->getScheme();
}
public function testExistingHostnameParsing(): void
{
$this->assertSame('example.info', $this->getParser('example.info')->getHostname());
$this->assertSame('example.info', $this->getParser('https://example.info')->getHostname());
}
public function testEmptyHostnameParsing(): void
{
$this->expectException(UrlNotContainsHostname::class);
$this->getParser('https:///api/webhooks/event')->getHostname();
}
public function testExistingPortParsing(): void
{
$this->assertSame(80, $this->getParser('localhost:80')->getPort());
$this->assertSame(443, $this->getParser('https://google.com:443/index')->getPort());
}
public function testEmptyPortParsing(): void
{
$this->expectException(UrlNotContainsPort::class);
$this->getParser('localhost')->getPort();
}
public function testExistingPathParsing(): void
{
$this->assertSame('/', $this->getParser('https://google.com/')->getPath());
$this->assertSame('/api/endpoint', $this->getParser('https://something.org/api/endpoint')->getPath());
$this->assertSame('/api/endpoint', $this->getParser('something.org:1234/api/endpoint')->getPath());
}
public function testEmptyPathParsing(): void
{
$this->expectException(UrlNotContainsPath::class);
$this->getParser('https://google.com')->getPath();
}
public function testExistingFlatParametersParsing(): void
{
$url = 'https://example.com?parameter=value&secondParameter=secondValue&a=100';
$parser = $this->getParser($url);
$urlParameters = [
'parameter' => 'value',
'secondParameter' => 'secondValue',
'a' => '100'
];
$this->assertEquals($urlParameters, $parser->getParameters());
}
public function testExistingNestedParametersParsing(): void
{
$url = 'https://example.com/search?query=book&filters%5Bprice%5D%5Bmin%5D=10&filters%5Bprice%5D%5Bmax%5D=100&filters%5Bcategories%5D%5B%5D=fiction&filters%5Bcategories%5D%5B%5D=history';
$parser = $this->getParser($url);
$urlParameters = [
'query' => 'book',
'price' => [
'min' => '10',
'max' => '100'
],
'categories' => [
'fiction',
'history'
]
];
$this->assertEquals($urlParameters, $parser->getParameters());
}
public function testNonExistingParametersParsing(): void
{
$this->expectException(UrlNotContainsQuery::class);
$this->getParser('https://google.com')->getParameters();
}
private function getParser(string $url): Parser
{
return new Parser($url);
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Tests\Serializer;
use Diffhead\PHP\Url\Port;
use Diffhead\PHP\Url\Scheme;
use Diffhead\PHP\Url\Serializer\RFC3986;
use Diffhead\PHP\Url\Url;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
#[CoversClass(RFC3986::class)]
#[CoversMethod(RFC3986::class, 'toString')]
class RFC3986Test extends TestCase
{
public function testSerializeHttpUrlWithDefaultPort(): void
{
$url = new Url(
Scheme::Http->value,
'localhost',
'/api/endpoint',
Port::Web->value,
[
'isActive' => true,
'page' => 1,
'perPage' => 100
]
);
$this->assertSame(
'http://localhost/api/endpoint?isActive=1&page=1&perPage=100',
$this->getSerializer()->toString($url)
);
}
public function serializeHttpUrlWithNonDefaultPort(): void
{
$url = new Url(
Scheme::Http->value,
'localhost',
'/api/endpoint',
Port::WebSecure->value,
[
'isActive' => true,
'page' => 1,
'perPage' => 100
]
);
$this->assertSame(
'http://localhost:443/api/endpoint?isActive=1&page=1&perPage=100',
$this->getSerializer()->toString($url)
);
}
public function serializeHttpsUrlWithDefaultPort(): void
{
$url = new Url(
Scheme::Https->value,
'localhost',
'/api/endpoint',
Port::WebSecure->value,
[
'isActive' => true,
'page' => 1,
'perPage' => 100
]
);
$this->assertSame(
'https://localhost/api/endpoint?isActive=1&page=1&perPage=100',
$this->getSerializer()->toString($url)
);
}
public function serializeHttpsUrlWithNonDefaultPort(): void
{
$url = new Url(
Scheme::Https->value,
'localhost',
'/api/endpoint',
Port::MySql->value,
[
'isActive' => true,
'page' => 1,
'perPage' => 100
]
);
$this->assertSame(
'https://localhost:3306/api/endpoint?isActive=1&page=1&perPage=100',
$this->getSerializer()->toString($url)
);
}
public function testSerializeWebSocketUrlWithDefaultPort(): void
{
$url = new Url(
Scheme::WebSocket->value,
'localhost',
'/api/endpoint',
Port::Web->value,
[
'isActive' => true,
'page' => 1,
'perPage' => 100
]
);
$this->assertSame(
'ws://localhost/api/endpoint?isActive=1&page=1&perPage=100',
$this->getSerializer()->toString($url)
);
}
public function serializeWebSocketUrlWithNonDefaultPort(): void
{
$url = new Url(
Scheme::Http->value,
'localhost',
'/api/endpoint',
Port::WebSecure->value,
[
'isActive' => true,
'page' => 1,
'perPage' => 100
]
);
$this->assertSame(
'ws://localhost:443/api/endpoint?isActive=1&page=1&perPage=100',
$this->getSerializer()->toString($url)
);
}
public function serializeWebSocketSecureUrlWithDefaultPort(): void
{
$url = new Url(
Scheme::WebSocketSecure->value,
'localhost',
'/api/endpoint',
Port::WebSecure->value,
[
'isActive' => true,
'page' => 1,
'perPage' => 100
]
);
$this->assertSame(
'wss://localhost/api/endpoint?isActive=1&page=1&perPage=100',
$this->getSerializer()->toString($url)
);
}
public function serializeWebSocketSecureUrlWithNonDefaultPort(): void
{
$url = new Url(
Scheme::WebSocketSecure->value,
'localhost',
'/api/endpoint',
Port::Web->value,
[
'isActive' => true,
'page' => 1,
'perPage' => 100
]
);
$this->assertSame(
'wss://localhost:80/api/endpoint?isActive=1&page=1&perPage=100',
$this->getSerializer()->toString($url)
);
}
private function getSerializer(): RFC3986
{
return new RFC3986();
}
}

55
tests/UrlTest.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Tests;
use Diffhead\PHP\Url\Port;
use Diffhead\PHP\Url\Scheme;
use Diffhead\PHP\Url\Url;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
#[CoversClass(Url::class)]
#[CoversMethod(Url::class, 'scheme')]
#[CoversMethod(Url::class, 'hostname')]
#[CoversMethod(Url::class, 'path')]
#[CoversMethod(Url::class, 'port')]
#[CoversMethod(Url::class, 'parameters')]
class UrlTest extends TestCase
{
public function testInitialization(): void
{
$url = new Url(
Scheme::FileTransferOverSsh->value,
'file.storage.info',
'/home/user/Downloads',
Port::FileTransferOverSsh->value,
['file' => 'something.txt']
);
$this->assertSame(Scheme::FileTransferOverSsh->value, $url->scheme());
$this->assertSame('file.storage.info', $url->hostname());
$this->assertSame('/home/user/Downloads', $url->path());
$this->assertSame(Port::FileTransferOverSsh->value, $url->port());
$this->assertEquals(['file' => 'something.txt'], $url->parameters());
}
public function testStaticInitialization(): void
{
$url = Url::create(
Scheme::WebSocketSecure->value,
'localhost',
'/api/endpoint',
Port::WebSecure->value,
['token' => 'token1234']
);
$this->assertSame(Scheme::WebSocketSecure->value, $url->scheme());
$this->assertSame('localhost', $url->hostname());
$this->assertSame('/api/endpoint', $url->path());
$this->assertSame(Port::WebSecure->value, $url->port());
$this->assertEquals(['token' => 'token1234'], $url->parameters());
}
}

108
tests/UtilTest.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\Url\Tests;
use Diffhead\PHP\Url\Port;
use Diffhead\PHP\Url\Scheme;
use Diffhead\PHP\Url\Url;
use Diffhead\PHP\Url\Util;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\TestCase;
#[CoversClass(Util::class)]
#[CoversMethod(Util::class, 'isDefaultHttpUrl')]
#[CoversMethod(Util::class, 'isDefaultWebSocketUrl')]
class UtilTest extends TestCase
{
public function testHttpHttpsWithDefaultPortsTesting(): void
{
$httpWith80Port = new Url(
Scheme::Http->value,
'localhost',
'/',
Port::Web->value,
[]
);
$httpsWith443Port = new Url(
Scheme::Https->value,
'localhost',
'/',
Port::WebSecure->value,
[]
);
$this->assertTrue(Util::isDefaultHttpUrl($httpWith80Port));
$this->assertTrue(Util::isDefaultHttpUrl($httpsWith443Port));
}
public function testHttpHttpsWithNonDefaultPortsTesting(): void
{
$httpWith443Port = new Url(
Scheme::Http->value,
'localhost',
'/',
Port::WebSecure->value,
[]
);
$httpsWith80Port = new Url(
Scheme::Https->value,
'localhost',
'/',
Port::Web->value,
[]
);
$this->assertFalse(Util::isDefaultHttpUrl($httpWith443Port));
$this->assertFalse(Util::isDefaultHttpUrl($httpsWith80Port));
}
public function testWebSocketWithDefaultPortsTesting(): void
{
$webSocketWith80Port = new Url(
Scheme::WebSocket->value,
'localhost',
'/',
Port::Web->value,
[]
);
$webSocketSecureWith443Port = new Url(
Scheme::WebSocketSecure->value,
'localhost',
'/',
Port::WebSecure->value,
[]
);
$this->assertTrue(Util::isDefaultWebSocketUrl($webSocketWith80Port));
$this->assertTrue(Util::isDefaultWebSocketUrl($webSocketSecureWith443Port));
}
public function testWebSocketWithNonDefaultPortsTesting(): void
{
$webSocketWith443Port = new Url(
Scheme::WebSocket->value,
'localhost',
'/',
Port::WebSecure->value,
[]
);
$webSocketSecureWith80Port = new Url(
Scheme::WebSocketSecure->value,
'localhost',
'/',
Port::Web->value,
[]
);
$this->assertFalse(Util::isDefaultWebSocketUrl($webSocketWith443Port));
$this->assertFalse(Util::isDefaultWebSocketUrl($webSocketSecureWith80Port));
}
}