diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..bbcbbe7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
new file mode 100644
index 0000000..f4d9307
--- /dev/null
+++ b/.github/workflows/php.yml
@@ -0,0 +1,47 @@
+name: PHP Composer
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php: [8.0, 8.1, 8.2, 8.3]
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: intl #optional
+ ini-values: "post_max_size=256M" #optional
+ - name: Check PHP Version
+ run: php -v
+
+ - name: Validate composer.json and composer.lock
+ run: composer validate
+
+ - name: Cache Composer packages
+ id: composer-cache
+ uses: actions/cache@v2
+ with:
+ path: vendor
+ key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-php-
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Run lint suite
+ run: composer run-script lint
+
+ - name: Run test suite
+ run: vendor/bin/phpunit --coverage-clover=coverage.xml
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ab145f5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,34 @@
+# phpstorm project files
+.idea
+/.idea
+# netbeans project files
+nbproject
+
+# zend studio for eclipse project files
+.buildpath
+.project
+.settings
+
+# windows thumbnail cache
+Thumbs.db
+
+# composer vendor dir
+/vendor
+
+# composer itself is not needed
+composer.phar
+
+# Mac DS_Store Files
+.DS_Store
+
+# phpunit itself is not needed
+phpunit.phar
+
+tests/_output/*
+tests/_support/_generated
+
+#vagrant folder
+/.vagrant
+/public_html
+/cli
+composer.lock
\ No newline at end of file
diff --git a/.phplint.yml b/.phplint.yml
new file mode 100644
index 0000000..83e94a7
--- /dev/null
+++ b/.phplint.yml
@@ -0,0 +1,9 @@
+path: ./
+jobs: 10
+cache: tests/cache/phplint.cache
+extensions:
+ - php
+exclude:
+ - vendor
+ - tests
+warning: false
\ No newline at end of file
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..e63bfd4
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+@xR2_D2x.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2ad5592
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,9 @@
+Thank you for contributing to this project.
+
+Please submit pull requests to the master branch only.
+
+Please run phpunit.
+
+If you have added something new, create a new unit test. If you have changed something, update all unit tests as necessary.
+
+We aim to achieve 100% code coverage by tests, including checking for PHP errors and exceptional situations. Therefore, make sure your new or modified tests fully cover all changes you have made.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..38d643a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 vinogradsoft
+
+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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0bb4cb1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,679 @@
+# Navigator
+
+> Генератор url. Работает с внутренней навигацией приложения и способен генерировать URL-адреса на внешние ресурсы.
+> Внутренняя навигация осуществляется на основе именованных определений маршрутов.
+> Именованные маршруты дают возможность удобно создавать URL-адреса, не привязываясь к домену или к определению
+> маршрута.
+
+## Общая информация
+
+Библиотека предназначена для приложений использующих в своем коде маршрутизатор
+[FastRoute](https://github.com/nikic/FastRoute#fastroute---fast-request-router-for-php). FastRoute использует
+определения маршрутов на основе регулярных выражений, а Navigator создает URL-адреса соответствующие этим определениям
+маршрутов.
+
+Для постоянства кода определениям маршрутов назначаются имена.
+
+```
+ имя определение
+ маршрута маршрута
+ /‾‾‾‾‾‾‾\ /‾‾‾‾‾‾‾‾‾‾‾\
+'user/view' => '/user/{id:\d+}',
+```
+
+Это позволяет манипулировать URL-адресами без внесения изменений в существующий код. Например, для создания URL-адреса
+именованного определения `'user' => '/user/{id:\d+}'`, можно использовать следующий код:
+
+```php
+/** relative url */
+echo $urlBuilder->build('/user', ['id' => 100]); # /user/100
+
+/** absolute url */
+echo $urlBuilder->build('/user', ['id' => 100], true); # http://mydomain.ru/user/100
+```
+
+Если по какой-либо причине потребуется изменить адрес с `/user/100` на `/employee/100`, вам нужно будет просто изменить
+настройку маршрута с `'/user/{id:\d+}'` на `'/employee/{id:\d+}'`. После этого код выше будет создавать относительный
+URL-адрес `/employee/100` и абсолютный `http://mydomain.ru/employee/100`.
+
+В объекте класса `Navigator\UrlBuilder` два метода генерации URL: `build` и `buildExternal`. Метод `build` используется
+для навигации внутри приложения, а `buildExternal` создает URL для внешних ресурсов и может генерировать только
+абсолютные URL-адреса.
+
+## Установка
+
+Предпочтительный способ установки - через [composer](http://getcomposer.org/download/).
+
+Запустите команду
+
+```
+php composer require vinogradsoft/navigator "^1.0.0"
+```
+
+Требуется PHP 8.0 или новее.
+
+## Быстрый старт
+
+```php
+ '/user[/{var_name}]',
+ // many other route definitions in key-value format.
+ ])
+);
+
+echo $urlBuilder->build('/user', null, true), '
'; # https://vinograd.soft/user
+echo $urlBuilder->build('/user', ['var_name' => 'var_value'], true), '
'; # https://vinograd.soft/user/var_value
+echo $urlBuilder->build('user', ['var_name' => 'var_value'], true), '
'; # https://vinograd.soft/user/var_value
+echo $urlBuilder->build('/user', ['var_name' => 'var_value']), '
'; # /user/var_value
+echo $urlBuilder->build('user', ['var_name' => 'var_value']), '
'; # user/var_value
+```
+
+## Конструктор
+
+Конструктор имеет шесть параметров, пожалуй самые главные первые два.
+Первым параметром идет `$baseUrl` - в данном примере его значение равно `'https://vinograd.soft'` - это базовый URL,
+который используется для генерации абсолютных URL-адресов внутри приложения.
+
+Второй параметр `$rulesProvider` принимает экземпляр реализации интерфейса `Navigator\RulesProvider`.
+В примере используется реализация `Navigator\ArrayRulesProvider`, которая работает с обычным массивом
+зарегистрированных маршрутов. По сути это источник именованных определений маршрутов.
+
+## Параметры метода `build`
+
+### Параметр `$name`
+
+Параметром `$name` вы передаете имя маршрута. На основе значения этого параметра система понимает с каким определением
+работает и преобразует его в URL-адрес. В примере используется два случая `'user'` и `'/user'`.
+Стоит отметить, что символ “/” в начале передаваемого имени никак не влияет на поиск маршрута, он указывает системе, что
+данный символ должен быть включен в начало генерируемого URL-адреса. Система распознает этот символ затем
+осуществляет поиск маршрута по имени, исключая из него данный символ. После этого “/” используется системой как
+индикатор того, что его необходимо включить в начало формируемого относительного URL-адреса.
+
+### Параметр `$placeholders`
+
+Может быть `массивом настроек заполнителей` или `null`.
+
+#### Массив настроек заполнителей
+
+Из примера, `$urlBuilder` работает с определением маршрута `'/user[/{var_name}]'`, где `var_name` это динамическая
+часть,
+другими словами переменная.
+
+В метод `build` мы передали такой массив `['var_name' => 'var_value']`, где `'var_name'` переменная из определения
+маршрута, а `'var_value'` ее значение, т.е. то чем `'var_name'` в результате будет заменена, в нашем случае результат
+для абсолютного URL-адреса такой `https://vinograd.soft/user/var_value`.
+
+#### NULL
+
+NULL используется в случаях генерации статических URL, которым не нужны настройки в директориях пути, такому как это
+определение `'user' => '/user'`.
+
+Пример:
+
+```php
+$urlBuilder = new UrlBuilder(
+ 'https://vinograd.soft',
+ new ArrayRulesProvider([
+ 'user' => '/user/profile',
+ ])
+);
+
+echo $urlBuilder->build('/user', null, true); # https://vinograd.soft/user/profile
+```
+
+### Параметр `$absolute`
+
+Этот булевый параметр указывает системе, какой URL-адрес создавать: абсолютный или относительный. Передача `true`
+приведет к созданию абсолютного адреса, в противном случае будет создан относительный адрес. Значение по умолчанию
+равно `false`.
+
+## Конфигурация маршрутов
+
+Имена определений не имеют жесткого формата, поэтому какие давать имена зависит от вас.
+Вы можете использовать формат `<контроллер>/<действие>` пример: `post/view`.
+Или добавлять в начало название модуля `blog/post/view`.
+
+Важно запомнить одно, нельзя чтобы первым символом был `/`. Такое имя не правильное `/blog/post/view` в моменте
+генерации URL методом `build`, маршрут с таким именем не будет найден, поскольку система будет его искать без
+символа `/`
+в начале, затем будет выброшено исключение `Navigator\RoutConfigurationException`.
+
+Пример правильного имени маршрута:
+
+```php
+new ArrayRulesProvider([
+ 'blog/post/view' => '/post/{id:\d+}',
+]);
+```
+
+Пример **НЕДОПУСТИМОГО** имени маршрута:
+
+```php
+new ArrayRulesProvider([
+ // Not working warrant /blog/post/view
+ '/blog/post/view' => '/post/{id:\d+}',
+]);
+```
+
+> В именах совсем не обязателен символ `/`, имя может быть и таким `blog.post.view` или каким-то другим не имеющим
+> разделителей вовсе.
+
+Как формировать маршруты вы можете прочитать в документации к библиотеке
+[FastRoute](https://github.com/nikic/FastRoute#defining-routes).
+
+## Заполнители
+
+### Схема показывает какой заполнитель, за какой участок URL-адреса отвечает
+
+```
+ |------------------------------:src--------------------------------------------------|
+ | :path ? # |
+ | /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\ /‾‾‾‾‾‾‾‾‾\ /‾‾‾‾‾‾\|
+ |http://grigor:password@vinograd.soft:8080/path/to/resource.json?query=value#fragment|
+ \__/ \___/ \_____/ \___________/ \__/ \__/
+ :scheme :user :password :host :port :suffix
+```
+
+Не все заполнители доступны для обоих методов генерации. Таблица ниже показывает доступность каждого заполнителя и тип
+данных значения.
+
+| Заполнитель | Доступность для метода `build` | Доступность для метода `buildExternal` | Тип |
+|--------------------------------------|:------------------------------:|:--------------------------------------:|:----------------------------------------------------------------------|
+| `название переменной из определения` | **ДА** | НЕТ | `string/int`
тип `bool` - для переменных без фигурных скобок`{}`. |
+| `:src` | НЕТ | **ДА** | `string` |
+| `:scheme` | НЕТ | **ДА** | `string` |
+| `:user` | НЕТ | **ДА** | `string` |
+| `:password` | НЕТ | **ДА** | `string` |
+| `:host` | НЕТ | **ДА** | `string` |
+| `:port` | НЕТ | **ДА** | `string` |
+| `:path` | НЕТ | **ДА** | `string/array` |
+| `:suffix` | НЕТ | **ДА** | `string` |
+| `?` | **ДА** | **ДА** | `string/array` |
+| `#` | **ДА** | **ДА** | `string` |
+| `:strategy` | **ДА** | **ДА** | `string` |
+| `:idn` | **ДА** | **ДА** | `bool` |
+
+## Примеры использования заполнителей
+
+Для начала сконфигурируем `$urlBuilder` таким образом:
+
+```php
+$urlBuilder = new UrlBuilder(
+ 'https://vinograd.soft',
+ new ArrayRulesProvider([
+ 'user' => '/user[/{var_name}]',
+ 'about' => '/about[.html]',
+ ])
+);
+```
+
+### **Название переменной из определения**
+
+> Не обязательные параметры в определениях не обрамленные в фигурные скобки имеют тип заполнителя `bool`.
+
+#### **Пример 1.**
+
+Страница "about" имеет в определении суффикс - не обязательный параметр `.html` без фигурных скобок. Требуется
+сгенерировать URL с этим суффиксом.
+
+```php
+echo $urlBuilder->build('about', ['.html' => true], true); # https://vinograd.soft/about.html
+```
+
+#### **Пример 2.**
+
+Сгенерируйте абсолютный URL-адрес используя необязательный заполнитель для определения `'user' => '/user[/{var_name}]'`.
+
+```php
+echo $urlBuilder->build('/user', ['var_name' => 'my_unique_value'], true);
+# https://vinograd.soft/user/my_unique_value
+```
+
+#### **Пример 3.**
+
+Требуется сгенерировать относительный URL-адрес с символом `/` в начале для определения
+`'user' => '/user[/{var_name}]'`.
+
+```php
+echo $urlBuilder->build('/user', ['var_name' => 'my_unique_value']); # /user/my_unique_value
+```
+
+#### **Пример 4.**
+
+Создайте относительный URL-адрес без символа `/` в начале для определения `'user' => '/user[/{var_name}]'`.
+
+```php
+echo $urlBuilder->build('user', ['var_name' => 'my_unique_value']); # user/my_unique_value
+```
+
+#### **Пример 5.**
+
+Определение `'user' => '/user[/{var_name}]'` имеет не обязательный параметр `var_name`. Нужно сгенерировать абсолютный
+URL-адрес без этого параметра.
+
+```php
+echo $urlBuilder->build('user', null, true); # https://vinograd.soft/user
+```
+
+---
+
+### **:src**
+
+#### **Пример 1.**
+
+В приложении есть переменная в которую записан URL-адрес внешнего ресурса. Нужно внести в URL-адрес несколько
+изменений:
+
++ изменить его схему с `http` на `ftp`
++ добавить имя пользователя `grigor` и пароль `password123`
++ изменить порт на `21`.
+
+```php
+$externalAddress = 'http://another.site:8080/path/to/resource';
+echo $urlBuilder->buildExternal([
+ ':src' => $externalAddress,
+ ':scheme' => 'ftp',
+ ':user' => 'grigor',
+ ':password' => 'password123',
+ ':port' => '21'
+]);
+# ftp://grigor:password123@another.site:21/path/to/resource
+```
+
+#### **Пример 2.**
+
+Добавьте путь `blog/post/41` к URL-адресу главной странице другого сайта `http://another.site`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'http://another.site',
+ ':path' => ['blog', 'post', 41],
+]);
+# http://another.site/blog/post/41
+```
+
+---
+
+### **:scheme**
+
+#### **Пример 1.**
+
+Измените схему URL-адреса `http://another.site` с `http` на `https`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'http://another.site',
+ ':scheme' => 'https',
+]);
+# https://another.site
+```
+
+#### **Пример 2.**
+
+Создайте URL-адрес `https://another.site/path/to/resource` из имеющихся в приложении частей со схемой `https`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':scheme' => 'https',
+ ':host' => 'another.site',
+ ':path' => ['path', 'to', 'resource'],
+]);
+# https://another.site/path/to/resource
+```
+
+---
+
+### **:user**
+
+#### **Пример.**
+
+Добавьте имя пользователя `user` для URL-адреса `http://another.site`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'http://another.site',
+ ':user' => 'user'
+]);
+# http://user@another.site
+```
+
+---
+
+### **:password**
+
+> Этот заполнитель работает только в паре с заполнителем `:user`. Часть `:user` либо должна присутствовать в исходном
+> URL-адресе передаваемом в заполнителе `:src`, либо должен передаваться в паре с заполнителем `:password`, если `:user`
+> в URL его не окажется, будет выброшено исключение `Navigator\BadParameterException`.
+
+#### **Пример.**
+
+Добавьте имя пользователя `grigor` и пароль `password123` для URL-адреса `ftp://another.site:21`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'ftp://another.site:21',
+ ':user' => 'grigor',
+ ':password' => 'password123'
+]);
+# ftp://grigor:password123@another.site:21
+```
+
+---
+
+### **:host**
+
+> Заполнитель `:host` используется в паре с заполнителем `:scheme`. В случае когда вы не используете
+> заполнитель `:src`, хотите создать URL-адрес из частей, то `:host` и `':scheme'` будут обязательными, при отсутствии
+> любого из них будет выброшено исключение `Navigator\BadParameterException`. `:host` единственный заполнитель
+> который не переопределяет свой участок в URL-адресе переданным заполнителем `:src`.
+
+#### **Пример.**
+
+Требуется создать URL-адрес `http://another.site`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':scheme' => 'http',
+ ':host' => 'another.site'
+]);
+# http://another.site
+```
+
+---
+
+### **:port**
+
+#### **Пример.**
+
+Создайте URL-адрес `http://another.site` с портом `5000`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':scheme' => 'http',
+ ':host' => 'another.site',
+ ':port' => '5000'
+]);
+# http://another.site:5000
+```
+
+---
+
+### **:path**
+
+#### **Пример 1.**
+
+Требуется создать URL-адрес `https://another.site/path/to` используя значение заполнителя с типом `array`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://another.site',
+ ':path' => ['path', 'to']
+]);
+# https://another.site/path/to
+```
+
+#### **Пример 2.**
+
+Сгенерируйте URL-адрес `https://another.site/path/to` используя значение заполнителя с типом `string`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://another.site',
+ ':path' => 'path/to'
+]);
+# https://another.site/path/to
+```
+
+---
+
+### **:suffix**
+
+> С суффиксами есть один нюанс, если вы передаете URL-адрес с суффиксом средствами заполнителя `:src` вы не сможете
+> его переопределить заполнителем `:suffix`, вы можете его только добавить, поскольку суффиксом может быть любая стока,
+> он не анализируется и будет являться частью path.
+
+#### **Пример 1.**
+
+Добавьте к URL-адресу `https://another.site/path/to` расширение `.html`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://another.site',
+ ':path' => 'path/to',
+ ':suffix' => '.html'
+]);
+# https://another.site/path/to.html
+```
+
+#### **Пример 2.**
+
+Добавьте суффикс `-city` к URL-адресу `https://another.site/path/to?q=value#news`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://another.site/path/to?q=value#news',
+ ':suffix' => '-city'
+]);
+# https://another.site/path/to-city?q=value#news
+```
+
+---
+
+### **Заполнитель `?`**
+
+#### **Пример 1.**
+
+Добавьте парамер `s` со значением `Hello world` URL-адресу `https://another.site`.
+
+```php
+echo $urlBuilder->buildExternal([':src' => 'https://another.site', '?' => ['s' => 'Hello world']]);
+# https://another.site/?s=Hello%20world
+```
+
+#### **Пример 2.**
+
+В системе есть подготовленный параметр `s=Hello world` нужно добавить его к URL-адресу `https://another.site`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://another.site',
+ '?' => 's=Hello world'
+]);
+# https://another.site/?s=Hello%20world
+```
+
+#### **Пример 3.**
+
+Требуется создать URL-адрес для ссылки на результаты поиска другого сайта `https://another.site` со следующими
+параметрами:
+
+```php
+ [
+ 'search' => [
+ 'blog' => [
+ 'category' => 'news',
+ 'author' => 'Grigor'
+ ]
+ ]
+ ]
+```
+
+Код:
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://another.site',
+ '?' => [
+ 'search' => [
+ 'blog' => [
+ 'category' => 'news',
+ 'author' => 'Grigor'
+ ]
+ ]
+ ]
+]);
+# https://another.site/?search%5Bblog%5D%5Bcategory%5D=news&search%5Bblog%5D%5Bauthor%5D=Grigor
+```
+
+---
+
+### **Заполнитель `#`**
+
+#### **Пример 1.**
+
+Задача сформировать адрес для ссылки на документацию библиотеки `vinogradsoft/compass`, параграф
+`быстрый-старт`.
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://github.com/vinogradsoft/compass',
+ '#' => 'быстрый-старт'
+]);
+# https://github.com/vinogradsoft/compass#быстрый-старт
+```
+
+---
+
+### **:strategy**
+
+> Для использования стратегий создания URL-адресов нужно либо реализовать интерфейс `Compass\UrlStrategy`,
+> либо наследоваться от класса `Compass\DefaultUrlStrategy`. Более подробно о принципах работы стратегий можно прочитать
+> в документации к библиотеке
+> [Compass](https://github.com/vinogradsoft/compass#%D1%81%D1%82%D1%80%D0%B0%D1%82%D0%B5%D0%B3%D0%B8%D0%B8-%D0%BE%D0%B1%D0%BD%D0%BE%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D1%8F).
+
+#### **Пример 1.**
+
+Задача сделать стратегию для генерации URL-адресов реферальных ссылок с параметром `refid` равному `222`.
+
+Код стратегии:
+
+```php
+;
+
+use Compass\DefaultUrlStrategy;
+use Compass\Url;
+
+class ReferralUrlStrategy extends DefaultUrlStrategy
+{
+
+ /**
+ * @inheritDoc
+ */
+ public function updateQuery(array $items): string
+ {
+ $items['refid'] = 222;
+ return http_build_query($items, '', '&', PHP_QUERY_RFC3986);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function forceUnlockMethod(
+ bool &$schemeState,
+ int &$authoritySate,
+ int &$relativeUrlState,
+ array $items,
+ array $pathItems,
+ array $queryItems,
+ bool $updateAbsoluteUrl,
+ ?string $suffix = null
+ ): void
+ {
+ $relativeUrlState &= ~Url::QUERY_STATE;
+ }
+
+}
+```
+
+Добавим стратегию в конструктор `$urlBuilder`:
+
+```php
+$urlBuilder = new UrlBuilder(
+ 'https://vinograd.soft',
+ new ArrayRulesProvider([
+ 'user' => '/user[/{var_name}]'
+ ]),
+ ['referral' => new ReferralUrlStrategy()]
+);
+```
+
+Генерируем URL-адрес:
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://another.site/path/to/resource',
+ ':strategy' => 'referral'
+]);
+# https://another.site/path/to/resource?refid=222
+```
+
+#### **Пример 2.**
+
+Возьмем стратегию из Примера 1 этого параграфа, и сделаем ее стратегией по умолчанию для генерации внешних URL-адресов.
+
+Заменим в экземпляре класса `Compass\Url` стратегию и передадим его в конструктор нашего `$urlBuilder` пятым
+параметром (`$externalUrl`):
+
+```php
+$externalUrl = Url::createBlank();
+$externalUrl->setUpdateStrategy(new ReferralUrlStrategy());
+
+$urlBuilder = new UrlBuilder(
+ 'https://vinograd.soft',
+ new ArrayRulesProvider([
+ 'user' => '/user[/{var_name}]'
+ ]),
+ [],
+ null,
+ $externalUrl
+);
+```
+
+Генерируем URL-адрес:
+
+```php
+echo $urlBuilder->buildExternal([
+ ':src' => 'https://another.site/path/to/resource'
+]);
+# https://another.site/path/to/resource?refid=222
+```
+
+---
+
+### **:idn**
+
+#### **Пример 1.**
+
+Задача конвертировать URL-адрес `https://россия.рф` в panycode.
+
+```php
+echo $urlBuilder->buildExternal([':src' => 'https://россия.рф', ':idn' => true]);
+# https://xn--h1alffa9f.xn--p1ai
+```
+
+---
+
+## Тестировать
+
+``` php composer tests ```
+
+## Содействие
+
+Пожалуйста, смотрите [ВКЛАД](https://github.com/vinogradsoft/navigator/blob/master/CONTRIBUTING.md) для получения
+подробной информации.
+
+## Лицензия
+
+Лицензия MIT (MIT). Пожалуйста, смотрите [файл лицензии](https://github.com/vinogradsoft/navigator/blob/master/LICENSE)
+для получения дополнительной информации.
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..4f3cf4c
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,46 @@
+{
+ "name": "vinogradsoft/navigator",
+ "description": "URL generator",
+ "authors": [
+ {
+ "name": "vinograd",
+ "email": "cmk.cmyk@mail.ru"
+ }
+ ],
+ "version": "1.0.0",
+ "minimum-stability": "stable",
+ "require": {
+ "php": ">=8.0",
+ "vinogradsoft/compass": "^1.0",
+ "nikic/fast-route": "^1.3",
+ "ext-intl": "*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6",
+ "overtrue/phplint": "^2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Navigator\\": [
+ "src/"
+ ]
+ }
+ },
+ "type": "context",
+ "autoload-dev": {
+ "psr-4": {
+ "Test\\": "tests/"
+ }
+ },
+ "repositories": [
+ {
+ "type": "composer",
+ "url": "https://asset-packagist.org"
+ }
+ ],
+ "scripts": {
+ "lint": "phplint",
+ "tests": "php ./vendor/bin/phpunit --colors=always tests",
+ "coverage": "XDEBUG_MODE=coverage phpunit --colors=always --coverage-html tests/coverage"
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..9611426
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Adapter.php b/src/Adapter.php
new file mode 100644
index 0000000..abaa1b9
--- /dev/null
+++ b/src/Adapter.php
@@ -0,0 +1,24 @@
+ $rules
+ */
+ public function __construct(array $rules)
+ {
+ $this->rules = $rules;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getPattern(string $name): string
+ {
+ $name = ltrim($name, "/ \t\n\r\0\x0B");
+ if ($name === '') {
+ throw new RoutConfigurationException('The name cannot be empty.');
+ }
+ return $this->rules[$name] ?? throw new RoutConfigurationException('No route with this name was found.');
+ }
+
+}
\ No newline at end of file
diff --git a/src/BadParameterException.php b/src/BadParameterException.php
new file mode 100644
index 0000000..958a8c4
--- /dev/null
+++ b/src/BadParameterException.php
@@ -0,0 +1,9 @@
+ $segment) {
+ if ($segment === '' && $index !== 0) {
+ throw new BadParameterException('Empty optional part');
+ }
+ if ($segment === '' && $index === 0) {
+ continue;
+ }
+ if ($index === 0) {
+ $data = $this->fillPlaceholders(is_int(strpos($segment, '{')), $segment, $placeholders);
+ } else {
+ $data = $this->fillPlaceholders(false, $segment, $placeholders);
+ }
+
+ if ($data && $emptyCounter === 0) {
+
+ if (str_starts_with($segment, '/')) {
+ $url .= '/' . $data;
+ } else {
+ $url .= $data;
+ }
+
+ } elseif ($data && $emptyCounter > 0) {
+ $errorData[] = $data;
+ } else {
+ $emptyCounter++;
+ }
+
+ }
+ if (!empty($errorData)) {
+ throw new BadParameterException('Not enough parameters.');
+ }
+ return explode('/', ltrim($url, '/'));
+ }
+
+ /**
+ * @param bool $require
+ * @param string $segment
+ * @param array $placeholders
+ * @return string|null
+ */
+ private function fillPlaceholders(bool $require, string $segment, array $placeholders): ?string
+ {
+ if (!preg_match_all(
+ '~' . Std::VARIABLE_REGEX . '~x', $segment, $matches,
+ PREG_OFFSET_CAPTURE | PREG_SET_ORDER
+ )) {
+ if (!str_starts_with($segment, '/')) {
+ if (!isset($placeholders[$segment])) {
+ return null;
+ }
+ if (!is_bool($placeholders[$segment])) {
+ throw new BadParameterException('Invalid placeholder type.');
+ }
+ return $placeholders[$segment] ? ltrim($segment, '/') : null;
+ }
+ return ltrim($segment, '/');
+ }
+
+ $matchesCount = count($matches);
+ $counter = $matchesCount;
+
+ foreach ($matches as $set) {
+ $item = $set[1][0];
+ $data = $placeholders[$item] ?? null;
+ if (is_string($data) || is_int($data)) {
+
+ if (
+ !preg_match(
+ '~^' . (isset($set[2]) ? trim($set[2][0]) : Std::DEFAULT_DISPATCH_REGEX) . '$~isu',
+ (string)$data,
+ $m,
+ PREG_OFFSET_CAPTURE
+ )
+ ) {
+ throw new BadParameterException('Invalid placeholder type.');
+ }
+
+ $segment = str_replace($set[0][0], (string)$data, $segment);
+
+ } elseif ($require && $data === null) {
+ throw new BadParameterException('Not enough parameters.');
+ } else {
+ $counter--;
+ }
+ }
+ if ($matchesCount !== $counter && $counter !== 0) {
+ throw new BadParameterException('Not enough parameters.');
+ }
+ if (($matchesCount - $counter) === 0) {
+ return ltrim($segment, '/');
+ }
+ return null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function buildStaticPath(string $pattern): string
+ {
+ $bracePos = strpos($pattern, '{');
+ $squareBracketPos = strpos($pattern, '[');
+
+ if ($bracePos === false && $squareBracketPos === false) {
+ return ltrim($pattern, '/');
+ } elseif ($squareBracketPos !== false && ($squareBracketPos < $bracePos || is_int($squareBracketPos) && is_bool($bracePos))) {
+ $result = ltrim(substr($pattern, 0, $squareBracketPos), '/');
+ if (empty($result)) {
+ return '';
+ }
+ return $result;
+ }
+
+ throw new BadParameterException('Bad parameters.');
+ }
+
+}
\ No newline at end of file
diff --git a/src/RoutConfigurationException.php b/src/RoutConfigurationException.php
new file mode 100644
index 0000000..954dfdb
--- /dev/null
+++ b/src/RoutConfigurationException.php
@@ -0,0 +1,9 @@
+baseUrl = $baseUrl;
+ $this->rulesProvider = $rulesProvider;
+ $this->strategies = $strategies;
+ $this->url = $url ?? Url::createBlank();
+ $this->url->setSource($this->baseUrl);
+ $this->adapter = $adapter ?? new FastRouteAdapter();
+ $this->externalUrl = $externalUrl;
+ }
+
+ /**
+ * @param string $name
+ * @return UrlStrategy
+ */
+ protected function getStrategy(string $name): UrlStrategy
+ {
+ if (array_key_exists($name, $this->strategies)) {
+ return $this->strategies[$name];
+ }
+ throw new BadParameterException(sprintf('The parameter %s is not registered.', $name));
+ }
+
+ /**
+ * @return string
+ */
+ public function getBaseUrl(): string
+ {
+ return $this->baseUrl;
+ }
+
+ /**
+ * @param string $name
+ * @param array|null $placeholders
+ * @param bool $absolute
+ * @return string
+ * @throws RoutConfigurationException
+ * @throws BadParameterException
+ */
+ public function build(
+ string $name,
+ array|null $placeholders = null,
+ bool $absolute = false
+ ): string
+ {
+ $pathResult = null;
+ $this->url->clearRelativeUrl();
+ if (!$absolute && ($separator = substr($name, 0, 1)) && $separator === '/') {
+ $pathResult[] = '';
+ }
+
+ if (!$placeholders) {
+ $pattern = $this->rulesProvider->getPattern($name);
+ $result = $this->adapter->buildStaticPath($pattern);
+ $this->url->setPath(empty($pathResult) ? ltrim($result, '/') : '/' . ltrim($result, '/'));
+ } else {
+ $dynamic = $this->adapter->buildDynamicPath($this->rulesProvider->getPattern($name), $placeholders);
+ $pathResult = $pathResult ? array_merge($pathResult, $dynamic) : $dynamic;
+ $this->url->setArrayPath($pathResult);
+ }
+
+ if ($fragment = $placeholders['#'] ?? null) {
+ $this->url->setFragment($fragment);
+ }
+
+ if (!empty($placeholders['?'])) {
+ $query = $placeholders['?'];
+ if (is_string($query)) {
+ $this->url->setQuery($query);
+ } elseif (is_array($query)) {
+ $this->url->setArrayQuery($query);
+ }
+ }
+
+ $originStrategy = null;
+
+ if ($absolute && isset($placeholders[':idn'])) {
+ $this->url->setConversionIdnToAscii($placeholders[':idn']);
+ } elseif ($absolute && !isset($placeholders[':idn'])) {
+ $this->url->setConversionIdnToAscii(false);
+ }
+
+ if ($strategyName = $placeholders[':strategy'] ?? null) {
+ $strategy = $this->getStrategy($strategyName);
+ $originStrategy = $this->url->getUpdateStrategy();
+ $this->url->setUpdateStrategy($strategy);
+ }
+
+ $this->url->updateSource($absolute);
+
+ if ($absolute) {
+ $result = $this->url->getSource();
+ } else {
+ $result = $this->url->getRelativeUrl();
+ if (empty($result)) {
+ $result = '/';
+ }
+ }
+ if (!empty($originStrategy)) {
+ $this->url->setUpdateStrategy($originStrategy);
+ }
+ return $result;
+ }
+
+ /**
+ * @param array $placeholders
+ * @return string
+ */
+ public function buildExternal(array $placeholders): string
+ {
+ if (empty($this->externalUrl)) {
+ $this->externalUrl = Url::createBlank();
+ }
+ try {
+ if (isset($placeholders[':src'])) {
+ $this->externalUrl->setSource($placeholders[':src']);
+ if ($scheme = $placeholders[':scheme'] ?? null) {
+ $this->externalUrl->setScheme($scheme);
+ }
+ if ($user = $placeholders[':user'] ?? null) {
+ $this->externalUrl->setUser($user);
+ }
+ if ($password = $placeholders[':password'] ?? null) {
+ if (!$this->externalUrl->getUser()) {
+ throw new BadParameterException('The :user placeholder was not found.');
+ }
+ $this->externalUrl->setPassword($password);
+ }
+ } else {
+ $this->externalUrl->reset();
+ $host = $placeholders[':host'] ?? throw new BadParameterException('The :host placeholder was not found.');
+ $this->externalUrl->setHost($host);
+
+ $scheme = $placeholders[':scheme'] ?? throw new BadParameterException('The :scheme placeholder was not found.');
+ $this->externalUrl->setScheme($scheme);
+ if ($user = $placeholders[':user'] ?? null) {
+ $this->externalUrl->setUser($user);
+ }
+ if ($password = $placeholders[':password'] ?? null) {
+ if (!isset($placeholders[':user'])) {
+ throw new BadParameterException('The :user placeholder was not found.');
+ }
+ $this->externalUrl->setPassword($password);
+ }
+ }
+
+ if ($port = $placeholders[':port'] ?? null) {
+ $this->externalUrl->setPort($port);
+ }
+ if ($path = $placeholders[':path'] ?? null) {
+ if (is_string($path)) {
+ $this->externalUrl->setPath($path);
+ } elseif (is_array($path)) {
+ $this->externalUrl->setArrayPath($path);
+ }
+ }
+ if ($suffix = $placeholders[':suffix'] ?? null) {
+ $this->externalUrl->setSuffix($suffix);
+ }
+ if ($query = $placeholders['?'] ?? null) {
+ if (is_string($query)) {
+ $this->externalUrl->setQuery($query);
+ } elseif (is_array($query)) {
+ $this->externalUrl->setArrayQuery($query);
+ }
+ }
+ if ($fragment = $placeholders['#'] ?? null) {
+ $this->externalUrl->setFragment($fragment);
+ }
+
+ $this->externalUrl->setConversionIdnToAscii($placeholders[':idn'] ?? false);
+ $originStrategy = null;
+ if ($strategyName = $placeholders[':strategy'] ?? null) {
+ $strategy = $this->getStrategy($strategyName);
+ $originStrategy = $this->externalUrl->getUpdateStrategy();
+ $this->externalUrl->setUpdateStrategy($strategy);
+ }
+ $this->externalUrl->updateSource();
+ $result = $this->externalUrl->getSource();
+ if (!empty($originStrategy)) {
+ $this->externalUrl->setUpdateStrategy($originStrategy);
+ }
+ return $result;
+ } catch (InvalidUrlException $e) {
+ throw new BadParameterException($e->getMessage());
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..4f9ee23
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,2 @@
+/cache/
+/coverage/
\ No newline at end of file
diff --git a/tests/Cases/Dummy/ReferralUrlStrategy.php b/tests/Cases/Dummy/ReferralUrlStrategy.php
new file mode 100644
index 0000000..9e8dabd
--- /dev/null
+++ b/tests/Cases/Dummy/ReferralUrlStrategy.php
@@ -0,0 +1,37 @@
+rulesProvider = new ArrayRulesProvider(
+ [
+ $this->var1 => $this->value1,
+ $this->var2 => $this->value2,
+ $this->var3 => $this->value3,
+ $this->var4 => $this->value4,
+ $this->var5 => $this->value5,
+ ]
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function testGetPattern()
+ {
+ $result1 = $this->rulesProvider->getPattern($this->var1);
+ $result2 = $this->rulesProvider->getPattern($this->var2);
+ $result3 = $this->rulesProvider->getPattern($this->var3);
+ $result4 = $this->rulesProvider->getPattern($this->var4);
+ $result5 = $this->rulesProvider->getPattern($this->var5);
+ self::assertEquals($this->value1, $result1);
+ self::assertEquals($this->value2, $result2);
+ self::assertEquals($this->value3, $result3);
+ self::assertEquals($this->value4, $result4);
+ self::assertEquals($this->value5, $result5);
+
+ $result1 = $this->rulesProvider->getPattern('/' . $this->var1);
+ $result2 = $this->rulesProvider->getPattern('/' . $this->var2);
+ $result3 = $this->rulesProvider->getPattern('/' . $this->var3);
+ $result4 = $this->rulesProvider->getPattern('/' . $this->var4);
+ $result5 = $this->rulesProvider->getPattern('/' . $this->var5);
+ self::assertEquals($this->value1, $result1);
+ self::assertEquals($this->value2, $result2);
+ self::assertEquals($this->value3, $result3);
+ self::assertEquals($this->value4, $result4);
+ self::assertEquals($this->value5, $result5);
+
+ $result1 = $this->rulesProvider->getPattern('//' . $this->var1);
+ $result2 = $this->rulesProvider->getPattern('//' . $this->var2);
+ $result3 = $this->rulesProvider->getPattern('//' . $this->var3);
+ $result4 = $this->rulesProvider->getPattern('//' . $this->var4);
+ $result5 = $this->rulesProvider->getPattern('//' . $this->var5);
+ self::assertEquals($this->value1, $result1);
+ self::assertEquals($this->value2, $result2);
+ self::assertEquals($this->value3, $result3);
+ self::assertEquals($this->value4, $result4);
+ self::assertEquals($this->value5, $result5);
+ }
+
+ /**
+ * @return void
+ */
+ public function testGetPatternException()
+ {
+ $this->expectException(RoutConfigurationException::class);
+ $this->rulesProvider->getPattern('no');
+ }
+
+ /**
+ * @dataProvider getData
+ */
+ public function testConstructAndGetPatternException($name, $badName)
+ {
+ $this->expectException(RoutConfigurationException::class);
+ $this->rulesProvider = new ArrayRulesProvider(
+ [
+ $badName => $this->value1
+ ]
+ );
+ $this->rulesProvider->getPattern($name);
+ }
+
+ /**
+ * @return array[]
+ */
+ public function getData()
+ {
+ return [
+ ['/user', '/user'],
+ [' /user', '/user'],
+ [' /user', ' /user'],
+ ['user', '/user'],
+ ['user', ' /user'],
+ [' user', ' /user'],
+ ['user', '/'],
+ ['/', '/'],
+ [' /', '/'],
+ ['/ ', '/'],
+ ['/', ' /'],
+ ['/', '/ '],
+ [' /', '/ '],
+ [' / ', ' / '],
+ ['/ ', '/ '],
+ [' /', ' /'],
+ ['/', ''],
+ ['/ ', ' '],
+ [' / ', ' '],
+ [' /', ' '],
+ ['', '/'],
+ ['', ''],
+ [' ', ' '],
+ [' ', ''],
+ [' ', ''],
+ ["/ \t\n\r\0\x0B", ''],
+ ['', ' ']
+ ];
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Unit/FastRouteAdapterTest.php b/tests/Unit/FastRouteAdapterTest.php
new file mode 100644
index 0000000..eb59806
--- /dev/null
+++ b/tests/Unit/FastRouteAdapterTest.php
@@ -0,0 +1,187 @@
+fastRouteAdapter = new FastRouteAdapter();
+ }
+
+ /**
+ * @dataProvider getData
+ */
+ public function testBuildDynamicPath($pattern, $placeholders, $expected)
+ {
+ $result = $this->fastRouteAdapter->buildDynamicPath($pattern, $placeholders);
+ self::assertEquals($expected, $result);
+ }
+
+ /**
+ * @return array[]
+ */
+ public function getData()
+ {
+ return [
+ ['/user/{id:\d+}[/{name}]', ['id' => 1], ['user', 1]],
+ ['/user/{id:\d+}[/{name}]', ['id' => 1, 'name' => 'test'], ['user', 1, 'test']],
+ ['/user/{id:\d+}', ['id' => 1], ['user', 1]],
+ ['/user/{id:\d+}/{name}', ['id' => 1, 'name' => 'test'], ['user', '1', 'test']],
+ ['/user[/{id:\d+}[/{name}]]', ['id' => 1, 'name' => 'test'], ['user', 1, 'test']],
+ ['/user[/{id:\d+}[/{name}]]', ['id' => 1,], ['user', 1]],
+ ['/user[/{id:\d+}[/{name}]]', [], ['user']],
+ ['/user/{name}/{id:[0-9]+}', ['name' => 'test', 'id' => 1,], ['user', 'test', 1]],
+ ['/user/{name}/{id:[a-z]+}', ['id' => 'aaaaaaa', 'name' => 'test'], ['user', 'test', 'aaaaaaa']],
+
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}]]]]', ['id' => 1, 'name' => 'test', 'ids' => 2, 'names' => 'tests'], ['user', '1', 'test', '2', 'tests']],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}]]]]', ['id' => 1, 'name' => 'test', 'ids' => 2,], ['user', '1', 'test', '2']],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}]]]]', ['id' => 1, 'name' => 'test'], ['user', '1', 'test']],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}]]]]', ['id' => 1], ['user', '1']],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}]]]]', [], ['user']],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['key' => 40, 'id' => 1, 'name' => 'test', 'ids' => 2, 'names' => 'tests'], ['user', '1', 'test', '2', 'tests', '40']],
+
+ ['/test/{param}', ['param' => 'paramvalue'], ['test', 'paramvalue']],
+ ['/test/{param1}/test2/{param2}', ['param1' => 'param1value', 'param2' => 'param2value'], ['test', 'param1value', 'test2', 'param2value']],
+ ['/test/{param:\d+}', ['param' => 1], ['test', 1]],
+ ['/test[/{param}]', ['param' => 'paramvalue'], ['test', 'paramvalue']],
+ ['/test[/{param}]', [], ['test']],
+ ['/{foo-bar}', ['foo-bar' => 'foo-barvalue'], ['foo-barvalue']],
+ ['/{_foo:.*}', ['_foo' => '_foovalue'], ['_foovalue']],
+ ['/te{ param }st', ['param' => 'paramvalue'], ['teparamvaluest']],
+ ['/test/{ param : \d{1,9} }', ['param' => 1], ['test', '1']],
+
+ ['[test]', ['test' => true], ['test']],
+ ['[test]', ['test' => false], ['']],
+ ['/{param}[opt]', ['param' => 'paramvalue', 'opt' => true], ['paramvalueopt']],
+ ['/{param}[opt]', ['param' => 'paramvalue', 'opt' => false], ['paramvalue']],
+ ['/{param}[opt]', ['param' => 'paramvalue'], ['paramvalue']],
+ ['/{param}[opt]', ['param' => 'value', 'opt' => true], ['valueopt']],
+ ['/{param}[opt]', ['param' => 'value', 'opt' => false], ['value']],
+ ['/{param}[opt]', ['param' => 'value'], ['value']],
+ ['/test[opt]', ['opt' => true], ['testopt']],
+ ['/test[opt]', ['opt' => false], ['test']],
+ ];
+ }
+
+ /**
+ * @dataProvider getBadData
+ */
+ public function testBuildDynamicPathException($pattern, $placeholders)
+ {
+ $this->expectException(BadParameterException::class);
+ $this->fastRouteAdapter->buildDynamicPath($pattern, $placeholders);
+ }
+
+ /**
+ * @return array[]
+ */
+ public function getBadData()
+ {
+ return [
+ ['/user/{id:\d+}[/{name}]', []],
+ ['/user/{id:\d+}', ['f' => 1]],
+ ['/user/{id:\d+}/{test}', ['test' => 1]],
+ ['/user/{id:\d+}', ['ids' => 1]],
+ ['/user/{name}', []],
+ ['/user/{name}', ['ids' => 1]],
+ ['/user/{name}', ['names' => 'test']],
+ ['/user/{id:\d+}/{ids:\d+}[/{name}]', ['id' => 1]],
+ ['/user/{id:\d+}/{ids:\d+}[/{name}]', ['ids' => 1]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['name' => 'test', 'ids' => 2, 'names' => 'tests', 'key' => 40,]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['ids' => 2, 'names' => 'tests', 'key' => 40,]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['names' => 'tests', 'key' => 40,]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['key' => 40,]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]', ['key' => 40,]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['id' => 1, 'ids' => 2, 'names' => 'tests', 'key' => 'key',]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['id' => 1, 'name' => 'test', 'names' => 'tests', 'key' => 'key',]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['id' => 1, 'name' => 'test', 'ids' => 2, 'key' => 'key',]],
+ ['/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]', ['id' => 1, 'name' => 'test', 'key' => 'key',]],
+ ['/user[/{id:\d+}][/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]', ['id' => 1, 'name' => 'test', 'key' => 'key',]],
+ ['/user/[/{name}]/{id:\d+}', ['id' => 1]],
+ ['/user/[/{name}]/{id:\d+}', []],
+ ['/user/[/{name}]/{id:\d+}', ['id' => 1, 'name' => 'name']],
+ ['/user/[/{name}]/{id:\d+}', []],
+ ['/user/[/{name}][/{id:\d+}]', ['id' => 1, 'name' => 'name']],
+ ['/user/[/{name}][/{id:\d+}]', ['name' => 'name']],
+ ['/user/[/{name}][/{id:\d+}]', []],
+ ['/test[opt', []],
+ ['/test[opt', ['opt' => true,]],
+ ['/test[opt', ['opt' => false,]],
+ ['/test[opt[opt2]', ['opt' => true, 'opt2' => true,]],
+ ['/test[opt[opt2]', ['opt' => true, 'opt2' => false,]],
+ ['/test[opt[opt2]', ['opt' => false, 'opt2' => true,]],
+ ['/testopt]', []],
+ ['/test[]', []],
+ ['/test[[opt]]', ['opt' => true]],
+ ['[[test]]', ['test' => true]],
+ ['/test[/opt]/required', ['opt' => true]],
+ ];
+ }
+
+ /**
+ * @dataProvider getStaticData
+ */
+ public function testBuildStaticPath($pattern, $expected)
+ {
+ $result = $this->fastRouteAdapter->buildStaticPath($pattern);
+ self::assertEquals($expected, $result);
+ }
+
+ /**
+ * @return array
+ */
+ public function getStaticData()
+ {
+ return [
+ ['[test]', ''],
+ ['', ''],
+ ['/test', 'test'],
+ ['/test/test2', 'test/test2'],
+ ['/test[opt]', 'test'],
+ ['/test[/{param}]', 'test'],
+ ];
+ }
+
+ /**
+ * @dataProvider getBadStaticData
+ */
+ public function testBuildStaticPathException($pattern)
+ {
+ $this->expectException(BadParameterException::class);
+ $this->fastRouteAdapter->buildStaticPath($pattern);
+ }
+
+ /**
+ * @return array
+ */
+ public function getBadStaticData()
+ {
+ return [
+ ['/user/{id:\d+}'],
+ ['/user/{id:\d+}/{name}'],
+ ['/user/{name}/{id:[0-9]+}'],
+ ['/user/{name}/{id:[a-z]+}'],
+
+ ['/test/{param}'],
+ ['/test/{param1}/test2/{param2}'],
+ ['/test/{param:\d+}'],
+ ['/{foo-bar}'],
+ ['/{_foo:.*}'],
+ ['/te{ param }st'],
+ ['/test/{ param : \d{1,9} }'],
+ ['/{param}[opt]'],
+ ];
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Unit/UrlBuilder/BuildExternalTest.php b/tests/Unit/UrlBuilder/BuildExternalTest.php
new file mode 100644
index 0000000..ee531b2
--- /dev/null
+++ b/tests/Unit/UrlBuilder/BuildExternalTest.php
@@ -0,0 +1,258 @@
+urlBuilder = new UrlBuilder(
+ 'https://vinograd.soft',
+ new ArrayRulesProvider([])
+ );
+ }
+
+ /**
+ * @dataProvider getBadData()
+ */
+ public function testBuildExternalException(array $placeholders)
+ {
+ $this->expectException(BadParameterException::class);
+ $this->urlBuilder->buildExternal($placeholders);
+ }
+
+ /**
+ * @return array[]
+ */
+ public function getBadData(): array
+ {
+ return [
+ [
+ [':scheme' => 'http', ':user' => 'user', ':password' => '123']
+ ],
+ [
+ [':scheme' => 'http']
+ ],
+ [
+ [':host' => 'another.site', ':user' => 'user', ':password' => '123']
+ ],
+ [
+ [':host' => 'another.site']
+ ],
+ [
+ [':scheme' => 'http', ':host' => 'another.site', ':password' => '123']
+ ],
+ [
+ [':src' => 'https://another.site', ':password' => '123']
+ ],
+ [
+ [':src' => 'another.site']
+ ],
+ [
+ [':src' => 'https://']
+ ],
+ [
+ [':src' => 'test']
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider getData()
+ */
+ public function testBuildExternal(array $placeholders, string $expected)
+ {
+ $result = $this->urlBuilder->buildExternal($placeholders);
+ self::assertEquals($expected, $result);
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildExternalDouble()
+ {
+ $result = $this->urlBuilder->buildExternal([':src' => 'https://another.site', ':user' => 'user', ':password' => '123']);
+ self::assertEquals('https://user:123@another.site', $result);
+ $result = $this->urlBuilder->buildExternal([':src' => 'https://another.site', ':scheme' => 'https']);
+ self::assertEquals('https://another.site', $result);
+
+ $result = $this->urlBuilder->buildExternal([':scheme' => 'https', ':host' => 'another.site', ':user' => 'user', ':password' => '123', ':path' => ['path', 'to'], '?' => 'key=value', '#' => 'fragment']);
+ self::assertEquals('https://user:123@another.site/path/to?key=value#fragment', $result);
+ $result = $this->urlBuilder->buildExternal([':scheme' => 'https', ':host' => 'another.site']);
+ self::assertEquals('https://another.site', $result);
+ }
+
+ /**
+ * @return array
+ */
+ public function getData()
+ {
+ return [
+ [
+ [':src' => 'https://another.site', ':user' => 'user', ':password' => '123'],
+ 'https://user:123@another.site'
+ ],
+ [
+ [':src' => 'https://another.site', ':scheme' => 'ftp'],
+ 'ftp://another.site'
+ ],
+ [
+ [':scheme' => 'ftp', ':host' => 'another.site', ':user' => 'user', ':password' => '123'],
+ 'ftp://user:123@another.site'
+ ],
+ [
+ [':src' => 'https://another.site', ':port' => '8080'],
+ 'https://another.site:8080'
+ ],
+ [
+ [':src' => 'https://another.site', ':path' => 'path/to'],
+ 'https://another.site/path/to'
+ ],
+ [
+ [':src' => 'https://another.site', ':path' => ['path', 'to']],
+ 'https://another.site/path/to'
+ ],
+ [
+ [':src' => 'https://another.site', ':path' => ['path', 'to'], ':suffix' => '.html'],
+ 'https://another.site/path/to.html'
+ ],
+ [
+ [':src' => 'https://another.site', ':path' => ['path', 'to'], ':suffix' => '-city'],
+ 'https://another.site/path/to-city'
+ ],
+ [
+ [':src' => 'https://another.site', ':suffix' => '-city'],
+ 'https://another.site'
+ ],
+ [
+ [':src' => 'https://another.site', '?' => 'key=value'],
+ 'https://another.site/?key=value'
+ ],
+ [
+ [':src' => 'https://another.site', '?' => ['key' => 'value']],
+ 'https://another.site/?key=value'
+ ],
+ [
+ [':src' => 'https://another.site', '#' => 'fragment'],
+ 'https://another.site/#fragment'
+ ],
+ [
+ [':src' => 'https://another.site', ':user' => 'user', ':password' => '123', ':path' => ['path', 'to'], '?' => ['key' => 'value'], '#' => 'fragment'],
+ 'https://user:123@another.site/path/to?key=value#fragment'
+ ],
+ [
+ [':src' => 'https://another.site', ':user' => 'user', ':password' => '123', ':path' => ['path', 'to'], '?' => 'key=value', '#' => 'fragment'],
+ 'https://user:123@another.site/path/to?key=value#fragment'
+ ],
+ [
+ [':src' => 'https://another.site', ':user' => 'user', ':password' => '123', ':path' => 'path/to', '?' => 'key=value', '#' => 'fragment'],
+ 'https://user:123@another.site/path/to?key=value#fragment'
+ ],
+ [
+ [':src' => 'https://another.site', ':user' => 'user', ':password' => '123', ':path' => 'path/to', '?' => ['key' => 'value'], '#' => 'fragment'],
+ 'https://user:123@another.site/path/to?key=value#fragment'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':user' => 'user', ':password' => '123', ':path' => ['path', 'to'], '?' => ['key' => 'value'], '#' => 'fragment'],
+ 'https://user:123@another.site/path/to?key=value#fragment'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':user' => 'user', ':password' => '123', ':path' => ['path', 'to'], '?' => 'key=value', '#' => 'fragment'],
+ 'https://user:123@another.site/path/to?key=value#fragment'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':user' => 'user', ':password' => '123', ':path' => 'path/to', '?' => 'key=value', '#' => 'fragment'],
+ 'https://user:123@another.site/path/to?key=value#fragment'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':user' => 'user', ':password' => '123', ':path' => 'path/to', '?' => ['key' => 'value'], '#' => 'fragment'],
+ 'https://user:123@another.site/path/to?key=value#fragment'
+ ],
+
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':user' => 'user', ':password' => '123'],
+ 'https://user:123@another.site'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':user' => 'user', ':password' => '123'],
+ 'https://user:123@another.site'
+ ],
+ [
+ ['https', ':host' => 'another.site', ':scheme' => 'ftp'],
+ 'ftp://another.site'
+ ],
+ [
+ [':scheme' => 'ftp', ':host' => 'another.site', ':user' => 'user', ':password' => '123'],
+ 'ftp://user:123@another.site'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':port' => '8080'],
+ 'https://another.site:8080'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':path' => 'path/to'],
+ 'https://another.site/path/to'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':path' => ['path', 'to']],
+ 'https://another.site/path/to'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':path' => ['path', 'to'], ':suffix' => '.html'],
+ 'https://another.site/path/to.html'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':path' => ['path', 'to'], ':suffix' => '-city'],
+ 'https://another.site/path/to-city'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', ':suffix' => '-city'],
+ 'https://another.site'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', '?' => 'key=value'],
+ 'https://another.site/?key=value'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', '?' => ['key' => 'value']],
+ 'https://another.site/?key=value'
+ ],
+ [
+ [':scheme' => 'https', ':host' => 'another.site', '#' => 'fragment'],
+ 'https://another.site/#fragment'
+ ],
+ ];
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildExternalWithStrategy()
+ {
+ $urlBuilder = new UrlBuilder(
+ 'https://another.site',
+ new ArrayRulesProvider([]),
+ ['referral' => new ReferralUrlStrategy()]
+ );
+ $result = $urlBuilder->buildExternal([':src' => 'https://another.site', ':strategy' => 'referral']);
+ self::assertEquals('https://another.site/?refid=222', $result);
+ $result = $urlBuilder->buildExternal([':src' => 'https://another.site/path/to/resource', ':strategy' => 'referral']);
+ self::assertEquals('https://another.site/path/to/resource?refid=222', $result);
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Unit/UrlBuilder/BuildTest.php b/tests/Unit/UrlBuilder/BuildTest.php
new file mode 100644
index 0000000..c666d22
--- /dev/null
+++ b/tests/Unit/UrlBuilder/BuildTest.php
@@ -0,0 +1,483 @@
+urlBuilder = new UrlBuilder(
+ self::BASE_URL,
+ new ArrayRulesProvider([
+ 'user1' => '/user/{id:\d+}[/{name}]',
+ 'user2' => '/user[/{id:\d+}[/{name}]]',
+ 'user3' => '/user/{id:\d+}/{name}',
+ 'user4' => '/user/{name}/{id:[0-9]+}',
+ 'user5' => '/user/{name}/{id:[a-z]+}',
+ 'user6' => '/user/{id:\d+}',
+ 'user7' => '/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}]]]]',
+ 'user8' => '/user[/{id:\d+}[/{name}[/{ids:\d+}[/{names}/{key:\d+}]]]]',
+
+ 'test1' => '/test/{param}',
+ 'test2' => '/test[/{param}]',
+ 'test3' => '/test/{param:\d+}',
+ 'test4' => '/test/{param1}/test2/{param2}',
+ 'test5' => '/test/{ param : \d{1,4} }',
+ 'test6' => '[test]',
+ 'test7' => '/test[opt]',
+
+ 'foo-bar' => '/{foo-bar}',
+ '_foo' => '/{_foo:.*}',
+ 'te' => '/te{ param }st',
+ 'param/opt' => '/{param}[opt]',
+
+ 'static1' => '[test]',
+ 'static2' => '',
+ 'static3' => '/test',
+ 'static4' => '/test/test2',
+ 'static5' => '/test[opt]',
+ 'static6' => '/test[/{param}]',
+ ])
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function testGetBaseUrl()
+ {
+ $baseUrl = $this->urlBuilder->getBaseUrl();
+ self::assertEquals(self::BASE_URL, $baseUrl);
+ }
+
+ /**
+ * @dataProvider getData
+ */
+ public function testBuild($routName, $placeholders, $expected, $absolute, $slash)
+ {
+ $result = $this->urlBuilder->build($slash ? '/' . $routName : $routName, $placeholders, $absolute);
+ if (!$absolute) {
+ if ($expected === '') {
+ self::assertEquals('/', $result);
+ } else {
+ self::assertEquals($slash ? '/' . $expected : $expected, $result);
+ }
+ } else {
+ if ($expected === '') {
+ self::assertEquals(self::BASE_URL, $result);
+ } else {
+ self::assertEquals(self::BASE_URL . '/' . $expected, $result);
+ }
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function getData()
+ {
+ return array_merge(
+ $this->getTestData(true),
+ $this->getTestData(true, true),
+ $this->getTestData(false),
+ $this->getTestData(false, true),
+ );
+ }
+
+ /**
+ * @param bool $absolute
+ * @param bool $slash
+ * @return array[]
+ */
+ protected function getTestData(bool $absolute, bool $slash = false): array
+ {
+ return [
+ ['user1', ['id' => 1], 'user/1', $absolute, $slash],
+ ['user1', ['id' => 1, 'name' => 'test'], 'user/1/test', $absolute, $slash],
+ ['user6', ['id' => 1], 'user/1', $absolute, $slash],
+ ['user3', ['id' => 1, 'name' => 'test'], 'user/1/test', $absolute, $slash],
+ ['user2', ['id' => 1, 'name' => 'test'], 'user/1/test', $absolute, $slash],
+ ['user2', ['id' => 1,], 'user/1', $absolute, $slash],
+ ['user2', [], 'user', $absolute, $slash],
+ ['user4', ['name' => 'test', 'id' => 1,], 'user/test/1', $absolute, $slash],
+ ['user5', ['id' => 'aaaaaaa', 'name' => 'test'], 'user/test/aaaaaaa', $absolute, $slash],
+ ['user7', ['id' => 1, 'name' => 'test', 'ids' => 2, 'names' => 'tests'], 'user/1/test/2/tests', $absolute, $slash],
+ ['user7', ['id' => 1, 'name' => 'test', 'ids' => 2,], 'user/1/test/2', $absolute, $slash],
+ ['user7', ['id' => 1, 'name' => 'test'], 'user/1/test', $absolute, $slash],
+ ['user7', ['id' => 1], 'user/1', $absolute, $slash],
+ ['user7', [], 'user', $absolute, $slash],
+ ['user8', ['key' => 40, 'id' => 1, 'name' => 'test', 'ids' => 2, 'names' => 'tests'], 'user/1/test/2/tests/40', $absolute, $slash],
+
+ ['test1', ['param' => 'paramvalue'], 'test/paramvalue', $absolute, $slash],
+ ['test4', ['param1' => 'param1value', 'param2' => 'param2value'], 'test/param1value/test2/param2value', $absolute, $slash],
+ ['test3', ['param' => 1], 'test/1', $absolute, $slash],
+ ['test2', ['param' => 'paramvalue'], 'test/paramvalue', $absolute, $slash],
+ ['test2', [], 'test', $absolute, $slash],
+ ['foo-bar', ['foo-bar' => 'foo-barvalue'], 'foo-barvalue', $absolute, $slash],
+ ['_foo', ['_foo' => '_foovalue'], '_foovalue', $absolute, $slash],
+ ['te', ['param' => 'paramvalue'], 'teparamvaluest', $absolute, $slash],
+ ['test5', ['param' => 1], 'test/1', $absolute, $slash],
+
+ ['test6', ['test' => true], 'test', $absolute, $slash],
+ ['test6', ['test' => false], '', $absolute, $slash],
+
+ ['param/opt', ['param' => 'paramvalue', 'opt' => true], 'paramvalueopt', $absolute, $slash],
+ ['param/opt', ['param' => 'paramvalue', 'opt' => false], 'paramvalue', $absolute, $slash],
+ ['param/opt', ['param' => 'paramvalue'], 'paramvalue', $absolute, $slash],
+ ['param/opt', ['param' => 'value', 'opt' => true], 'valueopt', $absolute, $slash],
+ ['param/opt', ['param' => 'value', 'opt' => false], 'value', $absolute, $slash],
+ ['param/opt', ['param' => 'value'], 'value', $absolute, $slash],
+ ['test7', ['opt' => true], 'testopt', $absolute, $slash],
+ ['test7', ['opt' => false], 'test', $absolute, $slash],
+
+ ['static1', null, '', $absolute, $slash],
+ ['static2', null, '', $absolute, $slash],
+
+ ['static3', null, 'test', $absolute, $slash],
+ ['static4', null, 'test/test2', $absolute, $slash],
+ ['static5', null, 'test', $absolute, $slash],
+ ['static6', null, 'test', $absolute, $slash],
+ ];
+ }
+
+ /**
+ * @dataProvider getBadData
+ */
+ public function testBuildException1($routName, $placeholders)
+ {
+ $this->expectException(BadParameterException::class);
+ $this->urlBuilder->build($routName, $placeholders);
+ }
+
+ /**
+ * @dataProvider getBadData
+ */
+ public function testBuildException2($routName, $placeholders)
+ {
+ $this->expectException(BadParameterException::class);
+ $this->urlBuilder->build('/' . $routName, $placeholders);
+ }
+
+ /**
+ * @dataProvider getBadData
+ */
+ public function testBuildException3($routName, $placeholders)
+ {
+ $this->expectException(BadParameterException::class);
+ $this->urlBuilder->build($routName, $placeholders, true);
+ }
+
+ /**
+ * @dataProvider getBadData
+ */
+ public function testBuildException4($routName, $placeholders)
+ {
+ $this->expectException(BadParameterException::class);
+ $this->urlBuilder->build('/' . $routName, $placeholders, true);
+ }
+
+ /**
+ * @return array
+ */
+ public function getBadData()
+ {
+ return [
+ ['user1', []],
+ ['user1', ['name' => 'test']],
+ ['user1', ['id' => 'test']],
+
+ ['user2', ['name' => 'test']],
+ ['user2', ['id' => 'test', 'name' => 'test']],
+ ['user2', ['id' => 'test']],
+
+ ['user3', []],
+ ['user3', ['id' => 'test']],
+ ['user3', ['id' => 1]],
+ ['user3', ['id' => 'test', 'name' => 'test']],
+ ['user3', ['name' => 'test']],
+
+ ['user4', []],
+ ['user4', ['id' => 'test']],
+ ['user4', ['id' => 1]],
+ ['user4', ['id' => 'ids', 'name' => 'test']],
+ ['user4', ['name' => 'test']],
+
+ ['user5', []],
+ ['user5', ['id' => 1]],
+ ['user5', ['id' => 'test']],
+ ['user5', ['id' => 1, 'name' => 'test']],
+ ['user5', ['name' => 'test']],
+
+ ['user6', []],
+ ['user6', ['id' => 'test']],
+
+ ['user7', ['name' => 'test', 'ids' => 1, 'names' => 'tests']],
+ ['user7', ['id' => 1, 'ids' => 1, 'names' => 'tests']],
+ ['user7', ['id' => 1, 'name' => 'test', 'names' => 'tests']],
+ ['user7', ['name' => 'test', 'ids' => 1, 'names' => 'tests']],
+ ['user7', ['ids' => 1, 'names' => 'tests']],
+ ['user7', ['names' => 'tests']],
+ ['user7', ['id' => 'ids', 'name' => 'test', 'ids' => 1, 'names' => 'tests']],
+ ['user7', ['id' => 'ids', 'ids' => 1, 'names' => 'tests']],
+ ['user7', ['id' => 'ids', 'name' => 'test', 'names' => 'tests']],
+ ['user7', ['id' => 'ids', 'ids' => 'bad', 'names' => 'tests']],
+
+ ['user8', ['id' => 1, 'name' => 'test', 'ids' => 'bad', 'names' => 'tests', 'key' => 'bad-key']],
+ ['user8', ['id' => 1, 'name' => 'test', 'ids' => 'bad', 'names' => 'tests']],
+ ['user8', ['id' => 1, 'name' => 'test', 'ids' => 'bad', 'key' => 1]],
+
+ ['test1', []],
+
+ ['test4', []],
+ ['test4', ['param1' => 'test']],
+ ['test4', ['param2' => 'test']],
+
+ ['test5', ['param' => 50000]],
+ ['test7', ['opt' => 50000]],
+
+ ['test7', ['opt' => 50000]],
+
+ ['foo-bar', []],
+
+ ['te', []],
+
+ ['param/opt', []],
+ ['param/opt', ['opt' => true]],
+ ['param/opt', ['opt' => false]],
+
+ ['static1', ['test' => 40]],
+
+ ['static5', ['opt' => 40]],
+
+ ];
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildWithQueryArray()
+ {
+ $result = $this->urlBuilder->build('/user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => ['key' => 'value']
+ ], true);
+ self::assertEquals(self::BASE_URL . '/user/2/grigor?key=value', $result);
+
+ $result = $this->urlBuilder->build('/user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => ['key' => 'value']
+ ]);
+ self::assertEquals('/user/2/grigor?key=value', $result);
+
+ $result = $this->urlBuilder->build('user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => ['key' => 'value']
+ ]);
+ self::assertEquals('user/2/grigor?key=value', $result);
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildWithQueryString()
+ {
+ $result = $this->urlBuilder->build('/user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => 'key=value'
+ ], true);
+ self::assertEquals(self::BASE_URL . '/user/2/grigor?key=value', $result);
+
+ $result = $this->urlBuilder->build('/user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => 'key=value'
+ ]);
+ self::assertEquals('/user/2/grigor?key=value', $result);
+
+ $result = $this->urlBuilder->build('user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => 'key=value'
+ ]);
+ self::assertEquals('user/2/grigor?key=value', $result);
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildWithFragment()
+ {
+ $result = $this->urlBuilder->build('/user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '#' => 'value'
+ ], true);
+ self::assertEquals(self::BASE_URL . '/user/2/grigor#value', $result);
+
+ $result = $this->urlBuilder->build('/user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '#' => 'value'
+ ]);
+ self::assertEquals('/user/2/grigor#value', $result);
+
+ $result = $this->urlBuilder->build('user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '#' => 'value'
+ ]);
+ self::assertEquals('user/2/grigor#value', $result);
+
+ $result = $this->urlBuilder->build('user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ ]);
+ self::assertEquals('user/2/grigor', $result);
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildAllParams()
+ {
+ $result = $this->urlBuilder->build('/user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => 'key=value',
+ '#' => 'value'
+ ], true);
+ self::assertEquals(self::BASE_URL . '/user/2/grigor?key=value#value', $result);
+
+ $result = $this->urlBuilder->build('/user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => 'key=value',
+ '#' => 'value'
+ ]);
+ self::assertEquals('/user/2/grigor?key=value#value', $result);
+
+ $result = $this->urlBuilder->build('user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ '?' => 'key=value',
+ '#' => 'value'
+ ]);
+ self::assertEquals('user/2/grigor?key=value#value', $result);
+
+ $result = $this->urlBuilder->build('user1',
+ [
+ 'id' => 2, 'name' => 'grigor',
+ ]);
+ self::assertEquals('user/2/grigor', $result);
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildWithIdnConverted()
+ {
+ $urlBuilder = new UrlBuilder(
+ 'https://россия.рф',
+ new ArrayRulesProvider([
+ 'user1' => '/user/{id:\d+}[/{name}]',
+ ])
+ );
+ $result = $urlBuilder->build('user1', ['id' => 2, 'name' => 'grigor', ':idn' => true], true);
+ self::assertEquals('https://xn--h1alffa9f.xn--p1ai/user/2/grigor', $result);
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildWithStrategy()
+ {
+ $urlBuilder = new UrlBuilder(
+ 'https://россия.рф',
+ new ArrayRulesProvider([
+ 'user1' => '/user/{id:\d+}[/{name}]',
+ ]),
+ ['referral' => new ReferralUrlStrategy()]
+ );
+
+ $result = $urlBuilder->build('/user1', ['id' => 2, 'name' => 'grigor', ':strategy' => 'referral'], true);
+ self::assertEquals('https://россия.рф/user/2/grigor?refid=222', $result);
+
+ $result = $urlBuilder->build('user1', ['id' => 2, 'name' => 'grigor', ':strategy' => 'referral'], true);
+ self::assertEquals('https://россия.рф/user/2/grigor?refid=222', $result);
+
+ $result = $urlBuilder->build('/user1', ['id' => 2, 'name' => 'grigor', ':strategy' => 'referral']);
+ self::assertEquals('/user/2/grigor?refid=222', $result);
+
+ $result = $urlBuilder->build('user1', ['id' => 2, 'name' => 'grigor', ':strategy' => 'referral']);
+ self::assertEquals('user/2/grigor?refid=222', $result);
+ }
+
+ /**
+ * @return void
+ */
+ public function testBuildWithStrategyException()
+ {
+ $this->expectException(BadParameterException::class);
+ $urlBuilder = new UrlBuilder(
+ 'https://россия.рф',
+ new ArrayRulesProvider([
+ 'user1' => '/user/{id:\d+}[/{name}]',
+ ]),
+ ['referral' => new ReferralUrlStrategy()]
+ );
+
+ $urlBuilder->build('/user1', ['id' => 2, 'name' => 'grigor', ':strategy' => 'non-existent'], true);
+ }
+
+ /**
+ * @return void
+ */
+ public function testConstructEmptyBaseUrl()
+ {
+ $this->expectException(InvalidUrlException::class);
+ new UrlBuilder(
+ '',
+ new ArrayRulesProvider([
+ 'user1' => '[/{name}]',
+ ])
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function testConstructEmptyBaseUrl2()
+ {
+ $this->expectException(InvalidUrlException::class);
+ new UrlBuilder(
+ ' ',
+ new ArrayRulesProvider([
+ 'user1' => '[/{name}]',
+ ])
+ );
+ }
+
+}
\ No newline at end of file