diff --git a/.travis.yml b/.travis.yml index 2fb1fd4..1aeacca 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,26 +13,6 @@ env: matrix: include: - - php: 5.6 - env: - - DEPS=lowest - - php: 5.6 - env: - - DEPS=locked - - LEGACY_DEPS="phpunit/phpunit" - - php: 5.6 - env: - - DEPS=latest - - php: 7 - env: - - DEPS=lowest - - php: 7 - env: - - DEPS=locked - - LEGACY_DEPS="phpunit/phpunit" - - php: 7 - env: - - DEPS=latest - php: 7.1 env: - DEPS=lowest @@ -58,8 +38,7 @@ before_install: - if [[ $TEST_COVERAGE != 'true' ]]; then phpenv config-rm xdebug.ini || return 0 ; fi install: - - travis_retry composer install $COMPOSER_ARGS --ignore-platform-reqs - - if [[ $LEGACY_DEPS != '' ]]; then travis_retry composer update $COMPOSER_ARGS --with-dependencies $LEGACY_DEPS ; fi + - travis_retry composer install $COMPOSER_ARGS - if [[ $DEPS == 'latest' ]]; then travis_retry composer update $COMPOSER_ARGS ; fi - if [[ $DEPS == 'lowest' ]]; then travis_retry composer update --prefer-lowest --prefer-stable $COMPOSER_ARGS ; fi - if [[ $TEST_COVERAGE == 'true' ]]; then travis_retry composer require --dev $COMPOSER_ARGS $COVERAGE_DEPS ; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index f39e155..6735491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to this project will be documented in this file, in reverse chronological order by release. +## 4.0.0 - TBD + +### Added + +- Nothing. + +### Changed + +- Nothing. + +### Deprecated + +- Nothing. + +### Removed + +- [#44](https://github.com/zendframework/zend-router/pull/44) removes support + for PHP versions prior to PHP 7.1. + +### Fixed + +- Nothing. + ## 3.1.0 - TBD ### Added diff --git a/README.md b/README.md index b68a47d..17225bb 100644 --- a/README.md +++ b/README.md @@ -18,5 +18,20 @@ request and responses, and provides capabilities around: Additionally, it supports combinations of different route types in tree structures, allowing for fast, b-tree lookups. -- File issues at https://github.com/zendframework/zend-router/issues -- Documentation is at https://docs.zendframework.com/zend-router +## Installation + +Run the following to install this library: + +```bash +$ composer require zendframework/zend-router +``` + +## Documentation + +Documentation is [in the doc tree](docs/book/), and can be compiled using [mkdocs](http://www.mkdocs.org): + +```bash +$ mkdocs build +``` + +You may also [browse the documentation online](https://docs.zendframework.com/zend-router/). diff --git a/composer.json b/composer.json index 461559c..7d5b4ae 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,6 @@ "zf", "zend", "zendframework", - "mvc", "routing" ], "support": { @@ -18,20 +17,19 @@ "forum": "https://discourse.zendframework.com/c/questions/components" }, "require": { - "php": "^5.6 || ^7.0", + "php": "^7.1", "container-interop/container-interop": "^1.2", - "zendframework/zend-http": "^2.6", - "zendframework/zend-servicemanager": "^2.7.8 || ^3.3", - "zendframework/zend-stdlib": "^2.7.7 || ^3.1" + "psr/container": "^1.0", + "psr/http-message": "^1.0", + "zendframework/zend-diactoros": "^1.7", + "zendframework/zend-servicemanager": "^3.3", + "zendframework/zend-stdlib": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^5.7.22 || ^6.4.1", + "phpunit/phpunit": "^7.0", "zendframework/zend-coding-standard": "~1.0.0", "zendframework/zend-i18n": "^2.7.4" }, - "conflict": { - "zendframework/zend-mvc": "<3.0.0" - }, "suggest": { "zendframework/zend-i18n": "^2.7.4, if defining translatable HTTP path segments" }, diff --git a/composer.lock b/composer.lock index a09c00a..ca5e96c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "8624751621bbfc18a2c1dd41b0441e19", + "content-hash": "803b44bdf8a54be6b1ec76608d7655d0", "packages": [ { "name": "container-interop/container-interop", @@ -87,155 +87,119 @@ "time": "2017-02-14T16:28:37+00:00" }, { - "name": "zendframework/zend-escaper", - "version": "2.5.2", + "name": "psr/http-message", + "version": "1.0.1", "source": { "type": "git", - "url": "https://github.com/zendframework/zend-escaper.git", - "reference": "2dcd14b61a72d8b8e27d579c6344e12c26141d4e" + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-escaper/zipball/2dcd14b61a72d8b8e27d579c6344e12c26141d4e", - "reference": "2dcd14b61a72d8b8e27d579c6344e12c26141d4e", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", "shasum": "" }, "require": { - "php": ">=5.5" - }, - "require-dev": { - "fabpot/php-cs-fixer": "1.7.*", - "phpunit/phpunit": "~4.0" + "php": ">=5.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.5-dev", - "dev-develop": "2.6-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { - "Zend\\Escaper\\": "src/" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" - ], - "homepage": "https://github.com/zendframework/zend-escaper", - "keywords": [ - "escaper", - "zf2" + "MIT" ], - "time": "2016-06-30T19:48:38+00:00" - }, - { - "name": "zendframework/zend-http", - "version": "2.6.0", - "source": { - "type": "git", - "url": "https://github.com/zendframework/zend-http.git", - "reference": "09f4d279f46d86be63171ff62ee0f79eca878678" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-http/zipball/09f4d279f46d86be63171ff62ee0f79eca878678", - "reference": "09f4d279f46d86be63171ff62ee0f79eca878678", - "shasum": "" - }, - "require": { - "php": "^5.5 || ^7.0", - "zendframework/zend-loader": "^2.5", - "zendframework/zend-stdlib": "^2.5 || ^3.0", - "zendframework/zend-uri": "^2.5", - "zendframework/zend-validator": "^2.5" - }, - "require-dev": { - "phpunit/phpunit": "^4.0", - "zendframework/zend-coding-standard": "~1.0.0", - "zendframework/zend-config": "^2.5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.6-dev", - "dev-develop": "2.7-dev" - } - }, - "autoload": { - "psr-4": { - "Zend\\Http\\": "src/" + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" ], - "description": "provides an easy interface for performing Hyper-Text Transfer Protocol (HTTP) requests", - "homepage": "https://github.com/zendframework/zend-http", + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", "keywords": [ "http", - "zf2" + "http-message", + "psr", + "psr-7", + "request", + "response" ], - "time": "2017-01-31T14:41:02+00:00" + "time": "2016-08-06T14:39:51+00:00" }, { - "name": "zendframework/zend-loader", - "version": "2.5.1", + "name": "zendframework/zend-diactoros", + "version": "1.7.0", "source": { "type": "git", - "url": "https://github.com/zendframework/zend-loader.git", - "reference": "c5fd2f071bde071f4363def7dea8dec7393e135c" + "url": "https://github.com/zendframework/zend-diactoros.git", + "reference": "ed6ce7e2105c400ca10277643a8327957c0384b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-loader/zipball/c5fd2f071bde071f4363def7dea8dec7393e135c", - "reference": "c5fd2f071bde071f4363def7dea8dec7393e135c", + "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/ed6ce7e2105c400ca10277643a8327957c0384b7", + "reference": "ed6ce7e2105c400ca10277643a8327957c0384b7", "shasum": "" }, "require": { - "php": ">=5.3.23" + "php": "^5.6 || ^7.0", + "psr/http-message": "^1.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" }, "require-dev": { - "fabpot/php-cs-fixer": "1.7.*", - "phpunit/phpunit": "~4.0" + "ext-dom": "*", + "ext-libxml": "*", + "phpunit/phpunit": "^5.7.16 || ^6.0.8", + "zendframework/zend-coding-standard": "~1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.5-dev", - "dev-develop": "2.6-dev" + "dev-master": "1.7.x-dev", + "dev-develop": "1.8.x-dev" } }, "autoload": { "psr-4": { - "Zend\\Loader\\": "src/" + "Zend\\Diactoros\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "BSD-2-Clause" ], - "homepage": "https://github.com/zendframework/zend-loader", + "description": "PSR HTTP Message implementations", + "homepage": "https://github.com/zendframework/zend-diactoros", "keywords": [ - "loader", - "zf2" + "http", + "psr", + "psr-7" ], - "time": "2015-06-03T14:05:47+00:00" + "time": "2018-01-04T18:21:48+00:00" }, { "name": "zendframework/zend-servicemanager", - "version": "3.3.0", + "version": "3.3.2", "source": { "type": "git", "url": "https://github.com/zendframework/zend-servicemanager.git", - "reference": "c3036efb81f71bfa36cc9962ee5d4474f36581d0" + "reference": "9f35a104b8d4d3b32da5f4a3b6efc0dd62e5af42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-servicemanager/zipball/c3036efb81f71bfa36cc9962ee5d4474f36581d0", - "reference": "c3036efb81f71bfa36cc9962ee5d4474f36581d0", + "url": "https://api.github.com/repos/zendframework/zend-servicemanager/zipball/9f35a104b8d4d3b32da5f4a3b6efc0dd62e5af42", + "reference": "9f35a104b8d4d3b32da5f4a3b6efc0dd62e5af42", "shasum": "" }, "require": { @@ -249,10 +213,10 @@ "psr/container-implementation": "^1.0" }, "require-dev": { - "mikey179/vfsstream": "^1.6", + "mikey179/vfsstream": "^1.6.5", "ocramius/proxy-manager": "^1.0 || ^2.0", - "phpbench/phpbench": "^0.10.0", - "phpunit/phpunit": "^5.7 || ^6.0.6", + "phpbench/phpbench": "^0.13.0", + "phpunit/phpunit": "^5.7.25 || ^6.4.4", "zendframework/zend-coding-standard": "~1.0.0" }, "suggest": { @@ -267,7 +231,7 @@ "extra": { "branch-alias": { "dev-master": "3.3-dev", - "dev-develop": "3.4-dev" + "dev-develop": "4.0-dev" } }, "autoload": { @@ -279,13 +243,18 @@ "license": [ "BSD-3-Clause" ], - "homepage": "https://github.com/zendframework/zend-servicemanager", + "description": "Factory-Driven Dependency Injection Container", "keywords": [ + "PSR-11", + "ZendFramework", + "dependency-injection", + "di", + "dic", "service-manager", "servicemanager", "zf" ], - "time": "2017-03-01T22:08:02+00:00" + "time": "2018-01-29T16:48:37+00:00" }, { "name": "zendframework/zend-stdlib", @@ -331,124 +300,6 @@ "zf2" ], "time": "2016-09-13T14:38:50+00:00" - }, - { - "name": "zendframework/zend-uri", - "version": "2.5.2", - "source": { - "type": "git", - "url": "https://github.com/zendframework/zend-uri.git", - "reference": "0bf717a239432b1a1675ae314f7c4acd742749ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-uri/zipball/0bf717a239432b1a1675ae314f7c4acd742749ed", - "reference": "0bf717a239432b1a1675ae314f7c4acd742749ed", - "shasum": "" - }, - "require": { - "php": "^5.5 || ^7.0", - "zendframework/zend-escaper": "^2.5", - "zendframework/zend-validator": "^2.5" - }, - "require-dev": { - "fabpot/php-cs-fixer": "1.7.*", - "phpunit/phpunit": "~4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.5-dev", - "dev-develop": "2.6-dev" - } - }, - "autoload": { - "psr-4": { - "Zend\\Uri\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "a component that aids in manipulating and validating ยป Uniform Resource Identifiers (URIs)", - "homepage": "https://github.com/zendframework/zend-uri", - "keywords": [ - "uri", - "zf2" - ], - "time": "2016-02-17T22:38:51+00:00" - }, - { - "name": "zendframework/zend-validator", - "version": "2.10.1", - "source": { - "type": "git", - "url": "https://github.com/zendframework/zend-validator.git", - "reference": "010084ddbd33299bf51ea6f0e07f8f4e8bd832a8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-validator/zipball/010084ddbd33299bf51ea6f0e07f8f4e8bd832a8", - "reference": "010084ddbd33299bf51ea6f0e07f8f4e8bd832a8", - "shasum": "" - }, - "require": { - "container-interop/container-interop": "^1.1", - "php": "^5.6 || ^7.0", - "zendframework/zend-stdlib": "^2.7.6 || ^3.1" - }, - "require-dev": { - "phpunit/phpunit": "^6.0.8 || ^5.7.15", - "zendframework/zend-cache": "^2.6.1", - "zendframework/zend-coding-standard": "~1.0.0", - "zendframework/zend-config": "^2.6", - "zendframework/zend-db": "^2.7", - "zendframework/zend-filter": "^2.6", - "zendframework/zend-http": "^2.5.4", - "zendframework/zend-i18n": "^2.6", - "zendframework/zend-math": "^2.6", - "zendframework/zend-servicemanager": "^2.7.5 || ^3.0.3", - "zendframework/zend-session": "^2.8", - "zendframework/zend-uri": "^2.5" - }, - "suggest": { - "zendframework/zend-db": "Zend\\Db component, required by the (No)RecordExists validator", - "zendframework/zend-filter": "Zend\\Filter component, required by the Digits validator", - "zendframework/zend-i18n": "Zend\\I18n component to allow translation of validation error messages", - "zendframework/zend-i18n-resources": "Translations of validator messages", - "zendframework/zend-math": "Zend\\Math component, required by the Csrf validator", - "zendframework/zend-servicemanager": "Zend\\ServiceManager component to allow using the ValidatorPluginManager and validator chains", - "zendframework/zend-session": "Zend\\Session component, ^2.8; required by the Csrf validator", - "zendframework/zend-uri": "Zend\\Uri component, required by the Uri and Sitemap\\Loc validators" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.10-dev", - "dev-develop": "2.11-dev" - }, - "zf": { - "component": "Zend\\Validator", - "config-provider": "Zend\\Validator\\ConfigProvider" - } - }, - "autoload": { - "psr-4": { - "Zend\\Validator\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "provides a set of commonly needed validators", - "homepage": "https://github.com/zendframework/zend-validator", - "keywords": [ - "validator", - "zf2" - ], - "time": "2017-08-22T14:19:23+00:00" } ], "packages-dev": [ @@ -508,37 +359,40 @@ }, { "name": "myclabs/deep-copy", - "version": "1.6.1", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "8e6e04167378abf1ddb4d3522d8755c5fd90d102" + "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8e6e04167378abf1ddb4d3522d8755c5fd90d102", - "reference": "8e6e04167378abf1ddb4d3522d8755c5fd90d102", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", + "reference": "3b8a3a99ba1f6a3952ac2747d989303cbd6b7a3e", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": "^5.6 || ^7.0" }, "require-dev": { - "doctrine/collections": "1.*", - "phpunit/phpunit": "~4.1" + "doctrine/collections": "^1.0", + "doctrine/common": "^2.6", + "phpunit/phpunit": "^4.1" }, "type": "library", "autoload": { "psr-4": { "DeepCopy\\": "src/DeepCopy/" - } + }, + "files": [ + "src/DeepCopy/deep_copy.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "Create deep copies (clones) of your objects", - "homepage": "https://github.com/myclabs/DeepCopy", "keywords": [ "clone", "copy", @@ -546,7 +400,7 @@ "object", "object graph" ], - "time": "2017-04-12T18:52:22+00:00" + "time": "2017-10-19T19:58:43+00:00" }, { "name": "phar-io/manifest", @@ -706,29 +560,35 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "4.1.1", + "version": "4.3.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "2d3d238c433cf69caeb4842e97a3223a116f94b2" + "reference": "94fd0001232e47129dd3504189fa1c7225010d08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/2d3d238c433cf69caeb4842e97a3223a116f94b2", - "reference": "2d3d238c433cf69caeb4842e97a3223a116f94b2", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94fd0001232e47129dd3504189fa1c7225010d08", + "reference": "94fd0001232e47129dd3504189fa1c7225010d08", "shasum": "" }, "require": { "php": "^7.0", - "phpdocumentor/reflection-common": "^1.0@dev", + "phpdocumentor/reflection-common": "^1.0.0", "phpdocumentor/type-resolver": "^0.4.0", "webmozart/assert": "^1.0" }, "require-dev": { - "mockery/mockery": "^0.9.4", - "phpunit/phpunit": "^4.4" + "doctrine/instantiator": "~1.0.5", + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^6.4" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, "autoload": { "psr-4": { "phpDocumentor\\Reflection\\": [ @@ -747,7 +607,7 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2017-08-30T18:51:59+00:00" + "time": "2017-11-30T07:14:17+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -798,16 +658,16 @@ }, { "name": "phpspec/prophecy", - "version": "v1.7.2", + "version": "1.7.3", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6" + "reference": "e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6", - "reference": "c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf", + "reference": "e4ed002c67da8eceb0eb8ddb8b3847bb53c5c2bf", "shasum": "" }, "require": { @@ -819,7 +679,7 @@ }, "require-dev": { "phpspec/phpspec": "^2.5|^3.2", - "phpunit/phpunit": "^4.8 || ^5.6.5" + "phpunit/phpunit": "^4.8.35 || ^5.7" }, "type": "library", "extra": { @@ -857,45 +717,44 @@ "spy", "stub" ], - "time": "2017-09-04T11:05:03+00:00" + "time": "2017-11-24T13:59:53+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "5.2.2", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "8ed1902a57849e117b5651fc1a5c48110946c06b" + "reference": "f8ca4b604baf23dab89d87773c28cc07405189ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/8ed1902a57849e117b5651fc1a5c48110946c06b", - "reference": "8ed1902a57849e117b5651fc1a5c48110946c06b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f8ca4b604baf23dab89d87773c28cc07405189ba", + "reference": "f8ca4b604baf23dab89d87773c28cc07405189ba", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.0", + "php": "^7.1", "phpunit/php-file-iterator": "^1.4.2", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^1.4.11 || ^2.0", + "phpunit/php-token-stream": "^3.0", "sebastian/code-unit-reverse-lookup": "^1.0.1", "sebastian/environment": "^3.0", "sebastian/version": "^2.0.1", "theseer/tokenizer": "^1.1" }, "require-dev": { - "ext-xdebug": "^2.5", - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^7.0" }, "suggest": { - "ext-xdebug": "^2.5.5" + "ext-xdebug": "^2.6.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.2.x-dev" + "dev-master": "6.0-dev" } }, "autoload": { @@ -910,7 +769,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -921,20 +780,20 @@ "testing", "xunit" ], - "time": "2017-08-03T12:40:43+00:00" + "time": "2018-02-02T07:01:41+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "1.4.2", + "version": "1.4.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3cc8f69b3028d0f96a9078e6295d86e9bf019be5" + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3cc8f69b3028d0f96a9078e6295d86e9bf019be5", - "reference": "3cc8f69b3028d0f96a9078e6295d86e9bf019be5", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", "shasum": "" }, "require": { @@ -968,7 +827,7 @@ "filesystem", "iterator" ], - "time": "2016-10-03T07:40:28+00:00" + "time": "2017-11-27T13:52:08+00:00" }, { "name": "phpunit/php-text-template", @@ -1013,28 +872,28 @@ }, { "name": "phpunit/php-timer", - "version": "1.0.9", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" + "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b8454ea6958c3dee38453d3bd571e023108c91f", + "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1049,7 +908,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -1058,33 +917,33 @@ "keywords": [ "timer" ], - "time": "2017-02-26T11:10:40+00:00" + "time": "2018-02-01T13:07:23+00:00" }, { "name": "phpunit/php-token-stream", - "version": "2.0.1", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "9a02332089ac48e704c70f6cefed30c224e3c0b0" + "reference": "21ad88bbba7c3d93530d93994e0a33cd45f02ace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/9a02332089ac48e704c70f6cefed30c224e3c0b0", - "reference": "9a02332089ac48e704c70f6cefed30c224e3c0b0", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/21ad88bbba7c3d93530d93994e0a33cd45f02ace", + "reference": "21ad88bbba7c3d93530d93994e0a33cd45f02ace", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2.4" + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1107,20 +966,20 @@ "keywords": [ "tokenizer" ], - "time": "2017-08-20T05:47:52+00:00" + "time": "2018-02-01T13:16:43+00:00" }, { "name": "phpunit/phpunit", - "version": "6.4.1", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b770d8ba7e60295ee91d69d5a5e01ae833cac220" + "reference": "9b3373439fdf2f3e9d1578f5e408a3a0d161c3bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b770d8ba7e60295ee91d69d5a5e01ae833cac220", - "reference": "b770d8ba7e60295ee91d69d5a5e01ae833cac220", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9b3373439fdf2f3e9d1578f5e408a3a0d161c3bc", + "reference": "9b3373439fdf2f3e9d1578f5e408a3a0d161c3bc", "shasum": "" }, "require": { @@ -1132,15 +991,15 @@ "myclabs/deep-copy": "^1.6.1", "phar-io/manifest": "^1.0.1", "phar-io/version": "^1.0", - "php": "^7.0", + "php": "^7.1", "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^5.2.2", - "phpunit/php-file-iterator": "^1.4.2", + "phpunit/php-code-coverage": "^6.0", + "phpunit/php-file-iterator": "^1.4.3", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^1.0.9", - "phpunit/phpunit-mock-objects": "^4.0.3", - "sebastian/comparator": "^2.0.2", - "sebastian/diff": "^2.0", + "phpunit/php-timer": "^2.0", + "phpunit/phpunit-mock-objects": "^6.0", + "sebastian/comparator": "^2.1", + "sebastian/diff": "^3.0", "sebastian/environment": "^3.1", "sebastian/exporter": "^3.1", "sebastian/global-state": "^2.0", @@ -1148,16 +1007,12 @@ "sebastian/resource-operations": "^1.0", "sebastian/version": "^2.0.1" }, - "conflict": { - "phpdocumentor/reflection-docblock": "3.0.2", - "phpunit/dbunit": "<3.0" - }, "require-dev": { "ext-pdo": "*" }, "suggest": { "ext-xdebug": "*", - "phpunit/php-invoker": "^1.1" + "phpunit/php-invoker": "^2.0" }, "bin": [ "phpunit" @@ -1165,7 +1020,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "6.4.x-dev" + "dev-master": "7.0-dev" } }, "autoload": { @@ -1191,33 +1046,30 @@ "testing", "xunit" ], - "time": "2017-10-07T17:53:53+00:00" + "time": "2018-02-02T05:04:08+00:00" }, { "name": "phpunit/phpunit-mock-objects", - "version": "4.0.4", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "2f789b59ab89669015ad984afa350c4ec577ade0" + "reference": "e495e5d3660321b62c294d8c0e954d02d6ce2573" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/2f789b59ab89669015ad984afa350c4ec577ade0", - "reference": "2f789b59ab89669015ad984afa350c4ec577ade0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/e495e5d3660321b62c294d8c0e954d02d6ce2573", + "reference": "e495e5d3660321b62c294d8c0e954d02d6ce2573", "shasum": "" }, "require": { "doctrine/instantiator": "^1.0.5", - "php": "^7.0", + "php": "^7.1", "phpunit/php-text-template": "^1.2.1", - "sebastian/exporter": "^3.0" - }, - "conflict": { - "phpunit/phpunit": "<6.0" + "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^7.0" }, "suggest": { "ext-soap": "*" @@ -1225,7 +1077,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0.x-dev" + "dev-master": "6.0.x-dev" } }, "autoload": { @@ -1240,7 +1092,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -1250,7 +1102,7 @@ "mock", "xunit" ], - "time": "2017-08-03T14:08:16+00:00" + "time": "2018-02-01T13:11:13+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -1299,30 +1151,30 @@ }, { "name": "sebastian/comparator", - "version": "2.0.2", + "version": "2.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "ae068fede81d06e7bb9bb46a367210a3d3e1fe6a" + "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ae068fede81d06e7bb9bb46a367210a3d3e1fe6a", - "reference": "ae068fede81d06e7bb9bb46a367210a3d3e1fe6a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/34369daee48eafb2651bea869b4b15d75ccc35f9", + "reference": "34369daee48eafb2651bea869b4b15d75ccc35f9", "shasum": "" }, "require": { "php": "^7.0", - "sebastian/diff": "^2.0", - "sebastian/exporter": "^3.0" + "sebastian/diff": "^2.0 || ^3.0", + "sebastian/exporter": "^3.1" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^6.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "2.1.x-dev" } }, "autoload": { @@ -1353,38 +1205,39 @@ } ], "description": "Provides the functionality to compare PHP values for equality", - "homepage": "http://www.github.com/sebastianbergmann/comparator", + "homepage": "https://github.com/sebastianbergmann/comparator", "keywords": [ "comparator", "compare", "equality" ], - "time": "2017-08-03T07:14:59+00:00" + "time": "2018-02-01T13:46:46+00:00" }, { "name": "sebastian/diff", - "version": "2.0.1", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd" + "reference": "e09160918c66281713f1c324c1f4c4c3037ba1e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", - "reference": "347c1d8b49c5c3ee30c7040ea6fc446790e6bddd", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/e09160918c66281713f1c324c1f4c4c3037ba1e8", + "reference": "e09160918c66281713f1c324c1f4c4c3037ba1e8", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.1" }, "require-dev": { - "phpunit/phpunit": "^6.2" + "phpunit/phpunit": "^7.0", + "symfony/process": "^2 || ^3.3 || ^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1409,9 +1262,12 @@ "description": "Diff implementation", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff" + "diff", + "udiff", + "unidiff", + "unified diff" ], - "time": "2017-08-03T08:09:46+00:00" + "time": "2018-02-01T13:45:15+00:00" }, { "name": "sebastian/environment", @@ -1931,16 +1787,16 @@ }, { "name": "webmozart/assert", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/webmozart/assert.git", - "reference": "2db61e59ff05fe5126d152bd0655c9ea113e550f" + "reference": "0df1908962e7a3071564e857d86874dad1ef204a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/2db61e59ff05fe5126d152bd0655c9ea113e550f", - "reference": "2db61e59ff05fe5126d152bd0655c9ea113e550f", + "url": "https://api.github.com/repos/webmozart/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a", + "reference": "0df1908962e7a3071564e857d86874dad1ef204a", "shasum": "" }, "require": { @@ -1977,7 +1833,7 @@ "check", "validate" ], - "time": "2016-11-23T20:04:58+00:00" + "time": "2018-01-29T19:49:41+00:00" }, { "name": "zendframework/zend-coding-standard", @@ -2082,7 +1938,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^5.6 || ^7.0" + "php": "^7.1" }, "platform-dev": [] } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 23f9287..e10e451 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,6 +2,7 @@ diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index a9e4596..3e02776 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -1,12 +1,17 @@ $this->getDependencyConfig(), - 'route_manager' => $this->getRouteManagerConfig(), + RoutePluginManager::class => $this->getRouteManagerConfig(), ]; } @@ -39,15 +44,11 @@ public function getDependencyConfig() { return [ 'aliases' => [ - 'HttpRouter' => Http\TreeRouteStack::class, - 'router' => RouteStackInterface::class, - 'Router' => RouteStackInterface::class, 'RoutePluginManager' => RoutePluginManager::class, ], 'factories' => [ - Http\TreeRouteStack::class => Http\HttpRouterFactory::class, + RouteConfigFactory::class => RouteConfigFactoryFactory::class, RoutePluginManager::class => RoutePluginManagerFactory::class, - RouteStackInterface::class => RouterFactory::class, ], ]; } diff --git a/src/Container/RouteConfigFactoryFactory.php b/src/Container/RouteConfigFactoryFactory.php new file mode 100644 index 0000000..f9f5f1c --- /dev/null +++ b/src/Container/RouteConfigFactoryFactory.php @@ -0,0 +1,22 @@ +get(RoutePluginManager::class)); + } +} diff --git a/src/Container/RoutePluginManagerFactory.php b/src/Container/RoutePluginManagerFactory.php new file mode 100644 index 0000000..429b1d3 --- /dev/null +++ b/src/Container/RoutePluginManagerFactory.php @@ -0,0 +1,36 @@ +getRoutesConfig($container); + return new RoutePluginManager($container, $options); + } + + public function getRoutesConfig(ContainerInterface $container) : array + { + if (! $container->has('config')) { + return []; + } + return $container->get('config')[RoutePluginManager::class] ?? []; + } +} diff --git a/src/Container/RouterFactory.php b/src/Container/RouterFactory.php new file mode 100644 index 0000000..0e26a1c --- /dev/null +++ b/src/Container/RouterFactory.php @@ -0,0 +1,63 @@ +get(RouteConfigFactory::class), + $this->getRouteStack($container), + $container->get(UriInterface::class) + ); + $this->configureRouter($container, $router); + + return $router; + } + + public function getRouteStack(ContainerInterface $container) : RouteStackInterface + { + return new TreeRouteStack(); + } + + public function configureRouter(ContainerInterface $container, Router $router) : void + { + $config = $this->getRouterConfig($container); + + foreach ($config['prototypes'] as $name => $prototype) { + $router->addPrototype($name, $prototype); + } + + foreach ($config['routes'] as $name => $route) { + $router->addRoute($name, $route); + } + } + + public function getRouterConfig(ContainerInterface $container) : array + { + return [ + 'routes' => [], + 'prototypes' => [], + ]; + } +} diff --git a/src/Exception/DomainException.php b/src/Exception/DomainException.php new file mode 100644 index 0000000..d7bd369 --- /dev/null +++ b/src/Exception/DomainException.php @@ -0,0 +1,14 @@ +chainRoutes = array_reverse($routes); - $this->routePluginManager = $routePlugins; - $this->routes = new PriorityList(); - $this->prototypes = $prototypes; - } - - /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param mixed $options - * @throws Exception\InvalidArgumentException - * @return Part - */ - public static function factory($options = []) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); - } - - if (! isset($options['routes'])) { - throw new Exception\InvalidArgumentException('Missing "routes" in options array'); - } - - if (! isset($options['prototypes'])) { - $options['prototypes'] = null; - } - - if ($options['routes'] instanceof Traversable) { - $options['routes'] = ArrayUtils::iteratorToArray($options['child_routes']); - } - - if (! isset($options['route_plugins'])) { - throw new Exception\InvalidArgumentException('Missing "route_plugins" in options array'); - } - - return new static( - $options['routes'], - $options['route_plugins'], - $options['prototypes'] - ); - } - - /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @param int|null $pathOffset - * @param array $options - * @return RouteMatch|null - */ - public function match(Request $request, $pathOffset = null, array $options = []) - { - if (! method_exists($request, 'getUri')) { - return; - } - - if ($pathOffset === null) { - $mustTerminate = true; - $pathOffset = 0; - } else { - $mustTerminate = false; - } - - if ($this->chainRoutes !== null) { - $this->addRoutes($this->chainRoutes); - $this->chainRoutes = null; - } - - $match = new RouteMatch([]); - $uri = $request->getUri(); - $pathLength = strlen($uri->getPath()); - - foreach ($this->routes as $route) { - $subMatch = $route->match($request, $pathOffset, $options); - - if ($subMatch === null) { - return; - } - - $match->merge($subMatch); - $pathOffset += $subMatch->getLength(); - } - - if ($mustTerminate && $pathOffset !== $pathLength) { - return; - } - - return $match; - } - - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = [], array $options = []) - { - if ($this->chainRoutes !== null) { - $this->addRoutes($this->chainRoutes); - $this->chainRoutes = null; - } - - $this->assembledParams = []; - - $routes = ArrayUtils::iteratorToArray($this->routes); - - end($routes); - $lastRouteKey = key($routes); - $path = ''; - - foreach ($routes as $key => $route) { - $chainOptions = $options; - $hasChild = isset($options['has_child']) ? $options['has_child'] : false; - - $chainOptions['has_child'] = ($hasChild || $key !== $lastRouteKey); - - $path .= $route->assemble($params, $chainOptions); - $params = array_diff_key($params, array_flip($route->getAssembledParams())); - - $this->assembledParams += $route->getAssembledParams(); - } - - return $path; - } - - /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see RouteInterface::getAssembledParams - * @return array - */ - public function getAssembledParams() - { - return $this->assembledParams; - } -} diff --git a/src/Http/HttpRouterFactory.php b/src/Http/HttpRouterFactory.php deleted file mode 100644 index ffcd658..0000000 --- a/src/Http/HttpRouterFactory.php +++ /dev/null @@ -1,55 +0,0 @@ -has('config') ? $container->get('config') : []; - - // Defaults - $class = TreeRouteStack::class; - $config = isset($config['router']) ? $config['router'] : []; - - return $this->createRouter($class, $config, $container); - } - - /** - * Create and return RouteStackInterface instance - * - * For use with zend-servicemanager v2; proxies to __invoke(). - * - * @param ServiceLocatorInterface $container - * @return RouteStackInterface - */ - public function createService(ServiceLocatorInterface $container) - { - return $this($container, RouteStackInterface::class); - } -} diff --git a/src/Http/Literal.php b/src/Http/Literal.php deleted file mode 100644 index 28a8c20..0000000 --- a/src/Http/Literal.php +++ /dev/null @@ -1,133 +0,0 @@ -route = $route; - $this->defaults = $defaults; - } - - /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param array|Traversable $options - * @return Literal - * @throws Exception\InvalidArgumentException - */ - public static function factory($options = []) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); - } - - if (! isset($options['route'])) { - throw new Exception\InvalidArgumentException('Missing "route" in options array'); - } - - if (! isset($options['defaults'])) { - $options['defaults'] = []; - } - - return new static($options['route'], $options['defaults']); - } - - /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @param integer|null $pathOffset - * @return RouteMatch|null - */ - public function match(Request $request, $pathOffset = null) - { - if (! method_exists($request, 'getUri')) { - return; - } - - $uri = $request->getUri(); - $path = $uri->getPath(); - - if ($pathOffset !== null) { - if ($pathOffset >= 0 && strlen($path) >= $pathOffset && ! empty($this->route)) { - if (strpos($path, $this->route, $pathOffset) === $pathOffset) { - return new RouteMatch($this->defaults, strlen($this->route)); - } - } - - return; - } - - if ($path === $this->route) { - return new RouteMatch($this->defaults, strlen($this->route)); - } - - return; - } - - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = [], array $options = []) - { - return $this->route; - } - - /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see RouteInterface::getAssembledParams - * @return array - */ - public function getAssembledParams() - { - return []; - } -} diff --git a/src/Http/Method.php b/src/Http/Method.php deleted file mode 100644 index 69c83a5..0000000 --- a/src/Http/Method.php +++ /dev/null @@ -1,124 +0,0 @@ -verb = $verb; - $this->defaults = $defaults; - } - - /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param array|Traversable $options - * @return Method - * @throws Exception\InvalidArgumentException - */ - public static function factory($options = []) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); - } - - if (! isset($options['verb'])) { - throw new Exception\InvalidArgumentException('Missing "verb" in options array'); - } - - if (! isset($options['defaults'])) { - $options['defaults'] = []; - } - - return new static($options['verb'], $options['defaults']); - } - - /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @return RouteMatch|null - */ - public function match(Request $request) - { - if (! method_exists($request, 'getMethod')) { - return; - } - - $requestVerb = strtoupper($request->getMethod()); - $matchVerbs = explode(',', strtoupper($this->verb)); - $matchVerbs = array_map('trim', $matchVerbs); - - if (in_array($requestVerb, $matchVerbs)) { - return new RouteMatch($this->defaults); - } - - return; - } - - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = [], array $options = []) - { - // The request method does not contribute to the path, thus nothing is returned. - return ''; - } - - /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see RouteInterface::getAssembledParams - * @return array - */ - public function getAssembledParams() - { - return []; - } -} diff --git a/src/Http/Part.php b/src/Http/Part.php deleted file mode 100644 index 2ea728d..0000000 --- a/src/Http/Part.php +++ /dev/null @@ -1,233 +0,0 @@ -routePluginManager = $routePlugins; - - if (! $route instanceof RouteInterface) { - $route = $this->routeFromArray($route); - } - - if ($route instanceof self) { - throw new Exception\InvalidArgumentException('Base route may not be a part route'); - } - - $this->route = $route; - $this->mayTerminate = $mayTerminate; - $this->childRoutes = $childRoutes; - $this->prototypes = $prototypes; - $this->routes = new PriorityList(); - } - - /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param mixed $options - * @return Part - * @throws Exception\InvalidArgumentException - */ - public static function factory($options = []) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); - } - - if (! isset($options['route'])) { - throw new Exception\InvalidArgumentException('Missing "route" in options array'); - } - - if (! isset($options['route_plugins'])) { - throw new Exception\InvalidArgumentException('Missing "route_plugins" in options array'); - } - - if (! isset($options['prototypes'])) { - $options['prototypes'] = null; - } - - if (! isset($options['may_terminate'])) { - $options['may_terminate'] = false; - } - - if (! isset($options['child_routes']) || ! $options['child_routes']) { - $options['child_routes'] = null; - } - - if ($options['child_routes'] instanceof Traversable) { - $options['child_routes'] = ArrayUtils::iteratorToArray($options['child_routes']); - } - - return new static( - $options['route'], - $options['may_terminate'], - $options['route_plugins'], - $options['child_routes'], - $options['prototypes'] - ); - } - - /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @param integer|null $pathOffset - * @param array $options - * @return RouteMatch|null - */ - public function match(Request $request, $pathOffset = null, array $options = []) - { - if ($pathOffset === null) { - $pathOffset = 0; - } - - $match = $this->route->match($request, $pathOffset, $options); - - if ($match !== null && method_exists($request, 'getUri')) { - if ($this->childRoutes !== null) { - $this->addRoutes($this->childRoutes); - $this->childRoutes = null; - } - - $nextOffset = $pathOffset + $match->getLength(); - - $uri = $request->getUri(); - $pathLength = strlen($uri->getPath()); - - if ($this->mayTerminate && $nextOffset === $pathLength) { - return $match; - } - - if (isset($options['translator']) - && ! isset($options['locale']) - && null !== ($locale = $match->getParam('locale', null)) - ) { - $options['locale'] = $locale; - } - - foreach ($this->routes as $name => $route) { - if (($subMatch = $route->match($request, $nextOffset, $options)) instanceof RouteMatch) { - if ($match->getLength() + $subMatch->getLength() + $pathOffset === $pathLength) { - return $match->merge($subMatch)->setMatchedRouteName($name); - } - } - } - } - - return; - } - - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - * @throws Exception\RuntimeException - */ - public function assemble(array $params = [], array $options = []) - { - if ($this->childRoutes !== null) { - $this->addRoutes($this->childRoutes); - $this->childRoutes = null; - } - - $options['has_child'] = (isset($options['name'])); - - if (isset($options['translator']) && ! isset($options['locale']) && isset($params['locale'])) { - $options['locale'] = $params['locale']; - } - - $path = $this->route->assemble($params, $options); - $params = array_diff_key($params, array_flip($this->route->getAssembledParams())); - - if (! isset($options['name'])) { - if (! $this->mayTerminate) { - throw new Exception\RuntimeException('Part route may not terminate'); - } else { - return $path; - } - } - - unset($options['has_child']); - $options['only_return_path'] = true; - $path .= parent::assemble($params, $options); - - return $path; - } - - /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see RouteInterface::getAssembledParams - * @return array - */ - public function getAssembledParams() - { - // Part routes may not occur as base route of other part routes, so we - // don't have to return anything here. - return []; - } -} diff --git a/src/Http/Regex.php b/src/Http/Regex.php deleted file mode 100644 index 7b2ee46..0000000 --- a/src/Http/Regex.php +++ /dev/null @@ -1,174 +0,0 @@ -regex = $regex; - $this->spec = $spec; - $this->defaults = $defaults; - } - - /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param array|Traversable $options - * @return Regex - * @throws \Zend\Router\Exception\InvalidArgumentException - */ - public static function factory($options = []) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); - } - - if (! isset($options['regex'])) { - throw new Exception\InvalidArgumentException('Missing "regex" in options array'); - } - - if (! isset($options['spec'])) { - throw new Exception\InvalidArgumentException('Missing "spec" in options array'); - } - - if (! isset($options['defaults'])) { - $options['defaults'] = []; - } - - return new static($options['regex'], $options['spec'], $options['defaults']); - } - - /** - * match(): defined by RouteInterface interface. - * - * @param Request $request - * @param int $pathOffset - * @return RouteMatch|null - */ - public function match(Request $request, $pathOffset = null) - { - if (! method_exists($request, 'getUri')) { - return; - } - - $uri = $request->getUri(); - $path = $uri->getPath(); - - if ($pathOffset !== null) { - $result = preg_match('(\G' . $this->regex . ')', $path, $matches, null, $pathOffset); - } else { - $result = preg_match('(^' . $this->regex . '$)', $path, $matches); - } - - if (! $result) { - return; - } - - $matchedLength = strlen($matches[0]); - - foreach ($matches as $key => $value) { - if (is_numeric($key) || is_int($key) || $value === '') { - unset($matches[$key]); - } else { - $matches[$key] = rawurldecode($value); - } - } - - return new RouteMatch(array_merge($this->defaults, $matches), $matchedLength); - } - - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = [], array $options = []) - { - $url = $this->spec; - $mergedParams = array_merge($this->defaults, $params); - $this->assembledParams = []; - - foreach ($mergedParams as $key => $value) { - $spec = '%' . $key . '%'; - - if (strpos($url, $spec) !== false) { - $url = str_replace($spec, rawurlencode($value), $url); - - $this->assembledParams[] = $key; - } - } - - return $url; - } - - /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see RouteInterface::getAssembledParams - * @return array - */ - public function getAssembledParams() - { - return $this->assembledParams; - } -} diff --git a/src/Http/RouteInterface.php b/src/Http/RouteInterface.php deleted file mode 100644 index fb7fdf7..0000000 --- a/src/Http/RouteInterface.php +++ /dev/null @@ -1,23 +0,0 @@ -length = $length; - } - - /** - * setMatchedRouteName(): defined by BaseRouteMatch. - * - * @see BaseRouteMatch::setMatchedRouteName() - * @param string $name - * @return RouteMatch - */ - public function setMatchedRouteName($name) - { - if ($this->matchedRouteName === null) { - $this->matchedRouteName = $name; - } else { - $this->matchedRouteName = $name . '/' . $this->matchedRouteName; - } - - return $this; - } - - /** - * Merge parameters from another match. - * - * @param RouteMatch $match - * @return RouteMatch - */ - public function merge(RouteMatch $match) - { - $this->params = array_merge($this->params, $match->getParams()); - $this->length += $match->getLength(); - - $this->matchedRouteName = $match->getMatchedRouteName(); - - return $this; - } - - /** - * Get the matched path length. - * - * @return int - */ - public function getLength() - { - return $this->length; - } -} diff --git a/src/Http/Scheme.php b/src/Http/Scheme.php deleted file mode 100644 index 1007343..0000000 --- a/src/Http/Scheme.php +++ /dev/null @@ -1,127 +0,0 @@ -scheme = $scheme; - $this->defaults = $defaults; - } - - /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param array|Traversable $options - * @return Scheme - * @throws Exception\InvalidArgumentException - */ - public static function factory($options = []) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); - } - - if (! isset($options['scheme'])) { - throw new Exception\InvalidArgumentException('Missing "scheme" in options array'); - } - - if (! isset($options['defaults'])) { - $options['defaults'] = []; - } - - return new static($options['scheme'], $options['defaults']); - } - - /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @return RouteMatch|null - */ - public function match(Request $request) - { - if (! method_exists($request, 'getUri')) { - return; - } - - $uri = $request->getUri(); - $scheme = $uri->getScheme(); - - if ($scheme !== $this->scheme) { - return; - } - - return new RouteMatch($this->defaults); - } - - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = [], array $options = []) - { - if (isset($options['uri'])) { - $options['uri']->setScheme($this->scheme); - } - - // A scheme does not contribute to the path, thus nothing is returned. - return ''; - } - - /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see RouteInterface::getAssembledParams - * @return array - */ - public function getAssembledParams() - { - return []; - } -} diff --git a/src/Http/TranslatorAwareTreeRouteStack.php b/src/Http/TranslatorAwareTreeRouteStack.php deleted file mode 100644 index 7249c62..0000000 --- a/src/Http/TranslatorAwareTreeRouteStack.php +++ /dev/null @@ -1,175 +0,0 @@ -hasTranslator() && $this->isTranslatorEnabled() && ! isset($options['translator'])) { - $options['translator'] = $this->getTranslator(); - } - - if (! isset($options['text_domain'])) { - $options['text_domain'] = $this->getTranslatorTextDomain(); - } - - return parent::match($request, $pathOffset, $options); - } - - /** - * assemble(): defined by \Zend\Router\RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - * @throws Exception\InvalidArgumentException - * @throws Exception\RuntimeException - */ - public function assemble(array $params = [], array $options = []) - { - if ($this->hasTranslator() && $this->isTranslatorEnabled() && ! isset($options['translator'])) { - $options['translator'] = $this->getTranslator(); - } - - if (! isset($options['text_domain'])) { - $options['text_domain'] = $this->getTranslatorTextDomain(); - } - - return parent::assemble($params, $options); - } - - /** - * setTranslator(): defined by TranslatorAwareInterface. - * - * @see TranslatorAwareInterface::setTranslator() - * @param Translator $translator - * @param string $textDomain - * @return TreeRouteStack - */ - public function setTranslator(Translator $translator = null, $textDomain = null) - { - $this->translator = $translator; - - if ($textDomain !== null) { - $this->setTranslatorTextDomain($textDomain); - } - - return $this; - } - - /** - * getTranslator(): defined by TranslatorAwareInterface. - * - * @see TranslatorAwareInterface::getTranslator() - * @return Translator - */ - public function getTranslator() - { - return $this->translator; - } - - /** - * hasTranslator(): defined by TranslatorAwareInterface. - * - * @see TranslatorAwareInterface::hasTranslator() - * @return bool - */ - public function hasTranslator() - { - return $this->translator !== null; - } - - /** - * setTranslatorEnabled(): defined by TranslatorAwareInterface. - * - * @see TranslatorAwareInterface::setTranslatorEnabled() - * @param bool $enabled - * @return TreeRouteStack - */ - public function setTranslatorEnabled($enabled = true) - { - $this->translatorEnabled = $enabled; - return $this; - } - - /** - * isTranslatorEnabled(): defined by TranslatorAwareInterface. - * - * @see TranslatorAwareInterface::isTranslatorEnabled() - * @return bool - */ - public function isTranslatorEnabled() - { - return $this->translatorEnabled; - } - - /** - * setTranslatorTextDomain(): defined by TranslatorAwareInterface. - * - * @see TranslatorAwareInterface::setTranslatorTextDomain() - * @param string $textDomain - * @return self - */ - public function setTranslatorTextDomain($textDomain = 'default') - { - $this->translatorTextDomain = $textDomain; - - return $this; - } - - /** - * getTranslatorTextDomain(): defined by TranslatorAwareInterface. - * - * @see TranslatorAwareInterface::getTranslatorTextDomain() - * @return string - */ - public function getTranslatorTextDomain() - { - return $this->translatorTextDomain; - } -} diff --git a/src/Http/TreeRouteStack.php b/src/Http/TreeRouteStack.php deleted file mode 100644 index 3f99e48..0000000 --- a/src/Http/TreeRouteStack.php +++ /dev/null @@ -1,481 +0,0 @@ -addPrototypes($options['prototypes']); - } - - return $instance; - } - - /** - * init(): defined by SimpleRouteStack. - * - * @see SimpleRouteStack::init() - */ - protected function init() - { - $this->prototypes = new ArrayObject; - - (new Config([ - 'aliases' => [ - 'chain' => Chain::class, - 'Chain' => Chain::class, - 'hostname' => Hostname::class, - 'Hostname' => Hostname::class, - 'hostName' => Hostname::class, - 'HostName' => Hostname::class, - 'literal' => Literal::class, - 'Literal' => Literal::class, - 'method' => Method::class, - 'Method' => Method::class, - 'part' => Part::class, - 'Part' => Part::class, - 'regex' => Regex::class, - 'Regex' => Regex::class, - 'scheme' => Scheme::class, - 'Scheme' => Scheme::class, - 'segment' => Segment::class, - 'Segment' => Segment::class, - 'wildcard' => Wildcard::class, - 'Wildcard' => Wildcard::class, - 'wildCard' => Wildcard::class, - 'WildCard' => Wildcard::class, - ], - 'factories' => [ - Chain::class => RouteInvokableFactory::class, - Hostname::class => RouteInvokableFactory::class, - Literal::class => RouteInvokableFactory::class, - Method::class => RouteInvokableFactory::class, - Part::class => RouteInvokableFactory::class, - Regex::class => RouteInvokableFactory::class, - Scheme::class => RouteInvokableFactory::class, - Segment::class => RouteInvokableFactory::class, - Wildcard::class => RouteInvokableFactory::class, - - // v2 normalized names - - 'zendmvcrouterhttpchain' => RouteInvokableFactory::class, - 'zendmvcrouterhttphostname' => RouteInvokableFactory::class, - 'zendmvcrouterhttpliteral' => RouteInvokableFactory::class, - 'zendmvcrouterhttpmethod' => RouteInvokableFactory::class, - 'zendmvcrouterhttppart' => RouteInvokableFactory::class, - 'zendmvcrouterhttpregex' => RouteInvokableFactory::class, - 'zendmvcrouterhttpscheme' => RouteInvokableFactory::class, - 'zendmvcrouterhttpsegment' => RouteInvokableFactory::class, - 'zendmvcrouterhttpwildcard' => RouteInvokableFactory::class, - ], - ]))->configureServiceManager($this->routePluginManager); - } - - /** - * addRoute(): defined by RouteStackInterface interface. - * - * @see RouteStackInterface::addRoute() - * @param string $name - * @param mixed $route - * @param int $priority - * @return TreeRouteStack - */ - public function addRoute($name, $route, $priority = null) - { - if (! $route instanceof RouteInterface) { - $route = $this->routeFromArray($route); - } - - return parent::addRoute($name, $route, $priority); - } - - /** - * routeFromArray(): defined by SimpleRouteStack. - * - * @see SimpleRouteStack::routeFromArray() - * @param string|array|Traversable $specs - * @return RouteInterface - * @throws Exception\InvalidArgumentException When route definition is not an array nor traversable - * @throws Exception\InvalidArgumentException When chain routes are not an array nor traversable - * @throws Exception\RuntimeException When a generated routes does not implement the HTTP route interface - */ - protected function routeFromArray($specs) - { - if (is_string($specs)) { - if (null === ($route = $this->getPrototype($specs))) { - throw new Exception\RuntimeException(sprintf('Could not find prototype with name %s', $specs)); - } - - return $route; - } elseif ($specs instanceof Traversable) { - $specs = ArrayUtils::iteratorToArray($specs); - } elseif (! is_array($specs)) { - throw new Exception\InvalidArgumentException('Route definition must be an array or Traversable object'); - } - - if (isset($specs['chain_routes'])) { - if (! is_array($specs['chain_routes'])) { - throw new Exception\InvalidArgumentException('Chain routes must be an array or Traversable object'); - } - - $chainRoutes = array_merge([$specs], $specs['chain_routes']); - unset($chainRoutes[0]['chain_routes']); - - if (isset($specs['child_routes'])) { - unset($chainRoutes[0]['child_routes']); - } - - $options = [ - 'routes' => $chainRoutes, - 'route_plugins' => $this->routePluginManager, - 'prototypes' => $this->prototypes, - ]; - - $route = $this->routePluginManager->get('chain', $options); - } else { - $route = parent::routeFromArray($specs); - } - - if (! $route instanceof RouteInterface) { - throw new Exception\RuntimeException('Given route does not implement HTTP route interface'); - } - - if (isset($specs['child_routes'])) { - $options = [ - 'route' => $route, - 'may_terminate' => (isset($specs['may_terminate']) && $specs['may_terminate']), - 'child_routes' => $specs['child_routes'], - 'route_plugins' => $this->routePluginManager, - 'prototypes' => $this->prototypes, - ]; - - $priority = (isset($route->priority) ? $route->priority : null); - - $route = $this->routePluginManager->get('part', $options); - $route->priority = $priority; - } - - return $route; - } - - /** - * Add multiple prototypes at once. - * - * @param Traversable $routes - * @return TreeRouteStack - * @throws Exception\InvalidArgumentException - */ - public function addPrototypes($routes) - { - if (! is_array($routes) && ! $routes instanceof Traversable) { - throw new Exception\InvalidArgumentException('addPrototypes expects an array or Traversable set of routes'); - } - - foreach ($routes as $name => $route) { - $this->addPrototype($name, $route); - } - - return $this; - } - - /** - * Add a prototype. - * - * @param string $name - * @param mixed $route - * @return TreeRouteStack - */ - public function addPrototype($name, $route) - { - if (! $route instanceof RouteInterface) { - $route = $this->routeFromArray($route); - } - - $this->prototypes[$name] = $route; - - return $this; - } - - /** - * Get a prototype. - * - * @param string $name - * @return RouteInterface|null - */ - public function getPrototype($name) - { - if (isset($this->prototypes[$name])) { - return $this->prototypes[$name]; - } - - return; - } - - /** - * match(): defined by \Zend\Router\RouteInterface - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @param integer|null $pathOffset - * @param array $options - * @return RouteMatch|null - */ - public function match(Request $request, $pathOffset = null, array $options = []) - { - if (! method_exists($request, 'getUri')) { - return; - } - - if ($this->baseUrl === null && method_exists($request, 'getBaseUrl')) { - $this->setBaseUrl($request->getBaseUrl()); - } - - $uri = $request->getUri(); - $baseUrlLength = strlen($this->baseUrl) ?: null; - - if ($pathOffset !== null) { - $baseUrlLength += $pathOffset; - } - - if ($this->requestUri === null) { - $this->setRequestUri($uri); - } - - if ($baseUrlLength !== null) { - $pathLength = strlen($uri->getPath()) - $baseUrlLength; - } else { - $pathLength = null; - } - - foreach ($this->routes as $name => $route) { - if (($match = $route->match($request, $baseUrlLength, $options)) instanceof RouteMatch - && ($pathLength === null || $match->getLength() === $pathLength) - ) { - $match->setMatchedRouteName($name); - - foreach ($this->defaultParams as $paramName => $value) { - if ($match->getParam($paramName) === null) { - $match->setParam($paramName, $value); - } - } - - return $match; - } - } - - return; - } - - /** - * assemble(): defined by \Zend\Router\RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - * @throws Exception\InvalidArgumentException - * @throws Exception\RuntimeException - */ - public function assemble(array $params = [], array $options = []) - { - if (! isset($options['name'])) { - throw new Exception\InvalidArgumentException('Missing "name" option'); - } - - $names = explode('/', $options['name'], 2); - $route = $this->routes->get($names[0]); - - if (! $route) { - throw new Exception\RuntimeException(sprintf('Route with name "%s" not found', $names[0])); - } - - if (isset($names[1])) { - if (! $route instanceof TreeRouteStack) { - throw new Exception\RuntimeException(sprintf( - 'Route with name "%s" does not have child routes', - $names[0] - )); - } - $options['name'] = $names[1]; - } else { - unset($options['name']); - } - - if (isset($options['only_return_path']) && $options['only_return_path']) { - return $this->baseUrl . $route->assemble(array_merge($this->defaultParams, $params), $options); - } - - if (! isset($options['uri'])) { - $uri = new HttpUri(); - - if (isset($options['force_canonical']) && $options['force_canonical']) { - if ($this->requestUri === null) { - throw new Exception\RuntimeException('Request URI has not been set'); - } - - $uri->setScheme($this->requestUri->getScheme()) - ->setHost($this->requestUri->getHost()) - ->setPort($this->requestUri->getPort()); - } - - $options['uri'] = $uri; - } else { - $uri = $options['uri']; - } - - $path = $this->baseUrl . $route->assemble(array_merge($this->defaultParams, $params), $options); - - if (isset($options['query'])) { - $uri->setQuery($options['query']); - } - - if (isset($options['fragment'])) { - $uri->setFragment($options['fragment']); - } - - if ((isset($options['force_canonical']) - && $options['force_canonical']) - || $uri->getHost() !== null - || $uri->getScheme() !== null - ) { - if (($uri->getHost() === null || $uri->getScheme() === null) && $this->requestUri === null) { - throw new Exception\RuntimeException('Request URI has not been set'); - } - - if ($uri->getHost() === null) { - $uri->setHost($this->requestUri->getHost()); - } - - if ($uri->getScheme() === null) { - $uri->setScheme($this->requestUri->getScheme()); - } - - $uri->setPath($path); - - if (! isset($options['normalize_path']) || $options['normalize_path']) { - $uri->normalize(); - } - - return $uri->toString(); - } elseif (! $uri->isAbsolute() && $uri->isValidRelative()) { - $uri->setPath($path); - - if (! isset($options['normalize_path']) || $options['normalize_path']) { - $uri->normalize(); - } - - return $uri->toString(); - } - - return $path; - } - - /** - * Set the base URL. - * - * @param string $baseUrl - * @return self - */ - public function setBaseUrl($baseUrl) - { - $this->baseUrl = rtrim($baseUrl, '/'); - return $this; - } - - /** - * Get the base URL. - * - * @return string - */ - public function getBaseUrl() - { - return $this->baseUrl; - } - - /** - * Set the request URI. - * - * @param HttpUri $uri - * @return TreeRouteStack - */ - public function setRequestUri(HttpUri $uri) - { - $this->requestUri = $uri; - return $this; - } - - /** - * Get the request URI. - * - * @return HttpUri - */ - public function getRequestUri() - { - return $this->requestUri; - } -} diff --git a/src/Http/Wildcard.php b/src/Http/Wildcard.php deleted file mode 100644 index a56f3b7..0000000 --- a/src/Http/Wildcard.php +++ /dev/null @@ -1,192 +0,0 @@ -keyValueDelimiter = $keyValueDelimiter; - $this->paramDelimiter = $paramDelimiter; - $this->defaults = $defaults; - } - - /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param array|Traversable $options - * @return Wildcard - * @throws Exception\InvalidArgumentException - */ - public static function factory($options = []) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); - } - - if (! isset($options['key_value_delimiter'])) { - $options['key_value_delimiter'] = '/'; - } - - if (! isset($options['param_delimiter'])) { - $options['param_delimiter'] = '/'; - } - - if (! isset($options['defaults'])) { - $options['defaults'] = []; - } - - return new static($options['key_value_delimiter'], $options['param_delimiter'], $options['defaults']); - } - - /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @param integer|null $pathOffset - * @return RouteMatch|null - */ - public function match(Request $request, $pathOffset = null) - { - if (! method_exists($request, 'getUri')) { - return; - } - - $uri = $request->getUri(); - $path = $uri->getPath() ?: ''; - - if ($path === '/') { - $path = ''; - } - - if ($pathOffset !== null) { - $path = substr($path, $pathOffset) ?: ''; - } - - $matches = []; - $params = explode($this->paramDelimiter, $path); - - if (count($params) > 1 && ($params[0] !== '' || end($params) === '')) { - return; - } - - if ($this->keyValueDelimiter === $this->paramDelimiter) { - $count = count($params); - - for ($i = 1; $i < $count; $i += 2) { - if (isset($params[$i + 1])) { - $matches[rawurldecode($params[$i])] = rawurldecode($params[$i + 1]); - } - } - } else { - array_shift($params); - - foreach ($params as $param) { - $param = explode($this->keyValueDelimiter, $param, 2); - - if (isset($param[1])) { - $matches[rawurldecode($param[0])] = rawurldecode($param[1]); - } - } - } - - return new RouteMatch(array_merge($this->defaults, $matches), strlen($path)); - } - - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = [], array $options = []) - { - $elements = []; - $mergedParams = array_merge($this->defaults, $params); - $this->assembledParams = []; - - if ($mergedParams) { - foreach ($mergedParams as $key => $value) { - $elements[] = rawurlencode($key) . $this->keyValueDelimiter . rawurlencode($value); - - $this->assembledParams[] = $key; - } - - return $this->paramDelimiter . implode($this->paramDelimiter, $elements); - } - - return ''; - } - - /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see RouteInterface::getAssembledParams - * @return array - */ - public function getAssembledParams() - { - return $this->assembledParams; - } -} diff --git a/src/Module.php b/src/Module.php index cbe86f9..af199dc 100644 --- a/src/Module.php +++ b/src/Module.php @@ -1,10 +1,12 @@ success = true; + $result->matchedParams = $matchedParams; + $result->matchedRouteName = $routeName; + $result->pathOffset = $pathOffset; + $result->matchedPathLength = $matchedPathLength; + if (! empty($allowedMethods)) { + $result->setMatchedAllowedMethods($allowedMethods); + } + return $result; + } + + /** + * Create failed routing result + */ + public static function fromRouteFailure() : self + { + $result = new self(); + $result->success = false; + return $result; + } + + /** + * Create routing failure result where http method is not allowed for the + * otherwise routable request + * + * @throws InvalidArgumentException + */ + public static function fromMethodFailure( + array $allowedMethods, + int $pathOffset, + int $matchedPathLength + ) : self { + if (empty($allowedMethods)) { + throw new InvalidArgumentException('Method failure requires list of allowed methods'); + } + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); + } + if ($matchedPathLength < 0) { + throw new InvalidArgumentException('Matched path length cannot be negative'); + } + + $result = new self(); + $result->success = false; + $result->setAllowedMethods($allowedMethods); + $result->pathOffset = $pathOffset; + $result->matchedPathLength = $matchedPathLength; + return $result; + } + + /** + * Is this a routing success result? + */ + public function isSuccess() : bool + { + return $this->success; + } + + /** + * Is this a routing failure result? + */ + public function isFailure() : bool + { + return ! $this->success; + } + + /** + * Is this a result for failed routing due to HTTP method? + */ + public function isMethodFailure() : bool + { + if ($this->isSuccess() || empty($this->allowedMethods)) { + return false; + } + return true; + } + + /** + * Checks if partial route result is a full match for the provided uri path. + * Expects same uri as used for matching. + */ + public function isFullPathMatch(UriInterface $uri) : bool + { + // non http method failure is no match. For edge case of empty uri path + if ($this->isFailure() && ! $this->isMethodFailure()) { + return false; + } + $pathLength = strlen($uri->getPath()); + return $pathLength === ($this->pathOffset + $this->matchedPathLength); + } + + /** + * Produce a new partial route result with provided route name. Can only be used + * with successful result. + * + * @param string $flag Signifies mode of setting route name: + * - {@see RouteResult::NAME_REPLACE} replaces existing route name + * - {@see RouteResult::NAME_PREPEND} prepends as a parent route part name. + * - {@see RouteResult::NAME_APPEND} appends as a child route part name. + * @throws InvalidArgumentException + * @throws RuntimeException + */ + public function withMatchedRouteName(string $routeName, $flag = RouteResult::NAME_REPLACE) : self + { + if (empty($routeName)) { + throw new InvalidArgumentException('Route name cannot be empty'); + } + if (! $this->isSuccess()) { + throw new RuntimeException('Only successful routing can have matched route name'); + } + $result = clone $this; + + // If no matched route name is set, simply replace value + if ($flag === RouteResult::NAME_REPLACE || $this->matchedRouteName === null) { + $result->matchedRouteName = $routeName; + return $result; + } + + if ($flag === RouteResult::NAME_PREPEND) { + $routeName = sprintf('%s/%s', $routeName, $this->matchedRouteName); + } elseif ($flag === RouteResult::NAME_APPEND) { + $routeName = sprintf('%s/%s', $this->matchedRouteName, $routeName); + } else { + throw new InvalidArgumentException('Unknown flag for setting matched route name'); + } + $result->matchedRouteName = $routeName; + + return $result; + } + + /** + * Produce a new partial route result with provided matched parameters. Can only be + * used with successful result. + * + * @throws RuntimeException + */ + public function withMatchedParams(array $params) : self + { + if (! $this->isSuccess()) { + throw new RuntimeException('Only successful routing can have matched params'); + } + $result = clone $this; + $result->matchedParams = $params; + return $result; + } + + /** + * Matched route name on successful routing. + * Can be null. Route name is normally set by the route stack and can differ + * for same route instance if it is used in several places. + */ + public function getMatchedRouteName() : ?string + { + return $this->matchedRouteName; + } + + /** + * Matched parameters on successful routing + */ + public function getMatchedParams() : array + { + return $this->matchedParams; + } + + /** + * Returns list of allowed methods on method failure. + */ + public function getAllowedMethods() : array + { + return $this->allowedMethods; + } + + /** + * Offset used for partial routing matching + */ + public function getUsedPathOffset() : int + { + return $this->pathOffset; + } + + /** + * Matched uri path length, starting from offset + */ + public function getMatchedPathLength() : int + { + return $this->matchedPathLength; + } + + public function getMatchedAllowedMethods() : ?array + { + return $this->matchedAllowedMethods; + } + + public function withMatchedAllowedMethods(array $methods) : self + { + $result = clone $this; + $result->setMatchedAllowedMethods($methods); + + return $result; + } + + /** + * Helper function to deduplicate and normalize HTTP method names + */ + private function setMatchedAllowedMethods(array $methods) : void + { + $methods = array_keys(array_change_key_case( + array_flip($methods), + CASE_UPPER + )); + $this->matchedAllowedMethods = $methods; + } + + /** + * Helper function to deduplicate and normalize HTTP method names + */ + private function setAllowedMethods(array $methods) : void + { + $methods = array_keys(array_change_key_case( + array_flip($methods), + CASE_UPPER + )); + $this->allowedMethods = $methods; + } + + /** + * Disallow new-ing route result + */ + private function __construct() + { + } +} diff --git a/src/PriorityList.php b/src/PriorityList.php index 1cd7b55..5e34969 100644 --- a/src/PriorityList.php +++ b/src/PriorityList.php @@ -1,10 +1,12 @@ addRoutes($routes); + } + + /** + * @throws InvalidArgumentException + */ + public static function factory(iterable $options = []) : self + { + if (! is_array($options)) { + $options = ArrayUtils::iteratorToArray($options); + } + + if (! isset($options['routes'])) { + throw new InvalidArgumentException('Missing "routes" in options array'); + } + + if ($options['routes'] instanceof Traversable) { + $options['routes'] = ArrayUtils::iteratorToArray($options['routes']); + } + + $routes = []; + foreach ($options['routes'] as $name => $route) { + if (is_numeric($name)) { + $name = sprintf('__chained_route_no_name_%d', self::$chainedIndex++); + } + $routes[$name] = $route; + } + + return new static($routes); + } + + /** + * @throws InvalidArgumentException + */ + public function addRoute(string $name, RouteInterface $route, int $priority = null) : void + { + if (! $route instanceof PartialRouteInterface) { + throw new InvalidArgumentException('Chain route can only chain partial routes'); + } + parent::addRoute($name, $route, $priority); + } + + /** + * @throws InvalidArgumentException + */ + public function partialMatch(Request $request, int $pathOffset = 0, array $options = []) : PartialRouteResult + { + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); + } + + $nextPathOffset = $pathOffset; + $methodFailure = false; + $allowedMethods = null; + $matchedParams = []; + + if ($this->routes->count() === 0) { + return PartialRouteResult::fromRouteFailure(); + } + + foreach ($this->getRoutes() as $route) { + /** @var PartialRouteInterface $route */ + $result = $route->partialMatch($request, $nextPathOffset, $options); + + if ($result->isFailure() && ! $result->isMethodFailure()) { + return $result; + } + + if ($result->isMethodFailure()) { + $methodFailure = true; + // make all following method routes fail, needed for allowed + // methods gathering by Part route even tho it should not + // be normally allowed to be chained + $options[Method::OPTION_FORCE_METHOD_FAILURE] = true; + + $allowedMethods = $allowedMethods ?? $result->getAllowedMethods(); + $allowedMethods = array_intersect( + $allowedMethods, + $result->getAllowedMethods() + ); + } + + if ($result->isSuccess()) { + $matchedParams = array_merge($matchedParams, $result->getMatchedParams()); + + $options['parent_match_params'] = $options['parent_match_params'] ?? []; + $options['parent_match_params'] += $matchedParams; + + $methods = $result->getMatchedAllowedMethods(); + if (! empty($methods)) { + $allowedMethods = $allowedMethods ?? $methods; + $allowedMethods = array_intersect($allowedMethods, $methods); + } + } + + $nextPathOffset += $result->getMatchedPathLength(); + } + + $matchedLength = $nextPathOffset - $pathOffset; + if ($methodFailure) { + if (empty($allowedMethods)) { + return PartialRouteResult::fromRouteFailure(); + } + return PartialRouteResult::fromMethodFailure($allowedMethods, $pathOffset, $matchedLength); + } + + // explicitly discarding chained route names if any + return PartialRouteResult::fromRouteMatch( + $matchedParams, + $pathOffset, + $matchedLength, + null, + $allowedMethods + ); + } + + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + $this->assembledParams = []; + + $routes = ArrayUtils::iteratorToArray($this->routes); + end($routes); + $lastRouteKey = key($routes); + + foreach ($routes as $key => $route) { + /** @var PartialRouteInterface $route */ + $chainOptions = $options; + $hasChild = isset($options['has_child']) ? $options['has_child'] : false; + + $chainOptions['has_child'] = $hasChild || $key !== $lastRouteKey; + + $uri = $route->assemble($uri, $params, $chainOptions); + $params = array_diff_key($params, array_flip($route->getLastAssembledParams())); + + $this->assembledParams = array_merge($this->assembledParams, $route->getLastAssembledParams()); + } + + return $uri; + } + + public function getLastAssembledParams() : array + { + return $this->assembledParams; + } + + /** + * @deprecated + */ + public function getAssembledParams() : array + { + return $this->getLastAssembledParams(); + } +} diff --git a/src/Http/Hostname.php b/src/Route/Hostname.php similarity index 64% rename from src/Http/Hostname.php rename to src/Route/Hostname.php index 5ddd9f5..44de48f 100644 --- a/src/Http/Hostname.php +++ b/src/Route/Hostname.php @@ -1,22 +1,37 @@ defaults = $defaults; - $this->parts = $this->parseRouteDefinition($route); - $this->regex = $this->buildRegex($this->parts, $constraints); + $this->parts = $this->parseRouteDefinition($route); + $this->regex = $this->buildRegex($this->parts, $constraints); } /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param array|Traversable $options - * @return Hostname - * @throws Exception\InvalidArgumentException + * @throws InvalidArgumentException */ - public static function factory($options = []) + public static function factory(iterable $options = []) : self { - if ($options instanceof Traversable) { + if (! is_array($options)) { $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); } if (! isset($options['route'])) { - throw new Exception\InvalidArgumentException('Missing "route" in options array'); + throw new InvalidArgumentException('Missing "route" in options array'); } if (! isset($options['constraints'])) { @@ -103,17 +104,15 @@ public static function factory($options = []) /** * Parse a route definition. * - * @param string $def - * @return array * @throws Exception\RuntimeException */ - protected function parseRouteDefinition($def) + protected function parseRouteDefinition(string $def) : array { $currentPos = 0; - $length = strlen($def); - $parts = []; + $length = strlen($def); + $parts = []; $levelParts = [&$parts]; - $level = 0; + $level = 0; while ($currentPos < $length) { if (! preg_match('(\G(?P[a-z0-9-.]*)(?P[:{\[\]]|$))', $def, $matches, 0, $currentPos)) { @@ -140,7 +139,7 @@ protected function parseRouteDefinition($def) $levelParts[$level][] = [ 'parameter', $matches['name'], - isset($matches['delimiters']) ? $matches['delimiters'] : null + isset($matches['delimiters']) ? $matches['delimiters'] : null, ]; $currentPos += strlen($matches[0]); @@ -170,14 +169,8 @@ protected function parseRouteDefinition($def) /** * Build the matching regex from parsed parts. - * - * @param array $parts - * @param array $constraints - * @param int $groupIndex - * @return string - * @throws Exception\RuntimeException */ - protected function buildRegex(array $parts, array $constraints, &$groupIndex = 1) + protected function buildRegex(array $parts, array $constraints, int &$groupIndex = 1) : string { $regex = ''; @@ -213,17 +206,12 @@ protected function buildRegex(array $parts, array $constraints, &$groupIndex = 1 /** * Build host. * - * @param array $parts - * @param array $mergedParams - * @param bool $isOptional - * @return string - * @throws Exception\RuntimeException - * @throws Exception\InvalidArgumentException + * @throws InvalidArgumentException */ - protected function buildHost(array $parts, array $mergedParams, $isOptional) + protected function buildHost(array $parts, array $mergedParams, bool $isOptional) : string { - $host = ''; - $skip = true; + $host = ''; + $skip = true; $skippable = false; foreach ($parts as $part) { @@ -237,7 +225,7 @@ protected function buildHost(array $parts, array $mergedParams, $isOptional) if (! isset($mergedParams[$part[1]])) { if (! $isOptional) { - throw new Exception\InvalidArgumentException(sprintf('Missing parameter "%s"', $part[1])); + throw new InvalidArgumentException(sprintf('Missing parameter "%s"', $part[1])); } return ''; @@ -254,12 +242,12 @@ protected function buildHost(array $parts, array $mergedParams, $isOptional) break; case 'optional': - $skippable = true; + $skippable = true; $optionalPart = $this->buildHost($part[1], $mergedParams, true); if ($optionalPart !== '') { $host .= $optionalPart; - $skip = false; + $skip = false; } break; } @@ -273,25 +261,20 @@ protected function buildHost(array $parts, array $mergedParams, $isOptional) } /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @return RouteMatch|null + * @throws InvalidArgumentException */ - public function match(Request $request) + public function partialMatch(Request $request, int $pathOffset = 0, array $options = []) : PartialRouteResult { - if (! method_exists($request, 'getUri')) { - return; + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); } - - $uri = $request->getUri(); + $uri = $request->getUri(); $host = $uri->getHost(); $result = preg_match('(^' . $this->regex . '$)', $host, $matches); if (! $result) { - return; + return PartialRouteResult::fromRouteFailure(); } $params = []; @@ -302,43 +285,36 @@ public function match(Request $request) } } - return new RouteMatch(array_merge($this->defaults, $params)); + return PartialRouteResult::fromRouteMatch(array_merge($this->defaults, $params), $pathOffset, 0); } - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = [], array $options = []) + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface { $this->assembledParams = []; - if (isset($options['uri'])) { - $host = $this->buildHost( - $this->parts, - array_merge($this->defaults, $params), - false - ); - - $options['uri']->setHost($host); - } - - // A hostname does not contribute to the path, thus nothing is returned. - return ''; + return $uri->withHost($this->buildHost( + $this->parts, + array_merge($this->defaults, $params), + false + )); } /** - * getAssembledParams(): defined by RouteInterface interface. + * Get parameters used to assemble uri on the last assemble invocation. + * Used during uri assembling by Part and Chain routes * - * @see RouteInterface::getAssembledParams - * @return array + * @internal */ - public function getAssembledParams() + public function getLastAssembledParams() : array { return $this->assembledParams; } + + /** + * @deprecated + */ + public function getAssembledParams() : array + { + return $this->getLastAssembledParams(); + } } diff --git a/src/Route/Literal.php b/src/Route/Literal.php new file mode 100644 index 0000000..f93fb7c --- /dev/null +++ b/src/Route/Literal.php @@ -0,0 +1,121 @@ +path = $path; + $this->defaults = $defaults; + } + + /** + * @todo provide factory for route plugin manager + * @throws InvalidArgumentException + */ + public static function factory(iterable $options = []) : self + { + if (! is_array($options)) { + $options = ArrayUtils::iteratorToArray($options); + } + + if (! isset($options['route'])) { + throw new InvalidArgumentException('Missing "route" in options array'); + } + + if (! isset($options['defaults'])) { + $options['defaults'] = []; + } + + return new static($options['route'], $options['defaults']); + } + + /** + * Attempt to match ServerRequestInterface by checking for literal + * path segment at offset position. + * + * @throws InvalidArgumentException + */ + public function partialMatch(Request $request, int $pathOffset = 0, array $options = []) : PartialRouteResult + { + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); + } + $path = $request->getUri()->getPath(); + + if (strpos($path, $this->path, $pathOffset) === $pathOffset) { + return PartialRouteResult::fromRouteMatch($this->defaults, $pathOffset, strlen($this->path)); + } + return PartialRouteResult::fromRouteFailure(); + } + + /** + * Assemble url by appending literal path part + */ + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + return $uri->withPath($uri->getPath() . $this->path); + } + + /** + * Literal routes are not using parameters to assemble uri + */ + public function getLastAssembledParams() : array + { + return []; + } + + /** + * @deprecated + */ + public function getAssembledParams() : array + { + return $this->getLastAssembledParams(); + } +} diff --git a/src/Route/Method.php b/src/Route/Method.php new file mode 100644 index 0000000..b3f9794 --- /dev/null +++ b/src/Route/Method.php @@ -0,0 +1,119 @@ +verb = $verb; + $this->defaults = $defaults; + } + + /** + * Create a new method route. + * + * @throws InvalidArgumentException + */ + public static function factory(iterable $options = []) : self + { + if (! is_array($options)) { + $options = ArrayUtils::iteratorToArray($options); + } + + if (! isset($options['verb'])) { + throw new InvalidArgumentException('Missing "verb" in options array'); + } + + if (! isset($options['defaults'])) { + $options['defaults'] = []; + } + + return new static($options['verb'], $options['defaults']); + } + + /** + * @throws InvalidArgumentException + */ + public function partialMatch(Request $request, int $pathOffset = 0, array $options = []) : PartialRouteResult + { + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); + } + $requestVerb = strtoupper($request->getMethod()); + $matchVerbs = explode(',', strtoupper($this->verb)); + $matchVerbs = array_map('trim', $matchVerbs); + + $forceFailure = $options[self::OPTION_FORCE_METHOD_FAILURE] ?? false; + if (! $forceFailure && in_array($requestVerb, $matchVerbs)) { + return PartialRouteResult::fromRouteMatch($this->defaults, $pathOffset, 0, null, $matchVerbs); + } + + return PartialRouteResult::fromMethodFailure($matchVerbs, $pathOffset, 0); + } + + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + return $uri; + } + + /** + * Method routes are not using parameters to assemble uri + */ + public function getLastAssembledParams() : array + { + return []; + } + + /** + * @deprecated + */ + public function getAssembledParams() : array + { + return $this->getLastAssembledParams(); + } +} diff --git a/src/Route/Part.php b/src/Route/Part.php new file mode 100644 index 0000000..40282ee --- /dev/null +++ b/src/Route/Part.php @@ -0,0 +1,267 @@ +route = $route; + $this->childRoutes = $childRoutes; + $this->mayTerminate = $mayTerminate; + } + + /** + * @throws InvalidArgumentException + */ + public static function factory(iterable $options = []) : self + { + if (! is_array($options)) { + $options = ArrayUtils::iteratorToArray($options); + } + + if (! isset($options['route'])) { + throw new InvalidArgumentException('Missing "route" in options array'); + } + + if (! isset($options['child_routes']) || ! $options['child_routes']) { + $options['child_routes'] = []; + } + + if (is_array($options['child_routes'])) { + $childRoutes = new TreeRouteStack(); + $childRoutes->addRoutes($options['child_routes']); + $options['child_routes'] = $childRoutes; + } + + if (! isset($options['may_terminate'])) { + $options['may_terminate'] = false; + } + + return new static( + $options['route'], + $options['child_routes'], + $options['may_terminate'] + ); + } + + /** + * Match a given request. + * + * @throws InvalidArgumentException on negative path offset + */ + public function match(Request $request, int $pathOffset = 0, array $options = []) : RouteResult + { + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); + } + $partialResult = $this->route->partialMatch($request, $pathOffset, $options); + + // continue matching for method failure to allow precise method list + if ($partialResult->isFailure() && ! $partialResult->isMethodFailure()) { + return RouteResult::fromRouteFailure(); + } + + if ($this->mayTerminate && $partialResult->isFullPathMatch($request->getUri())) { + // we get complete list of allowed methods on method failure. Child + // routes cannot expand it, so no reason to try to gather allowed + // methods for them + if ($partialResult->isMethodFailure()) { + return RouteResult::fromMethodFailure($partialResult->getAllowedMethods()); + } + // We got full match, our work here is done + return RouteResult::fromRouteMatch( + $partialResult->getMatchedParams() + ); + } + + // pass matched params to child routes. + // Could be used for eg obtaining locale from matched parameters from parent routes. + if ($partialResult->isSuccess()) { + $options['parent_match_params'] = $options['parent_match_params'] ?? []; + $options['parent_match_params'] += $partialResult->getMatchedParams(); + } + + // we continue matching only to gather allowed methods. Force + // method routes to fail + if ($partialResult->isMethodFailure()) { + $options[Method::OPTION_FORCE_METHOD_FAILURE] = true; + } + + $nextOffset = $pathOffset + $partialResult->getMatchedPathLength(); + + $childResult = $this->childRoutes->match($request, $nextOffset, $options); + + if ($partialResult->isSuccess() && $childResult->isSuccess()) { + return $childResult->withMatchedParams( + array_merge($partialResult->getMatchedParams(), $childResult->getMatchedParams()) + ); + } + + if ($childResult->isMethodFailure() && $partialResult->isMethodFailure()) { + $methods = array_intersect( + $partialResult->getAllowedMethods(), + $childResult->getAllowedMethods() + ); + if (empty($methods)) { + return RouteResult::fromRouteFailure(); + } + return RouteResult::fromMethodFailure($methods); + } + + if ($partialResult->isMethodFailure() && $childResult->isSuccess()) { + return RouteResult::fromMethodFailure( + $partialResult->getAllowedMethods() + ); + } + + if ($childResult->isMethodFailure()) { + $parentMethods = $partialResult->getMatchedAllowedMethods(); + $methods = $childResult->getAllowedMethods(); + if (! empty($parentMethods)) { + $methods = array_intersect( + $parentMethods, + $childResult->getAllowedMethods() + ); + } + return RouteResult::fromMethodFailure($methods); + } + + return RouteResult::fromRouteFailure(); + } + + /** + * Assemble uri for the route. + * + * @throws Exception\RuntimeException when trying to assemble part route without + * child route name, if part route can't terminate + */ + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + $partOptions = $options; + $partOptions['has_child'] = isset($options['name']); + unset($partOptions['name']); + + $uri = $this->route->assemble($uri, $params, $partOptions); + $params = array_diff_key($params, array_flip($this->route->getLastAssembledParams())); + + if (! isset($options['name'])) { + if (! $this->mayTerminate) { + throw new Exception\RuntimeException('Part route may not terminate'); + } else { + return $uri; + } + } + + return $this->childRoutes->assemble($uri, $params, $options); + } + + /** + * Add a route to the stack. + */ + public function addRoute(string $name, RouteInterface $route, int $priority = null) : void + { + $this->childRoutes->addRoute($name, $route, $priority); + } + + /** + * Add multiple routes to the stack. + */ + public function addRoutes(iterable $routes) : void + { + $this->childRoutes->addRoutes($routes); + } + + /** + * Remove a route from the stack. + */ + public function removeRoute(string $name) : void + { + $this->childRoutes->removeRoute($name); + } + + /** + * Remove all routes from the stack and set new ones. + */ + public function setRoutes(iterable $routes) : void + { + $this->childRoutes->setRoutes($routes); + } + + /** + * Get the added routes + */ + public function getRoutes() : array + { + return $this->childRoutes->getRoutes(); + } + + /** + * Check if a route with a specific name exists + */ + public function hasRoute(string $name) : bool + { + return $this->childRoutes->hasRoute($name); + } + + /** + * Get a route by name + */ + public function getRoute(string $name) : ?RouteInterface + { + return $this->childRoutes->getRoute($name); + } +} diff --git a/src/Route/PartialRouteTrait.php b/src/Route/PartialRouteTrait.php new file mode 100644 index 0000000..8cdd711 --- /dev/null +++ b/src/Route/PartialRouteTrait.php @@ -0,0 +1,50 @@ +partialMatch($request, $pathOffset, $options); + if (! $result->isFullPathMatch($request->getUri())) { + return RouteResult::fromRouteFailure(); + } + if ($result->isSuccess()) { + return RouteResult::fromRouteMatch($result->getMatchedParams(), $result->getMatchedRouteName()); + } + if ($result->isMethodFailure()) { + return RouteResult::fromMethodFailure($result->getAllowedMethods()); + } + // unreachable due to full path match check. It is kept here intentionally + return RouteResult::fromRouteFailure(); + } +} diff --git a/src/Route/Regex.php b/src/Route/Regex.php new file mode 100644 index 0000000..8c4af00 --- /dev/null +++ b/src/Route/Regex.php @@ -0,0 +1,162 @@ +regex = $regex; + $this->spec = $spec; + $this->defaults = $defaults; + } + + /** + * @throws InvalidArgumentException + */ + public static function factory(iterable $options = []) : self + { + if (! is_array($options)) { + $options = ArrayUtils::iteratorToArray($options); + } + + if (! isset($options['regex'])) { + throw new InvalidArgumentException('Missing "regex" in options array'); + } + + if (! isset($options['spec'])) { + throw new InvalidArgumentException('Missing "spec" in options array'); + } + + if (! isset($options['defaults'])) { + $options['defaults'] = []; + } + + return new static($options['regex'], $options['spec'], $options['defaults']); + } + + /** + * @throws InvalidArgumentException + */ + public function partialMatch(Request $request, int $pathOffset = 0, array $options = []) : PartialRouteResult + { + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); + } + $uri = $request->getUri(); + $path = $uri->getPath(); + + $result = preg_match('(\G' . $this->regex . ')', $path, $matches, 0, $pathOffset); + + if (! $result) { + return PartialRouteResult::fromRouteFailure(); + } + + $matchedLength = strlen($matches[0]); + + foreach ($matches as $key => $value) { + if (is_numeric($key) || is_int($key) || $value === '') { + unset($matches[$key]); + } else { + $matches[$key] = rawurldecode($value); + } + } + + return PartialRouteResult::fromRouteMatch(array_merge($this->defaults, $matches), $pathOffset, $matchedLength); + } + + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + $url = $this->spec; + $mergedParams = array_merge($this->defaults, $params); + $this->assembledParams = []; + + foreach ($mergedParams as $key => $value) { + $spec = '%' . $key . '%'; + + if (strpos($url, $spec) !== false) { + $url = str_replace($spec, rawurlencode($value), $url); + + $this->assembledParams[] = $key; + } + } + + return $uri->withPath($uri->getPath() . $url); + } + + public function getLastAssembledParams() : array + { + return $this->assembledParams; + } + + /** + * @deprecated + */ + public function getAssembledParams() : array + { + return $this->getLastAssembledParams(); + } +} diff --git a/src/Route/Scheme.php b/src/Route/Scheme.php new file mode 100644 index 0000000..2652868 --- /dev/null +++ b/src/Route/Scheme.php @@ -0,0 +1,106 @@ +scheme = $scheme; + $this->defaults = $defaults; + } + + /** + * @throws InvalidArgumentException + */ + public static function factory(iterable $options = []) : self + { + if (! is_array($options)) { + $options = ArrayUtils::iteratorToArray($options); + } + + if (! isset($options['scheme'])) { + throw new InvalidArgumentException('Missing "scheme" in options array'); + } + + if (! isset($options['defaults'])) { + $options['defaults'] = []; + } + + return new static($options['scheme'], $options['defaults']); + } + + /** + * @throws InvalidArgumentException + */ + public function partialMatch(Request $request, int $pathOffset = 0, array $options = []) : PartialRouteResult + { + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); + } + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + + if ($scheme !== $this->scheme) { + return PartialRouteResult::fromRouteFailure(); + } + + return PartialRouteResult::fromRouteMatch($this->defaults, $pathOffset, 0); + } + + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + return $uri->withScheme($this->scheme); + } + + public function getLastAssembledParams() : array + { + return []; + } + + /** + * @deprecated + */ + public function getAssembledParams() : array + { + return $this->getLastAssembledParams(); + } +} diff --git a/src/Http/Segment.php b/src/Route/Segment.php similarity index 60% rename from src/Http/Segment.php rename to src/Route/Segment.php index 01ee076..dc58c52 100644 --- a/src/Http/Segment.php +++ b/src/Route/Segment.php @@ -1,23 +1,42 @@ "!", // sub-delims - '%24' => "$", // sub-delims - '%26' => "&", // sub-delims + '%21' => '!', // sub-delims + '%24' => '$', // sub-delims + '%26' => '&', // sub-delims '%27' => "'", // sub-delims - '%28' => "(", // sub-delims - '%29' => ")", // sub-delims - '%2A' => "*", // sub-delims - '%2B' => "+", // sub-delims - '%2C' => ",", // sub-delims -// '%2D' => "-", // unreserved - not touched by rawurlencode -// '%2E' => ".", // unreserved - not touched by rawurlencode - '%3A' => ":", // pchar - '%3B' => ";", // sub-delims - '%3D' => "=", // sub-delims - '%40' => "@", // pchar -// '%5F' => "_", // unreserved - not touched by rawurlencode -// '%7E' => "~", // unreserved - not touched by rawurlencode + '%28' => '(', // sub-delims + '%29' => ')', // sub-delims + '%2A' => '*', // sub-delims + '%2B' => '+', // sub-delims + '%2C' => ',', // sub-delims + // '%2D' => "-", // unreserved - not touched by rawurlencode + // '%2E' => ".", // unreserved - not touched by rawurlencode + '%3A' => ':', // pchar + '%3B' => ';', // sub-delims + '%3D' => '=', // sub-delims + '%40' => '@', // pchar + // '%5F' => "_", // unreserved - not touched by rawurlencode + // '%7E' => "~", // unreserved - not touched by rawurlencode ]; /** @@ -101,39 +120,27 @@ class Segment implements RouteInterface /** * Create a new regex route. - * - * @param string $route - * @param array $constraints - * @param array $defaults */ - public function __construct($route, array $constraints = [], array $defaults = []) + public function __construct(string $route, array $constraints = [], array $defaults = []) { $this->defaults = $defaults; - $this->parts = $this->parseRouteDefinition($route); - $this->regex = $this->buildRegex($this->parts, $constraints); + $this->parts = $this->parseRouteDefinition($route); + $this->regex = $this->buildRegex($this->parts, $constraints); } /** * factory(): defined by RouteInterface interface. * - * @see \Zend\Router\RouteInterface::factory() - * @param array|Traversable $options - * @return Segment - * @throws Exception\InvalidArgumentException + * @throws InvalidArgumentException */ - public static function factory($options = []) + public static function factory(iterable $options = []) : self { - if ($options instanceof Traversable) { + if (! is_array($options)) { $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); } if (! isset($options['route'])) { - throw new Exception\InvalidArgumentException('Missing "route" in options array'); + throw new InvalidArgumentException('Missing "route" in options array'); } if (! isset($options['constraints'])) { @@ -150,17 +157,15 @@ public static function factory($options = []) /** * Parse a route definition. * - * @param string $def - * @return array - * @throws Exception\RuntimeException + * @throws RuntimeException */ - protected function parseRouteDefinition($def) + protected function parseRouteDefinition(string $def) : array { $currentPos = 0; - $length = strlen($def); - $parts = []; + $length = strlen($def); + $parts = []; $levelParts = [&$parts]; - $level = 0; + $level = 0; while ($currentPos < $length) { preg_match('(\G(?P[^:{\[\]]*)(?P[:{\[\]]|$))', $def, $matches, 0, $currentPos); @@ -179,19 +184,19 @@ protected function parseRouteDefinition($def) 0, $currentPos )) { - throw new Exception\RuntimeException('Found empty parameter name'); + throw new RuntimeException('Found empty parameter name'); } $levelParts[$level][] = [ 'parameter', $matches['name'], - isset($matches['delimiters']) ? $matches['delimiters'] : null + isset($matches['delimiters']) ? $matches['delimiters'] : null, ]; $currentPos += strlen($matches[0]); } elseif ($matches['token'] === '{') { if (! preg_match('(\G(?P[^}]+)\})', $def, $matches, 0, $currentPos)) { - throw new Exception\RuntimeException('Translated literal missing closing bracket'); + throw new RuntimeException('Translated literal missing closing bracket'); } $currentPos += strlen($matches[0]); @@ -207,7 +212,7 @@ protected function parseRouteDefinition($def) $level--; if ($level < 0) { - throw new Exception\RuntimeException('Found closing bracket without matching opening bracket'); + throw new RuntimeException('Found closing bracket without matching opening bracket'); } } else { break; @@ -215,7 +220,7 @@ protected function parseRouteDefinition($def) } if ($level > 0) { - throw new Exception\RuntimeException('Found unbalanced brackets'); + throw new RuntimeException('Found unbalanced brackets'); } return $parts; @@ -223,13 +228,8 @@ protected function parseRouteDefinition($def) /** * Build the matching regex from parsed parts. - * - * @param array $parts - * @param array $constraints - * @param int $groupIndex - * @return string */ - protected function buildRegex(array $parts, array $constraints, &$groupIndex = 1) + protected function buildRegex(array $parts, array $constraints, int &$groupIndex = 1) : string { $regex = ''; @@ -270,29 +270,28 @@ protected function buildRegex(array $parts, array $constraints, &$groupIndex = 1 /** * Build a path. * - * @param array $parts - * @param array $mergedParams - * @param bool $isOptional - * @param bool $hasChild - * @param array $options - * @return string - * @throws Exception\InvalidArgumentException - * @throws Exception\RuntimeException + * @throws InvalidArgumentException + * @throws RuntimeException */ - protected function buildPath(array $parts, array $mergedParams, $isOptional, $hasChild, array $options) - { + protected function buildPath( + array $parts, + array $mergedParams, + bool $isOptional, + bool $hasChild, + array $options + ) : string { if ($this->translationKeys) { if (! isset($options['translator']) || ! $options['translator'] instanceof Translator) { - throw new Exception\RuntimeException('No translator provided'); + throw new RuntimeException('No translator provided'); } $translator = $options['translator']; - $textDomain = (isset($options['text_domain']) ? $options['text_domain'] : 'default'); - $locale = (isset($options['locale']) ? $options['locale'] : null); + $textDomain = isset($options['text_domain']) ? $options['text_domain'] : 'default'; + $locale = isset($options['locale']) ? $options['locale'] : null; } - $path = ''; - $skip = true; + $path = ''; + $skip = true; $skippable = false; foreach ($parts as $part) { @@ -306,7 +305,7 @@ protected function buildPath(array $parts, array $mergedParams, $isOptional, $ha if (! isset($mergedParams[$part[1]])) { if (! $isOptional || $hasChild) { - throw new Exception\InvalidArgumentException(sprintf('Missing parameter "%s"', $part[1])); + throw new InvalidArgumentException(sprintf('Missing parameter "%s"', $part[1])); } return ''; @@ -318,18 +317,18 @@ protected function buildPath(array $parts, array $mergedParams, $isOptional, $ha $skip = false; } - $path .= $this->encode($mergedParams[$part[1]]); + $path .= $this->encode((string) $mergedParams[$part[1]]); $this->assembledParams[] = $part[1]; break; case 'optional': - $skippable = true; + $skippable = true; $optionalPart = $this->buildPath($part[1], $mergedParams, true, $hasChild, $options); if ($optionalPart !== '') { $path .= $optionalPart; - $skip = false; + $skip = false; } break; @@ -347,52 +346,42 @@ protected function buildPath(array $parts, array $mergedParams, $isOptional, $ha } /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @param string|null $pathOffset - * @param array $options - * @return RouteMatch|null - * @throws Exception\RuntimeException + * @throws InvalidArgumentException + * @throws RuntimeException */ - public function match(Request $request, $pathOffset = null, array $options = []) + public function partialMatch(Request $request, int $pathOffset = 0, array $options = []) : PartialRouteResult { - if (! method_exists($request, 'getUri')) { - return; + if ($pathOffset < 0) { + throw new InvalidArgumentException('Path offset cannot be negative'); } - - $uri = $request->getUri(); + $uri = $request->getUri(); $path = $uri->getPath(); $regex = $this->regex; if ($this->translationKeys) { if (! isset($options['translator']) || ! $options['translator'] instanceof Translator) { - throw new Exception\RuntimeException('No translator provided'); + throw new RuntimeException('No translator provided'); } $translator = $options['translator']; - $textDomain = (isset($options['text_domain']) ? $options['text_domain'] : 'default'); - $locale = (isset($options['locale']) ? $options['locale'] : null); + $textDomain = $options['text_domain'] ?? 'default'; + $locale = $options['locale'] ?? $options['parent_match_params'] ?? null; foreach ($this->translationKeys as $key) { $regex = str_replace('#' . $key . '#', $translator->translate($key, $textDomain, $locale), $regex); } } - if ($pathOffset !== null) { - $result = preg_match('(\G' . $regex . ')', $path, $matches, null, $pathOffset); - } else { - $result = preg_match('(^' . $regex . '$)', $path, $matches); - } + // needs to be urlencoded to match urlencoded non-latin characters + $result = preg_match('(\G' . $regex . ')', $path, $matches, 0, $pathOffset); if (! $result) { - return; + return PartialRouteResult::fromRouteFailure(); } $matchedLength = strlen($matches[0]); - $params = []; + $params = []; foreach ($this->paramMap as $index => $name) { if (isset($matches[$index]) && $matches[$index] !== '') { @@ -400,48 +389,43 @@ public function match(Request $request, $pathOffset = null, array $options = []) } } - return new RouteMatch(array_merge($this->defaults, $params), $matchedLength); + return PartialRouteResult::fromRouteMatch(array_merge($this->defaults, $params), $pathOffset, $matchedLength); } - /** - * assemble(): Defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = [], array $options = []) + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface { $this->assembledParams = []; - return $this->buildPath( + $path = $this->buildPath( $this->parts, array_merge($this->defaults, $params), false, - (isset($options['has_child']) ? $options['has_child'] : false), + isset($options['has_child']) ? $options['has_child'] : false, $options ); + + return $uri->withPath($uri->getPath() . $path); + } + + public function getLastAssembledParams() : array + { + return $this->assembledParams; } /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see RouteInterface::getAssembledParams - * @return array + * @deprecated */ - public function getAssembledParams() + public function getAssembledParams() : array { - return $this->assembledParams; + return $this->getLastAssembledParams(); } /** * Encode a path segment. * - * @param string $value - * @return string + * @todo replace with the version from diactoros */ - protected function encode($value) + protected function encode(string $value) : string { $key = (string) $value; if (! isset(static::$cacheEncode[$key])) { @@ -453,11 +437,8 @@ protected function encode($value) /** * Decode a path segment. - * - * @param string $value - * @return string */ - protected function decode($value) + protected function decode(string $value) : string { return rawurldecode($value); } diff --git a/src/RouteConfigFactory.php b/src/RouteConfigFactory.php new file mode 100644 index 0000000..fc0ab02 --- /dev/null +++ b/src/RouteConfigFactory.php @@ -0,0 +1,148 @@ +routes = $routes; + } + + /** + * Creates route or route tree from the provided spec + * + * @param array|string|RouteInterface $spec + * @throws RuntimeException + * @throws InvalidArgumentException + */ + public function routeFromSpec($spec, array $prototypes = []) : RouteInterface + { + if ($spec instanceof RouteInterface) { + return $spec; + } + + if (is_string($spec)) { + $route = $prototypes[$spec] ?? null; + if (null === $route) { + throw new RuntimeException(sprintf('Could not find prototype with name %s', $spec)); + } + if (! $route instanceof RouteInterface) { + throw new RuntimeException(sprintf( + 'Invalid prototype provided. Expected %s got %s', + RouteInterface::class, + is_object($route) ? get_class($route) : gettype($route) + )); + } + + return $route; + } + + if (! is_array($spec)) { + throw new InvalidArgumentException('Route definition must be an array'); + } + + if (isset($spec['chain_routes'])) { + $route = $this->createChainFromSpec($spec, $prototypes); + } else { + if (! isset($spec['type'])) { + throw new InvalidArgumentException('Missing "type" option'); + } + + if (! isset($spec['options'])) { + $spec['options'] = []; + } + + $route = $this->routes->build($spec['type'], $spec['options']); + + if (isset($spec['priority'])) { + $route->priority = $spec['priority']; + } + } + + if (isset($spec['child_routes'])) { + $route = $this->createPartFromSpec($spec, $route, $prototypes); + } + + return $route; + } + + /** + * Wraps route in spec with Chain route, adds chain_routes to chain + * + * @throws InvalidArgumentException + */ + private function createChainFromSpec(array $specs, array $prototypes) : RouteInterface + { + if (! is_array($specs['chain_routes'])) { + throw new InvalidArgumentException('Chain routes must be an array'); + } + + $chainRoutesSpec = $specs['chain_routes']; + + $route = $specs; + unset($route['chain_routes']); + unset($route['child_routes']); + + array_unshift($chainRoutesSpec, $route); + + $chainRoutes = []; + foreach ($chainRoutesSpec as $name => $routeSpec) { + $chainRoutes[$name] = $this->routeFromSpec($routeSpec, $prototypes); + } + + $options = [ + 'routes' => $chainRoutes, + ]; + + return $this->routes->build('chain', $options); + } + + /** + * Wraps route in spec with Part route and adds child_routes to Part + */ + private function createPartFromSpec(array $specs, RouteInterface $route, array $prototypes) : RouteInterface + { + $childRoutes = []; + foreach ($specs['child_routes'] as $name => $childSpec) { + $childRoutes[$name] = $this->routeFromSpec($childSpec, $prototypes); + } + $options = [ + 'route' => $route, + 'may_terminate' => $specs['may_terminate'] ?? false, + 'child_routes' => $childRoutes, + ]; + + $priority = isset($route->priority) ? $route->priority : null; + + $route = $this->routes->build('part', $options); + if (isset($priority)) { + $route->priority = $priority; + } + + return $route; + } +} diff --git a/src/RouteInterface.php b/src/RouteInterface.php index 72266e0..03c9831 100644 --- a/src/RouteInterface.php +++ b/src/RouteInterface.php @@ -1,48 +1,29 @@ canCreate($container, $routeName); + return true; } /** * Create and return a RouteInterface instance. * - * If the specified $routeName class does not exist or does not implement - * RouteInterface, this method will raise an exception. + * If the specified $routeName class does not exist, does not implement + * RouteInterface or does not provide factory method, this method will raise an exception. * * Otherwise, it uses the class' `factory()` method with the provided * $options to produce an instance. * - * @param ContainerInterface $container * @param string $routeName - * @param null|array $options - * @return RouteInterface + * @throws ServiceNotCreatedException */ - public function __invoke(ContainerInterface $container, $routeName, array $options = null) + public function __invoke(ContainerInterface $container, $routeName, array $options = null) : RouteInterface { $options = $options ?: []; @@ -102,45 +84,14 @@ public function __invoke(ContainerInterface $container, $routeName, array $optio )); } - return $routeName::factory($options); - } - - /** - * Create a route instance with the given name. (v2) - * - * Proxies to __invoke(). - * - * @param ServiceLocatorInterface $container - * @param string $normalizedName - * @param string $routeName - * @return RouteInterface - */ - public function createServiceWithName(ServiceLocatorInterface $container, $normalizedName, $routeName) - { - return $this($container, $routeName, $this->creationOptions); - } - - /** - * Create and return RouteInterface instance - * - * For use with zend-servicemanager v2; proxies to __invoke(). - * - * @param ServiceLocatorInterface $container - * @return RouteInterface - */ - public function createService(ServiceLocatorInterface $container, $normalizedName = null, $routeName = null) - { - $routeName = $routeName ?: RouteInterface::class; - return $this($container, $routeName, $this->creationOptions); - } + if (! method_exists($routeName, 'factory')) { + throw new ServiceNotCreatedException(sprintf( + '%s: failed retrieving invokable class "%s"; class does not provide factory method', + __CLASS__, + $routeName + )); + } - /** - * Set options to use when creating a service (v2) - * - * @param array $creationOptions - */ - public function setCreationOptions(array $creationOptions) - { - $this->creationOptions = $creationOptions; + return $routeName::factory($options); } } diff --git a/src/RouteMatch.php b/src/RouteMatch.php index 471c101..8dcc7a9 100644 --- a/src/RouteMatch.php +++ b/src/RouteMatch.php @@ -1,14 +1,22 @@ params = $params; } + /** + * @throws RuntimeException + */ + public static function fromRouteResult(RouteResult $result) : self + { + if (! $result->isSuccess()) { + throw new RuntimeException('Route match cannot be created from failure route result'); + } + $match = new static($result->getMatchedParams()); + $match->setMatchedRouteName($result->getMatchedRouteName()); + + return $match; + } + /** * Set name of matched route. * - * @param string $name - * @return RouteMatch + * @param string $name + * @return $this */ public function setMatchedRouteName($name) { @@ -61,9 +81,9 @@ public function getMatchedRouteName() /** * Set a parameter. * - * @param string $name - * @param mixed $value - * @return RouteMatch + * @param string $name + * @param mixed $value + * @return $this */ public function setParam($name, $value) { @@ -84,8 +104,8 @@ public function getParams() /** * Get a specific parameter. * - * @param string $name - * @param mixed $default + * @param string $name + * @param mixed $default * @return mixed */ public function getParam($name, $default = null) diff --git a/src/RoutePluginManager.php b/src/RoutePluginManager.php index 6f42149..038d2e8 100644 --- a/src/RoutePluginManager.php +++ b/src/RoutePluginManager.php @@ -1,15 +1,16 @@ Route\Chain::class, + 'Chain' => Route\Chain::class, + 'hostname' => Route\Hostname::class, + 'Hostname' => Route\Hostname::class, + 'literal' => Route\Literal::class, + 'Literal' => Route\Literal::class, + 'method' => Route\Method::class, + 'Method' => Route\Method::class, + 'part' => Route\Part::class, + 'Part' => Route\Part::class, + 'regex' => Route\Regex::class, + 'Regex' => Route\Regex::class, + 'scheme' => Route\Scheme::class, + 'Scheme' => Route\Scheme::class, + 'segment' => Route\Segment::class, + 'Segment' => Route\Segment::class, + 'Zend\Router\Http\Chain' => Route\Chain::class, + 'Zend\Router\Http\Hostname' => Route\Hostname::class, + 'Zend\Router\Http\Literal' => Route\Literal::class, + 'Zend\Router\Http\Method' => Route\Method::class, + 'Zend\Router\Http\Part' => Route\Part::class, + 'Zend\Router\Http\Regex' => Route\Regex::class, + 'Zend\Router\Http\Scheme' => Route\Scheme::class, + 'Zend\Router\Http\Segment' => Route\Segment::class, + ]; + + /** + * @var array */ - protected $sharedByDefault = false; + protected $factories = [ + Route\Chain::class => RouteInvokableFactory::class, + Route\Hostname::class => RouteInvokableFactory::class, + Route\Literal::class => RouteInvokableFactory::class, + Route\Method::class => RouteInvokableFactory::class, + Route\Part::class => RouteInvokableFactory::class, + Route\Regex::class => RouteInvokableFactory::class, + Route\Scheme::class => RouteInvokableFactory::class, + Route\Segment::class => RouteInvokableFactory::class, + ]; /** * Constructor @@ -51,125 +89,10 @@ class RoutePluginManager extends AbstractPluginManager * abstract factory. * * @param ContainerInterface|\Zend\ServiceManager\ConfigInterface $configOrContainerInstance - * @param array $v3config */ - public function __construct($configOrContainerInstance, array $v3config = []) + public function __construct($configOrContainerInstance, array $config = []) { $this->addAbstractFactory(RouteInvokableFactory::class); - parent::__construct($configOrContainerInstance, $v3config); - } - - /** - * Validate a route plugin. (v2) - * - * @param object $plugin - * @throws InvalidServiceException - */ - public function validate($plugin) - { - if (! $plugin instanceof $this->instanceOf) { - throw new InvalidServiceException(sprintf( - 'Plugin of type %s is invalid; must implement %s', - (is_object($plugin) ? get_class($plugin) : gettype($plugin)), - RouteInterface::class - )); - } - } - - /** - * Validate a route plugin. (v2) - * - * @param object $plugin - * @throws Exception\RuntimeException - */ - public function validatePlugin($plugin) - { - try { - $this->validate($plugin); - } catch (InvalidServiceException $e) { - throw new Exception\RuntimeException( - $e->getMessage(), - $e->getCode(), - $e - ); - } - } - - /** - * Pre-process configuration. (v3) - * - * Checks for invokables, and, if found, maps them to the - * component-specific RouteInvokableFactory; removes the invokables entry - * before passing to the parent. - * - * @param array $config - * @return void - */ - public function configure(array $config) - { - if (isset($config['invokables']) && ! empty($config['invokables'])) { - $aliases = $this->createAliasesForInvokables($config['invokables']); - $factories = $this->createFactoriesForInvokables($config['invokables']); - - if (! empty($aliases)) { - $config['aliases'] = isset($config['aliases']) - ? array_merge($config['aliases'], $aliases) - : $aliases; - } - - $config['factories'] = isset($config['factories']) - ? array_merge($config['factories'], $factories) - : $factories; - - unset($config['invokables']); - } - - parent::configure($config); - } - - /** - * Create aliases for invokable classes. - * - * If an invokable service name does not match the class it maps to, this - * creates an alias to the class (which will later be mapped as an - * invokable factory). - * - * @param array $invokables - * @return array - */ - protected function createAliasesForInvokables(array $invokables) - { - $aliases = []; - foreach ($invokables as $name => $class) { - if ($name === $class) { - continue; - } - $aliases[$name] = $class; - } - return $aliases; - } - - /** - * Create invokable factories for invokable classes. - * - * If an invokable service name does not match the class it maps to, this - * creates an invokable factory entry for the class name; otherwise, it - * creates an invokable factory for the entry name. - * - * @param array $invokables - * @return array - */ - protected function createFactoriesForInvokables(array $invokables) - { - $factories = []; - foreach ($invokables as $name => $class) { - if ($name === $class) { - $factories[$name] = RouteInvokableFactory::class; - continue; - } - - $factories[$class] = RouteInvokableFactory::class; - } - return $factories; + parent::__construct($configOrContainerInstance, $config); } } diff --git a/src/RoutePluginManagerFactory.php b/src/RoutePluginManagerFactory.php deleted file mode 100644 index 1a4197b..0000000 --- a/src/RoutePluginManagerFactory.php +++ /dev/null @@ -1,42 +0,0 @@ -success = true; + $result->matchedParams = $matchedParams; + $result->matchedRouteName = $routeName; + return $result; + } + + /** + * Create failed routing result + */ + public static function fromRouteFailure() : self + { + $result = new self(); + $result->success = false; + return $result; + } + + /** + * Create routing failure result where http method is not allowed for the + * otherwise routable request + * + * @throws DomainException + */ + public static function fromMethodFailure(array $allowedMethods) : self + { + if (empty($allowedMethods)) { + throw new DomainException('Method failure requires list of allowed methods'); + } + $result = new self(); + $result->success = false; + $result->setAllowedMethods($allowedMethods); + return $result; + } + + /** + * Is this a routing success result? + */ + public function isSuccess() : bool + { + return $this->success; + } + + /** + * Is this a routing failure result? + */ + public function isFailure() : bool + { + return ! $this->success; + } + + /** + * Is this a result for failed routing due to HTTP method? + */ + public function isMethodFailure() : bool + { + if ($this->isSuccess() || empty($this->allowedMethods)) { + return false; + } + return true; + } + + /** + * Produce a new route result with provided route name. Can only be used + * with successful result. + * + * @param string $flag Signifies mode of setting route name: + * - {@see RouteResult::NAME_REPLACE} replaces existing route name + * - {@see RouteResult::NAME_PREPEND} prepends as a parent route part name. + * - {@see RouteResult::NAME_APPEND} appends as a child route part name. + * @throws DomainException + * @throws RuntimeException + */ + public function withMatchedRouteName(string $routeName, $flag = self::NAME_REPLACE) : self + { + if (empty($routeName)) { + throw new DomainException('Route name cannot be empty'); + } + if (! $this->isSuccess()) { + throw new RuntimeException('Only successful routing can have matched route name'); + } + $result = clone $this; + + // If no matched route name is set, simply replace value + if ($flag === self::NAME_REPLACE || $this->matchedRouteName === null) { + $result->matchedRouteName = $routeName; + return $result; + } + + if ($flag === self::NAME_PREPEND) { + $routeName = sprintf('%s/%s', $routeName, $this->matchedRouteName); + } elseif ($flag === self::NAME_APPEND) { + $routeName = sprintf('%s/%s', $this->matchedRouteName, $routeName); + } else { + throw new DomainException('Unknown flag for setting matched route name'); + } + $result->matchedRouteName = $routeName; + + return $result; + } + + /** + * Produce a new route result with provided matched parameters. Can only be + * used with successful result. + * + * @throws RuntimeException + */ + public function withMatchedParams(array $params) : self + { + if (! $this->isSuccess()) { + throw new RuntimeException('Only successful routing can have matched params'); + } + $result = clone $this; + $result->matchedParams = $params; + return $result; + } + + /** + * Matched route name on successful routing. + * Can be null. Route name is normally set by the route stack and can differ + * for same route instance if it is used in several places. + */ + public function getMatchedRouteName() : ?string + { + return $this->matchedRouteName; + } + + /** + * Matched parameters on successful routing + */ + public function getMatchedParams() : array + { + return $this->matchedParams; + } + + /** + * Returns list of allowed methods on method failure. + */ + public function getAllowedMethods() : array + { + return $this->allowedMethods; + } + + /** + * Helper function to deduplicate and normalize HTTP method names + */ + private function setAllowedMethods(array $methods) : void + { + $methods = array_keys(array_change_key_case( + array_flip($methods), + CASE_UPPER + )); + $this->allowedMethods = $methods; + } + + /** + * Disallow new-ing route result + */ + private function __construct() + { + } +} diff --git a/src/RouteStackInterface.php b/src/RouteStackInterface.php index 8d90dd3..d57a752 100644 --- a/src/RouteStackInterface.php +++ b/src/RouteStackInterface.php @@ -1,45 +1,48 @@ routeFactory = $routeFactory; + $this->routeStack = $routeStack; + $this->uriFactory = $uriFactory; + } + + public function getRouteFactory() : RouteConfigFactory + { + return $this->routeFactory; + } + + public function setRouteStack(RouteStackInterface $routeStack) : void + { + $this->routeStack = $routeStack; + } + + public function getRouteStack() : RouteStackInterface + { + return $this->routeStack; + } + + /** + * Add route to the underlying route stack. + * + * @param array|string|RouteInterface $routeOrSpec Route instance, array + * specification or string name of prototype route to add + */ + public function addRoute(string $name, $routeOrSpec) : void + { + $this->routeStack->addRoute($name, $this->routeFactory->routeFromSpec($routeOrSpec, $this->prototypes)); + } + + /** + * Add reusable "prototype" route to be used when adding routers as + * array or string specification + * + * @param array|RouteInterface $routeOrSpec + */ + public function addPrototype(string $name, $routeOrSpec) : void + { + $this->prototypes[$name] = $this->routeFactory->routeFromSpec($routeOrSpec); + } + + /** + * Get reusable "prototype" route + */ + public function getPrototype(string $name) : ?RouteInterface + { + return $this->prototypes[$name] ?? null; + } + + /** + * Get registered "prototype" routes + */ + public function getPrototypes() : array + { + return $this->prototypes; + } + + /** + * Match request using configured route stack + */ + public function match(Request $request) : RouteResult + { + return $this->routeStack->match($request, 0); + } + + /** + * Assemble uri using configured route stack + */ + public function assemble(string $name, array $params, array $options = []) : UriInterface + { + $options['name'] = $name; + $uri = ($this->uriFactory)(); + return $this->routeStack->assemble($uri, $params, $options); + } +} diff --git a/src/RouterConfigTrait.php b/src/RouterConfigTrait.php deleted file mode 100644 index 7420d84..0000000 --- a/src/RouterConfigTrait.php +++ /dev/null @@ -1,38 +0,0 @@ -get('RoutePluginManager'); - $config['route_plugins'] = $routePluginManager; - } - - // Obtain an instance - $factory = sprintf('%s::factory', $class); - return call_user_func($factory, $config); - } -} diff --git a/src/RouterFactory.php b/src/RouterFactory.php deleted file mode 100644 index 5fdd529..0000000 --- a/src/RouterFactory.php +++ /dev/null @@ -1,46 +0,0 @@ -get('HttpRouter'); - } - - /** - * Create and return RouteStackInterface instance - * - * For use with zend-servicemanager v2; proxies to __invoke(). - * - * @param ServiceLocatorInterface $container - * @param null|string $normalizedName - * @param null|string $requestedName - * @return RouteStackInterface - */ - public function createService(ServiceLocatorInterface $container, $normalizedName = null, $requestedName = null) - { - $requestedName = $requestedName ?: 'Router'; - return $this($container, $requestedName); - } -} diff --git a/src/SimpleRouteStack.php b/src/SimpleRouteStack.php index 8175064..959b679 100644 --- a/src/SimpleRouteStack.php +++ b/src/SimpleRouteStack.php @@ -1,16 +1,22 @@ routes = new PriorityList(); - - if (null === $routePluginManager) { - $routePluginManager = new RoutePluginManager(new ServiceManager()); - } - - $this->routePluginManager = $routePluginManager; - - $this->init(); - } - - /** - * factory(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::factory() - * @param array|Traversable $options - * @return SimpleRouteStack - * @throws Exception\InvalidArgumentException - */ - public static function factory($options = []) - { - if ($options instanceof Traversable) { - $options = ArrayUtils::iteratorToArray($options); - } elseif (! is_array($options)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects an array or Traversable set of options', - __METHOD__ - )); - } - - $routePluginManager = null; - if (isset($options['route_plugins'])) { - $routePluginManager = $options['route_plugins']; - } - - $instance = new static($routePluginManager); - - if (isset($options['routes'])) { - $instance->addRoutes($options['routes']); - } - - if (isset($options['default_params'])) { - $instance->setDefaultParams($options['default_params']); - } - - return $instance; } - /** - * Init method for extending classes. - * - * @return void - */ - protected function init() + public function addRoutes(iterable $routes) : void { - } - - /** - * Set the route plugin manager. - * - * @param RoutePluginManager $routePlugins - * @return SimpleRouteStack - */ - public function setRoutePluginManager(RoutePluginManager $routePlugins) - { - $this->routePluginManager = $routePlugins; - return $this; - } - - /** - * Get the route plugin manager. - * - * @return RoutePluginManager - */ - public function getRoutePluginManager() - { - return $this->routePluginManager; - } - - /** - * addRoutes(): defined by RouteStackInterface interface. - * - * @see RouteStackInterface::addRoutes() - * @param array|Traversable $routes - * @return SimpleRouteStack - * @throws Exception\InvalidArgumentException - */ - public function addRoutes($routes) - { - if (! is_array($routes) && ! $routes instanceof Traversable) { - throw new Exception\InvalidArgumentException('addRoutes expects an array or Traversable set of routes'); - } - foreach ($routes as $name => $route) { $this->addRoute($name, $route); } - - return $this; } - /** - * addRoute(): defined by RouteStackInterface interface. - * - * @see RouteStackInterface::addRoute() - * @param string $name - * @param mixed $route - * @param int $priority - * @return SimpleRouteStack - */ - public function addRoute($name, $route, $priority = null) + public function addRoute(string $name, RouteInterface $route, int $priority = null) : void { - if (! $route instanceof RouteInterface) { - $route = $this->routeFromArray($route); - } - if ($priority === null && isset($route->priority)) { $priority = $route->priority; } $this->routes->insert($name, $route, $priority); - - return $this; } - /** - * removeRoute(): defined by RouteStackInterface interface. - * - * @see RouteStackInterface::removeRoute() - * @param string $name - * @return SimpleRouteStack - */ - public function removeRoute($name) + public function removeRoute(string $name) : void { $this->routes->remove($name); - return $this; } - /** - * setRoutes(): defined by RouteStackInterface interface. - * - * @param array|Traversable $routes - * @return SimpleRouteStack - */ - public function setRoutes($routes) + public function setRoutes(iterable $routes) : void { $this->routes->clear(); $this->addRoutes($routes); - return $this; } - /** - * Get the added routes - * - * @return Traversable list of all routes - */ - public function getRoutes() + public function getRoutes() : array { - return $this->routes; + return $this->routes->toArray($this->routes::EXTR_DATA); } - /** - * Check if a route with a specific name exists - * - * @param string $name - * @return bool true if route exists - */ - public function hasRoute($name) + public function hasRoute(string $name) : bool { return $this->routes->get($name) !== null; } - /** - * Get a route by name - * - * @param string $name - * @return RouteInterface the route - */ - public function getRoute($name) + public function getRoute(string $name) : ?RouteInterface { return $this->routes->get($name); } - /** - * Set a default parameters. - * - * @param array $params - * @return SimpleRouteStack - */ - public function setDefaultParams(array $params) + public function setDefaultParams(array $params) : void { $this->defaultParams = $params; - return $this; } /** * Set a default parameter. * - * @param string $name - * @param mixed $value - * @return SimpleRouteStack + * @param mixed $value */ - public function setDefaultParam($name, $value) + public function setDefaultParam(string $name, $value) : void { $this->defaultParams[$name] = $value; - return $this; } - /** - * Create a route from array specifications. - * - * @param array|Traversable $specs - * @return RouteInterface - * @throws Exception\InvalidArgumentException - */ - protected function routeFromArray($specs) - { - if ($specs instanceof Traversable) { - $specs = ArrayUtils::iteratorToArray($specs); - } - - if (! is_array($specs)) { - throw new Exception\InvalidArgumentException('Route definition must be an array or Traversable object'); - } - - if (! isset($specs['type'])) { - throw new Exception\InvalidArgumentException('Missing "type" option'); - } - - if (! isset($specs['options'])) { - $specs['options'] = []; - } - - $route = $this->getRoutePluginManager()->get($specs['type'], $specs['options']); - - if (isset($specs['priority'])) { - $route->priority = $specs['priority']; - } - - return $route; - } - - /** - * match(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::match() - * @param Request $request - * @return RouteMatch|null - */ - public function match(Request $request) + public function match(Request $request, int $pathOffset = 0, array $options = []) : RouteResult { + $methodFailureResults = []; foreach ($this->routes as $name => $route) { - if (($match = $route->match($request)) instanceof RouteMatch) { - $match->setMatchedRouteName($name); - - foreach ($this->defaultParams as $paramName => $value) { - if ($match->getParam($paramName) === null) { - $match->setParam($paramName, $value); - } - } - - return $match; + /** @var RouteInterface $route */ + $result = $route->match($request, $pathOffset, $options); + if ($result->isSuccess()) { + $result = $result->withMatchedRouteName($name); + $result = $result->withMatchedParams( + array_merge($this->defaultParams, $result->getMatchedParams()) + ); + return $result; + } + if ($result->isMethodFailure()) { + $methodFailureResults[] = $result; } } - return; + if (! empty($methodFailureResults)) { + $allowedMethods = array_reduce($methodFailureResults, function (array $methods, RouteResult $result) { + return $methods + $result->getAllowedMethods(); + }, []); + return RouteResult::fromMethodFailure($allowedMethods); + } + return RouteResult::fromRouteFailure(); } /** - * assemble(): defined by RouteInterface interface. - * - * @see \Zend\Router\RouteInterface::assemble() - * @param array $params - * @param array $options - * @return mixed - * @throws Exception\InvalidArgumentException - * @throws Exception\RuntimeException + * @throws InvalidArgumentException + * @throws RuntimeException */ - public function assemble(array $params = [], array $options = []) + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface { if (! isset($options['name'])) { - throw new Exception\InvalidArgumentException('Missing "name" option'); + throw new InvalidArgumentException('Missing "name" option'); } $route = $this->routes->get($options['name']); if (! $route) { - throw new Exception\RuntimeException(sprintf('Route with name "%s" not found', $options['name'])); + throw new RuntimeException(sprintf('Route with name "%s" not found', $options['name'])); } unset($options['name']); - return $route->assemble(array_merge($this->defaultParams, $params), $options); + return $route->assemble($uri, array_merge($this->defaultParams, $params), $options); } } diff --git a/src/TranslatorAwareRouteStackDecorator.php b/src/TranslatorAwareRouteStackDecorator.php new file mode 100644 index 0000000..d50994d --- /dev/null +++ b/src/TranslatorAwareRouteStackDecorator.php @@ -0,0 +1,196 @@ +decoratedRouteStack = $decoratedRouteStack; + $this->setTranslator($translator); + } + + public function getDecoratedRouteStack() : RouteStackInterface + { + return $this->decoratedRouteStack; + } + + /** + * @param null|string $textDomain + */ + public function setTranslator(Translator $translator = null, $textDomain = null) : TranslatorAwareInterface + { + $this->translator = $translator; + + if ($textDomain !== null) { + $this->setTranslatorTextDomain($textDomain); + } + + return $this; + } + + public function getTranslator() : ?TranslatorInterface + { + return $this->translator; + } + + public function hasTranslator() : bool + { + return $this->translator !== null; + } + + /** + * @param bool $enabled + */ + public function setTranslatorEnabled($enabled = true) : TranslatorAwareInterface + { + $this->translatorEnabled = $enabled; + return $this; + } + + public function isTranslatorEnabled() : bool + { + return $this->translatorEnabled; + } + + /** + * @param string $textDomain + */ + public function setTranslatorTextDomain($textDomain = 'default') : TranslatorAwareInterface + { + $this->translatorTextDomain = $textDomain; + + return $this; + } + + public function getTranslatorTextDomain() : string + { + return $this->translatorTextDomain; + } + + /** + * Match a given request. + */ + public function match(Request $request, int $pathOffset = 0, array $options = []) : RouteResult + { + // translator always present + if ($this->isTranslatorEnabled() && ! isset($options['translator'])) { + $options['translator'] = $this->getTranslator(); + } + + if ($this->isTranslatorEnabled() && ! isset($options['text_domain'])) { + $options['text_domain'] = $this->getTranslatorTextDomain(); + } + return $this->decoratedRouteStack->match($request, $pathOffset, $options); + } + + /** + * Assemble the route. + */ + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + if ($this->isTranslatorEnabled() && ! isset($options['translator'])) { + $options['translator'] = $this->getTranslator(); + } + + if ($this->isTranslatorEnabled() && ! isset($options['text_domain'])) { + $options['text_domain'] = $this->getTranslatorTextDomain(); + } + return $this->decoratedRouteStack->assemble($uri, $params, $options); + } + + /** + * Add a route to the stack. + */ + public function addRoute(string $name, RouteInterface $route, int $priority = null) : void + { + $this->decoratedRouteStack->addRoute($name, $route, $priority); + } + + /** + * Add multiple routes to the stack. + */ + public function addRoutes(iterable $routes) : void + { + $this->decoratedRouteStack->addRoutes($routes); + } + + /** + * Remove a route from the stack. + */ + public function removeRoute(string $name) : void + { + $this->decoratedRouteStack->removeRoute($name); + } + + /** + * Remove all routes from the stack and set new ones. + */ + public function setRoutes(iterable $routes) : void + { + $this->decoratedRouteStack->setRoutes($routes); + } + + /** + * Get the added routes + */ + public function getRoutes() : array + { + return $this->decoratedRouteStack->getRoutes(); + } + + /** + * Check if a route with a specific name exists + */ + public function hasRoute(string $name) : bool + { + return $this->decoratedRouteStack->hasRoute($name); + } + + /** + * Get a route by name + */ + public function getRoute(string $name) : ?RouteInterface + { + return $this->decoratedRouteStack->getRoute($name); + } +} diff --git a/src/TreeRouteStack.php b/src/TreeRouteStack.php new file mode 100644 index 0000000..c161584 --- /dev/null +++ b/src/TreeRouteStack.php @@ -0,0 +1,83 @@ +routes as $name => $route) { + /** @var RouteInterface $route */ + $result = $route->match($request, $pathOffset, $options); + if ($result->isSuccess()) { + $result = $result->withMatchedRouteName($name, RouteResult::NAME_PREPEND); + $result = $result->withMatchedParams( + array_merge($this->defaultParams, $result->getMatchedParams()) + ); + return $result; + } + if ($result->isMethodFailure()) { + $options[Method::OPTION_FORCE_METHOD_FAILURE] = true; + $allowedMethods = array_merge($allowedMethods, $result->getAllowedMethods()); + } + } + + if (! empty($allowedMethods)) { + return RouteResult::fromMethodFailure($allowedMethods); + } + return RouteResult::fromRouteFailure(); + } + + /** + * @throws InvalidArgumentException + * @throws RuntimeException + */ + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + if (! isset($options['name'])) { + throw new InvalidArgumentException('Missing "name" option'); + } + + $names = explode('/', $options['name'], 2); + $route = $this->routes->get($names[0]); + + if (! $route) { + throw new RuntimeException(sprintf('Route with name "%s" not found', $names[0])); + } + + if (isset($names[1])) { + if (! $route instanceof RouteStackInterface) { + throw new RuntimeException(sprintf( + 'Route with name "%s" does not have child routes', + $names[0] + )); + } + $options['name'] = $names[1]; + } else { + unset($options['name']); + } + + return $route->assemble($uri, array_merge($this->defaultParams, $params), $options); + } +} diff --git a/test/Container/RouteConfigFactoryFactoryTest.php b/test/Container/RouteConfigFactoryFactoryTest.php new file mode 100644 index 0000000..275cb18 --- /dev/null +++ b/test/Container/RouteConfigFactoryFactoryTest.php @@ -0,0 +1,34 @@ +prophesize(ContainerInterface::class); + $container->get(RoutePluginManager::class) + ->willReturn(new RoutePluginManager(new ServiceManager())) + ->shouldBeCalled(); + $factory = new RouteConfigFactoryFactory(); + $service = $factory->__invoke($container->reveal()); + $this->assertInstanceOf(RouteConfigFactory::class, $service); + } +} diff --git a/test/Container/RoutePluginManagerFactoryTest.php b/test/Container/RoutePluginManagerFactoryTest.php new file mode 100644 index 0000000..17a9dfa --- /dev/null +++ b/test/Container/RoutePluginManagerFactoryTest.php @@ -0,0 +1,83 @@ +container = $this->prophesize(ContainerInterface::class); + $this->factory = new RoutePluginManagerFactory(); + } + + public function testInvocationReturnsAPluginManager() + { + $plugins = $this->factory->__invoke($this->container->reveal(), RoutePluginManager::class); + $this->assertInstanceOf(RoutePluginManager::class, $plugins); + } + + public function testUsesRouteManagerConfigFromContainerWhenProvided() + { + $route = new Literal('/'); + $factory = function () use ($route) { + return $route; + }; + $this->container->has('config')->willReturn(true); + $this->container->get('config')->willReturn([ + RoutePluginManager::class => [ + 'factories' => [ + 'test' => $factory, + ], + ], + ]); + $routes = $this->factory->__invoke($this->container->reveal(), RoutePluginManager::class); + $this->assertSame($route, $routes->get('test')); + } + + public function testInvocationCanProvideOptionsToThePluginManager() + { + $options = [ + 'factories' => [ + 'TestRoute' => function ($container) { + return $this->prophesize(RouteInterface::class)->reveal(); + }, + ], + ]; + $plugins = $this->factory->__invoke( + $this->container->reveal(), + RoutePluginManager::class, + $options + ); + $this->assertInstanceOf(RoutePluginManager::class, $plugins); + $route = $plugins->get('TestRoute'); + $this->assertInstanceOf(RouteInterface::class, $route); + } +} diff --git a/test/Container/RouterFactoryTest.php b/test/Container/RouterFactoryTest.php new file mode 100644 index 0000000..0ddf412 --- /dev/null +++ b/test/Container/RouterFactoryTest.php @@ -0,0 +1,109 @@ +container = $this->prophesize(ContainerInterface::class); + $this->container->get(RouteConfigFactory::class) + ->willReturn($routeFactory); + $this->container->get(UriInterface::class) + ->willReturn($uriFactory); + } + + public function testGetRouteStackInstantiatesTreeRouteStack() + { + $factory = new RouterFactory(); + $routeStack = $factory->getRouteStack($this->container->reveal()); + $this->assertInstanceOf(TreeRouteStack::class, $routeStack); + } + + public function testConfigureRouterUsesConfigProvidedByGetRouterConfig() + { + $factory = new class() extends RouterFactory { + public function getRouterConfig(ContainerInterface $container) : array + { + return [ + 'routes' => [ + 'test-route' => new Literal('/'), + 'test-prototype' => 'prototype', + ], + 'prototypes' => [ + 'prototype' => new Method('GET'), + ], + ]; + } + }; + $router = $factory->__invoke($this->container->reveal()); + $this->assertInstanceOf( + Method::class, + $router->getPrototype('prototype') + ); + $this->assertInstanceOf( + Literal::class, + $router->getRouteStack()->getRoute('test-route') + ); + $this->assertInstanceOf( + Method::class, + $router->getRouteStack()->getRoute('test-prototype') + ); + } + + public function testCreatesRouterWithRouteStackReturnedByGetRouteStack() + { + $factory = new class() extends RouterFactory { + public function getRouteStack(ContainerInterface $container) : RouteStackInterface + { + return new SimpleRouteStack(); + } + }; + + $router = $factory->__invoke($this->container->reveal()); + $this->assertInstanceOf(SimpleRouteStack::class, $router->getRouteStack()); + } + + public function testGetRouterConfigReturnsDefaultEmptyConfig() + { + $this->container->get(Argument::any())->shouldNotBeCalled(); + $factory = new RouterFactory(); + $config = $factory->getRouterConfig($this->container->reveal()); + $this->assertEquals(['routes' => [], 'prototypes' => []], $config); + } +} diff --git a/test/FactoryTester.php b/test/FactoryTester.php index f3f600e..2d32eb0 100644 --- a/test/FactoryTester.php +++ b/test/FactoryTester.php @@ -1,10 +1,12 @@ testCase->fail('An expected exception was not thrown'); - } catch (\Zend\Router\Exception\InvalidArgumentException $e) { - $this->testCase->assertContains('factory expects an array or Traversable set of options', $e->getMessage()); - } - // Test required options. foreach ($requiredOptions as $option => $exceptionMessage) { $testOptions = $options; diff --git a/test/Http/ChainTest.php b/test/Http/ChainTest.php deleted file mode 100644 index 799fc72..0000000 --- a/test/Http/ChainTest.php +++ /dev/null @@ -1,200 +0,0 @@ - Segment::class, - 'options' => [ - 'route' => '/:controller', - 'defaults' => [ - 'controller' => 'foo', - ], - ], - ], - [ - 'type' => Segment::class, - 'options' => [ - 'route' => '/:bar', - 'defaults' => [ - 'bar' => 'bar', - ], - ], - ], - [ - 'type' => Wildcard::class, - ], - ], - $routePlugins - ); - } - - public static function getRouteWithOptionalParam() - { - $routePlugins = new RoutePluginManager(new ServiceManager()); - - return new Chain( - [ - [ - 'type' => Segment::class, - 'options' => [ - 'route' => '/:controller', - 'defaults' => [ - 'controller' => 'foo', - ], - ], - ], - [ - 'type' => Segment::class, - 'options' => [ - 'route' => '[/:bar]', - 'defaults' => [ - 'bar' => 'bar', - ], - ], - ], - ], - $routePlugins - ); - } - - public static function routeProvider() - { - return [ - 'simple-match' => [ - self::getRoute(), - '/foo/bar', - null, - [ - 'controller' => 'foo', - 'bar' => 'bar', - ], - ], - 'offset-skips-beginning' => [ - self::getRoute(), - '/baz/foo/bar', - 4, - [ - 'controller' => 'foo', - 'bar' => 'bar', - ], - ], - 'parameters-are-used-only-once' => [ - self::getRoute(), - '/foo/baz', - null, - [ - 'controller' => 'foo', - 'bar' => 'baz', - ], - ], - 'optional-parameter' => [ - self::getRouteWithOptionalParam(), - '/foo/baz', - null, - [ - 'controller' => 'foo', - 'bar' => 'baz', - ], - ], - 'optional-parameter-empty' => [ - self::getRouteWithOptionalParam(), - '/foo', - null, - [ - 'controller' => 'foo', - 'bar' => 'bar', - ], - ], - ]; - } - - /** - * @dataProvider routeProvider - * @param Chain $route - * @param string $path - * @param int $offset - * @param array $params - */ - public function testMatching(Chain $route, $path, $offset, array $params = null) - { - $request = new Request(); - $request->setUri('http://example.com' . $path); - $match = $route->match($request, $offset); - - if ($params === null) { - $this->assertNull($match); - } else { - $this->assertInstanceOf(RouteMatch::class, $match); - - if ($offset === null) { - $this->assertEquals(strlen($path), $match->getLength()); - } - - foreach ($params as $key => $value) { - $this->assertEquals($value, $match->getParam($key)); - } - } - } - - /** - * @dataProvider routeProvider - * @param Chain $route - * @param string $path - * @param int $offset - * @param array $params - */ - public function testAssembling(Chain $route, $path, $offset, array $params = null) - { - if ($params === null) { - // Data which will not match are not tested for assembling. - return; - } - - $result = $route->assemble($params); - - if ($offset !== null) { - $this->assertEquals($offset, strpos($path, $result, $offset)); - } else { - $this->assertEquals($path, $result); - } - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - Chain::class, - [ - 'routes' => 'Missing "routes" in options array', - 'route_plugins' => 'Missing "route_plugins" in options array', - ], - [ - 'routes' => [], - 'route_plugins' => new RoutePluginManager(new ServiceManager()), - ] - ); - } -} diff --git a/test/Http/HostnameTest.php b/test/Http/HostnameTest.php deleted file mode 100644 index dde8190..0000000 --- a/test/Http/HostnameTest.php +++ /dev/null @@ -1,254 +0,0 @@ - [ - new Hostname(':foo.example.com'), - 'bar.example.com', - ['foo' => 'bar'] - ], - 'no-match-on-different-hostname' => [ - new Hostname('foo.example.com'), - 'bar.example.com', - null - ], - 'no-match-with-different-number-of-parts' => [ - new Hostname('foo.example.com'), - 'example.com', - null - ], - 'no-match-with-different-number-of-parts-2' => [ - new Hostname('example.com'), - 'foo.example.com', - null - ], - 'match-overrides-default' => [ - new Hostname(':foo.example.com', [], ['foo' => 'baz']), - 'bat.example.com', - ['foo' => 'bat'] - ], - 'constraints-prevent-match' => [ - new Hostname(':foo.example.com', ['foo' => '\d+']), - 'bar.example.com', - null - ], - 'constraints-allow-match' => [ - new Hostname(':foo.example.com', ['foo' => '\d+']), - '123.example.com', - ['foo' => '123'] - ], - 'constraints-allow-match-2' => [ - new Hostname( - 'www.:domain.com', - ['domain' => '(mydomain|myaltdomain1|myaltdomain2)'], - ['domain' => 'mydomain'] - ), - 'www.mydomain.com', - ['domain' => 'mydomain'] - ], - 'optional-subdomain' => [ - new Hostname('[:foo.]example.com'), - 'bar.example.com', - ['foo' => 'bar'], - ], - 'two-optional-subdomain' => [ - new Hostname('[:foo.][:bar.]example.com'), - 'baz.bat.example.com', - ['foo' => 'baz', 'bar' => 'bat'], - ], - 'missing-optional-subdomain' => [ - new Hostname('[:foo.]example.com'), - 'example.com', - ['foo' => null], - ], - 'one-of-two-missing-optional-subdomain' => [ - new Hostname('[:foo.][:bar.]example.com'), - 'bat.example.com', - ['foo' => null, 'foo' => 'bat'], - ], - 'two-missing-optional-subdomain' => [ - new Hostname('[:foo.][:bar.]example.com'), - 'example.com', - ['foo' => null, 'bar' => null], - ], - 'two-optional-subdomain-nested' => [ - new Hostname('[[:foo.]:bar.]example.com'), - 'baz.bat.example.com', - ['foo' => 'baz', 'bar' => 'bat'], - ], - 'one-of-two-missing-optional-subdomain-nested' => [ - new Hostname('[[:foo.]:bar.]example.com'), - 'bat.example.com', - ['foo' => null, 'bar' => 'bat'], - ], - 'two-missing-optional-subdomain-nested' => [ - new Hostname('[[:foo.]:bar.]example.com'), - 'example.com', - ['foo' => null, 'bar' => null], - ], - 'no-match-on-different-hostname-and-optional-subdomain' => [ - new Hostname('[:foo.]example.com'), - 'bar.test.com', - null, - ], - 'no-match-with-different-number-of-parts-and-optional-subdomain' => [ - new Hostname('[:foo.]example.com'), - 'bar.baz.example.com', - null, - ], - 'match-overrides-default-optional-subdomain' => [ - new Hostname('[:foo.]:bar.example.com', [], ['bar' => 'baz']), - 'bat.qux.example.com', - ['foo' => 'bat', 'bar' => 'qux'], - ], - 'constraints-prevent-match-optional-subdomain' => [ - new Hostname('[:foo.]example.com', ['foo' => '\d+']), - 'bar.example.com', - null, - ], - 'constraints-allow-match-optional-subdomain' => [ - new Hostname('[:foo.]example.com', ['foo' => '\d+']), - '123.example.com', - ['foo' => '123'], - ], - 'middle-subdomain-optional' => [ - new Hostname(':foo.[:bar.]example.com'), - 'baz.bat.example.com', - ['foo' => 'baz', 'bar' => 'bat'], - ], - 'missing-middle-subdomain-optional' => [ - new Hostname(':foo.[:bar.]example.com'), - 'baz.example.com', - ['foo' => 'baz'], - ], - 'non-standard-delimeter' => [ - new Hostname('user-:username.example.com'), - 'user-jdoe.example.com', - ['username' => 'jdoe'], - ], - 'non-standard-delimeter-optional' => [ - new Hostname(':page{-}[-:username].example.com'), - 'article-jdoe.example.com', - ['page' => 'article', 'username' => 'jdoe'], - ], - 'missing-non-standard-delimeter-optional' => [ - new Hostname(':page{-}[-:username].example.com'), - 'article.example.com', - ['page' => 'article'], - ], - ]; - } - - /** - * @dataProvider routeProvider - * @param Hostname $route - * @param string $hostname - * @param array $params - */ - public function testMatching(Hostname $route, $hostname, array $params = null) - { - $request = new Request(); - $request->setUri('http://' . $hostname . '/'); - $match = $route->match($request); - - if ($params === null) { - $this->assertNull($match); - } else { - $this->assertInstanceOf(RouteMatch::class, $match); - - foreach ($params as $key => $value) { - $this->assertEquals($value, $match->getParam($key)); - } - } - } - - /** - * @dataProvider routeProvider - * @param Hostname $route - * @param string $hostname - * @param array $params - */ - public function testAssembling(Hostname $route, $hostname, array $params = null) - { - if ($params === null) { - // Data which will not match are not tested for assembling. - return; - } - - $uri = new HttpUri(); - $path = $route->assemble($params, ['uri' => $uri]); - - $this->assertEquals('', $path); - $this->assertEquals($hostname, $uri->getHost()); - } - - public function testNoMatchWithoutUriMethod() - { - $route = new Hostname('example.com'); - $request = new BaseRequest(); - - $this->assertNull($route->match($request)); - } - - public function testAssemblingWithMissingParameter() - { - $route = new Hostname(':foo.example.com'); - $uri = new HttpUri(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing parameter "foo"'); - $route->assemble([], ['uri' => $uri]); - } - - public function testGetAssembledParams() - { - $route = new Hostname(':foo.example.com'); - $uri = new HttpUri(); - $route->assemble(['foo' => 'bar', 'baz' => 'bat'], ['uri' => $uri]); - - $this->assertEquals(['foo'], $route->getAssembledParams()); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - Hostname::class, - [ - 'route' => 'Missing "route" in options array' - ], - [ - 'route' => 'example.com' - ] - ); - } - - /** - * @group zf5656 - */ - public function testFailedHostnameSegmentMatchDoesNotEmitErrors() - { - $this->expectException(RuntimeException::class); - new Hostname(':subdomain.with_underscore.com'); - } -} diff --git a/test/Http/HttpRouterFactoryTest.php b/test/Http/HttpRouterFactoryTest.php deleted file mode 100644 index de749fc..0000000 --- a/test/Http/HttpRouterFactoryTest.php +++ /dev/null @@ -1,28 +0,0 @@ -defaultServiceConfig = [ - 'factories' => [ - 'RoutePluginManager' => function ($services) { - return new RoutePluginManager($services); - }, - ], - ]; - - $this->factory = new HttpRouterFactory(); - } -} diff --git a/test/Http/LiteralTest.php b/test/Http/LiteralTest.php deleted file mode 100644 index 8fbb538..0000000 --- a/test/Http/LiteralTest.php +++ /dev/null @@ -1,141 +0,0 @@ - [ - new Literal('/foo'), - '/foo', - null, - true - ], - 'no-match-without-leading-slash' => [ - new Literal('foo'), - '/foo', - null, - false - ], - 'no-match-with-trailing-slash' => [ - new Literal('/foo'), - '/foo/', - null, - false - ], - 'offset-skips-beginning' => [ - new Literal('foo'), - '/foo', - 1, - true - ], - 'offset-enables-partial-matching' => [ - new Literal('/foo'), - '/foo/bar', - 0, - true - ], - ]; - } - - /** - * @dataProvider routeProvider - * @param Literal $route - * @param string $path - * @param int $offset - * @param bool $shouldMatch - */ - public function testMatching(Literal $route, $path, $offset, $shouldMatch) - { - $request = new Request(); - $request->setUri('http://example.com' . $path); - $match = $route->match($request, $offset); - - if (! $shouldMatch) { - $this->assertNull($match); - } else { - $this->assertInstanceOf(RouteMatch::class, $match); - - if ($offset === null) { - $this->assertEquals(strlen($path), $match->getLength()); - } - } - } - - /** - * @dataProvider routeProvider - * @param Literal $route - * @param string $path - * @param int $offset - * @param bool $shouldMatch - */ - public function testAssembling(Literal $route, $path, $offset, $shouldMatch) - { - if (! $shouldMatch) { - // Data which will not match are not tested for assembling. - return; - } - - $result = $route->assemble(); - - if ($offset !== null) { - $this->assertEquals($offset, strpos($path, $result, $offset)); - } else { - $this->assertEquals($path, $result); - } - } - - public function testNoMatchWithoutUriMethod() - { - $route = new Literal('/foo'); - $request = new BaseRequest(); - - $this->assertNull($route->match($request)); - } - - public function testGetAssembledParams() - { - $route = new Literal('/foo'); - $route->assemble(['foo' => 'bar']); - - $this->assertEquals([], $route->getAssembledParams()); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - Literal::class, - [ - 'route' => 'Missing "route" in options array' - ], - [ - 'route' => '/foo' - ] - ); - } - - /** - * @group ZF2-436 - */ - public function testEmptyLiteral() - { - $request = new Request(); - $route = new Literal(''); - $this->assertNull($route->match($request, 0)); - } -} diff --git a/test/Http/MethodTest.php b/test/Http/MethodTest.php deleted file mode 100644 index 6836238..0000000 --- a/test/Http/MethodTest.php +++ /dev/null @@ -1,80 +0,0 @@ - [ - new HttpMethod('get'), - 'get' - ], - 'match-comma-separated-verbs' => [ - new HttpMethod('get,post'), - 'get' - ], - 'match-comma-separated-verbs-ws' => [ - new HttpMethod('get , post , put'), - 'post' - ], - 'match-ignores-case' => [ - new HttpMethod('Get'), - 'get' - ] - ]; - } - - /** - * @dataProvider routeProvider - * @param HttpMethod $route - * @param $verb - * @internal param string $path - * @internal param int $offset - * @internal param bool $shouldMatch - */ - public function testMatching(HttpMethod $route, $verb) - { - $request = new Request(); - $request->setUri('http://example.com'); - $request->setMethod($verb); - - $match = $route->match($request); - $this->assertInstanceOf(RouteMatch::class, $match); - } - - public function testNoMatchWithoutVerb() - { - $route = new HttpMethod('get'); - $request = new BaseRequest(); - - $this->assertNull($route->match($request)); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - HttpMethod::class, - [ - 'verb' => 'Missing "verb" in options array' - ], - [ - 'verb' => 'get' - ] - ); - } -} diff --git a/test/Http/PartTest.php b/test/Http/PartTest.php deleted file mode 100644 index 74351d5..0000000 --- a/test/Http/PartTest.php +++ /dev/null @@ -1,490 +0,0 @@ - [ - 'literal' => Literal::class, - 'Literal' => Literal::class, - 'part' => Part::class, - 'Part' => Part::class, - 'segment' => Segment::class, - 'Segment' => Segment::class, - 'wildcard' => Wildcard::class, - 'Wildcard' => Wildcard::class, - 'wildCard' => Wildcard::class, - 'WildCard' => Wildcard::class, - ], - 'factories' => [ - Literal::class => RouteInvokableFactory::class, - Part::class => RouteInvokableFactory::class, - Segment::class => RouteInvokableFactory::class, - Wildcard::class => RouteInvokableFactory::class, - - // v2 normalized names - - 'zendmvcrouterhttpliteral' => RouteInvokableFactory::class, - 'zendmvcrouterhttppart' => RouteInvokableFactory::class, - 'zendmvcrouterhttpsegment' => RouteInvokableFactory::class, - 'zendmvcrouterhttpwildcard' => RouteInvokableFactory::class, - ], - ]); - } - - public static function getRoute() - { - return new Part( - [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/foo', - 'defaults' => [ - 'controller' => 'foo' - ] - ] - ], - true, - self::getRoutePlugins(), - [ - 'bar' => [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/bar', - 'defaults' => [ - 'controller' => 'bar' - ] - ] - ], - 'baz' => [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/baz' - ], - 'child_routes' => [ - 'bat' => [ - 'type' => Segment::class, - 'options' => [ - 'route' => '/:controller' - ], - 'may_terminate' => true, - 'child_routes' => [ - 'wildcard' => [ - 'type' => Wildcard::class - ] - ] - ] - ] - ], - 'bat' => [ - 'type' => Segment::class, - 'options' => [ - 'route' => '/bat[/:foo]', - 'defaults' => [ - 'foo' => 'bar' - ] - ], - 'may_terminate' => true, - 'child_routes' => [ - 'literal' => [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/bar' - ] - ], - 'optional' => [ - 'type' => Segment::class, - 'options' => [ - 'route' => '/bat[/:bar]' - ] - ], - ] - ] - ] - ); - } - - public static function getRouteAlternative() - { - return new Part( - [ - 'type' => Segment::class, - 'options' => [ - 'route' => '/[:controller[/:action]]', - 'defaults' => [ - 'controller' => 'fo-fo', - 'action' => 'index' - ] - ] - ], - true, - self::getRoutePlugins(), - [ - 'wildcard' => [ - 'type' => Wildcard::class, - 'options' => [ - 'key_value_delimiter' => '/', - 'param_delimiter' => '/' - ] - ], - ] - ); - } - - public static function routeProvider() - { - return [ - 'simple-match' => [ - self::getRoute(), - '/foo', - null, - null, - ['controller' => 'foo'] - ], - 'offset-skips-beginning' => [ - self::getRoute(), - '/bar/foo', - 4, - null, - ['controller' => 'foo'] - ], - 'simple-child-match' => [ - self::getRoute(), - '/foo/bar', - null, - 'bar', - ['controller' => 'bar'] - ], - 'offset-does-not-enable-partial-matching' => [ - self::getRoute(), - '/foo/foo', - null, - null, - null - ], - 'offset-does-not-enable-partial-matching-in-child' => [ - self::getRoute(), - '/foo/bar/baz', - null, - null, - null - ], - 'non-terminating-part-does-not-match' => [ - self::getRoute(), - '/foo/baz', - null, - null, - null - ], - 'child-of-non-terminating-part-does-match' => [ - self::getRoute(), - '/foo/baz/bat', - null, - 'baz/bat', - ['controller' => 'bat'] - ], - 'parameters-are-used-only-once' => [ - self::getRoute(), - '/foo/baz/wildcard/foo/bar', - null, - 'baz/bat/wildcard', - ['controller' => 'wildcard', 'foo' => 'bar'] - ], - 'optional-parameters-are-dropped-without-child' => [ - self::getRoute(), - '/foo/bat', - null, - 'bat', - ['foo' => 'bar'] - ], - 'optional-parameters-are-not-dropped-with-child' => [ - self::getRoute(), - '/foo/bat/bar/bar', - null, - 'bat/literal', - ['foo' => 'bar'] - ], - 'optional-parameters-not-required-in-last-part' => [ - self::getRoute(), - '/foo/bat/bar/bat', - null, - 'bat/optional', - ['foo' => 'bar'] - ], - 'simple-match' => [ - self::getRouteAlternative(), - '/', - null, - null, - [ - 'controller' => 'fo-fo', - 'action' => 'index' - ] - ], - 'match-wildcard' => [ - self::getRouteAlternative(), - '/fo-fo/index/param1/value1', - null, - 'wildcard', - [ - 'controller' => 'fo-fo', - 'action' => 'index', - 'param1' => 'value1' - ] - ], - /* - 'match-query' => array( - self::getRouteAlternative(), - '/fo-fo/index?param1=value1', - 0, - 'query', - array( - 'controller' => 'fo-fo', - 'action' => 'index' - ) - ) - */ - ]; - } - - /** - * @dataProvider routeProvider - * @param Part $route - * @param string $path - * @param int $offset - * @param string $routeName - * @param array $params - */ - public function testMatching(Part $route, $path, $offset, $routeName, array $params = null) - { - $request = new Request(); - $request->setUri('http://example.com' . $path); - $match = $route->match($request, $offset); - - if ($params === null) { - $this->assertNull($match); - } else { - $this->assertInstanceOf(RouteMatch::class, $match); - - if ($offset === null) { - $this->assertEquals(strlen($path), $match->getLength()); - } - - $this->assertEquals($routeName, $match->getMatchedRouteName()); - - foreach ($params as $key => $value) { - $this->assertEquals($value, $match->getParam($key)); - } - } - } - - /** - * @dataProvider routeProvider - * @param Part $route - * @param string $path - * @param int $offset - * @param string $routeName - * @param array $params - */ - public function testAssembling(Part $route, $path, $offset, $routeName, array $params = null) - { - if ($params === null) { - // Data which will not match are not tested for assembling. - return; - } - - $result = $route->assemble($params, ['name' => $routeName]); - - if ($offset !== null) { - $this->assertEquals($offset, strpos($path, $result, $offset)); - } else { - $this->assertEquals($path, $result); - } - } - - public function testAssembleNonTerminatedRoute() - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Part route may not terminate'); - self::getRoute()->assemble([], ['name' => 'baz']); - } - - public function testBaseRouteMayNotBePartRoute() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Base route may not be a part route'); - - new Part(self::getRoute(), true, new RoutePluginManager(new ServiceManager())); - } - - public function testNoMatchWithoutUriMethod() - { - $route = self::getRoute(); - $request = new BaseRequest(); - - $this->assertNull($route->match($request)); - } - - public function testGetAssembledParams() - { - $route = self::getRoute(); - $route->assemble(['controller' => 'foo'], ['name' => 'baz/bat']); - - $this->assertEquals([], $route->getAssembledParams()); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - Part::class, - [ - 'route' => 'Missing "route" in options array', - 'route_plugins' => 'Missing "route_plugins" in options array' - ], - [ - 'route' => new \Zend\Router\Http\Literal('/foo'), - 'route_plugins' => self::getRoutePlugins(), - ] - ); - } - - /** - * @group ZF2-105 - */ - public function testFactoryShouldAcceptTraversableChildRoutes() - { - $children = new ArrayObject([ - 'create' => [ - 'type' => 'Literal', - 'options' => [ - 'route' => 'create', - 'defaults' => [ - 'controller' => 'user-admin', - 'action' => 'edit', - ], - ], - ], - ]); - $options = [ - 'route' => [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/admin/users', - 'defaults' => [ - 'controller' => 'Admin\UserController', - 'action' => 'index', - ], - ], - ], - 'route_plugins' => self::getRoutePlugins(), - 'may_terminate' => true, - 'child_routes' => $children, - ]; - - $route = Part::factory($options); - $this->assertInstanceOf(Part::class, $route); - } - - /** - * @group 3711 - */ - public function testPartRouteMarkedAsMayTerminateCanMatchWhenQueryStringPresent() - { - $options = [ - 'route' => [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/resource', - 'defaults' => [ - 'controller' => 'ResourceController', - 'action' => 'resource', - ], - ], - ], - 'route_plugins' => self::getRoutePlugins(), - 'may_terminate' => true, - 'child_routes' => [ - 'child' => [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/child', - 'defaults' => [ - 'action' => 'child', - ], - ], - ], - ], - ]; - - $route = Part::factory($options); - $request = new Request(); - $request->setUri('http://example.com/resource?foo=bar'); - $query = new Parameters(['foo' => 'bar']); - $request->setQuery($query); - $query = $request->getQuery(); - - $match = $route->match($request); - $this->assertInstanceOf(\Zend\Router\RouteMatch::class, $match); - $this->assertEquals('resource', $match->getParam('action')); - } - - /** - * @group 3711 - */ - public function testPartRouteMarkedAsMayTerminateButWithQueryRouteChildWillMatchChildRoute() - { - $options = [ - 'route' => [ - 'type' => Literal::class, - 'options' => [ - 'route' => '/resource', - 'defaults' => [ - 'controller' => 'ResourceController', - 'action' => 'resource', - ], - ], - ], - 'route_plugins' => self::getRoutePlugins(), - 'may_terminate' => true, - ]; - - $route = Part::factory($options); - $request = new Request(); - $request->setUri('http://example.com/resource?foo=bar'); - $query = new Parameters(['foo' => 'bar']); - $request->setQuery($query); - $query = $request->getQuery(); - - /* - $match = $route->match($request); - $this->assertInstanceOf(\Zend\Router\RouteMatch::class, $match); - $this->assertEquals('string', $match->getParam('query')); - */ - } -} diff --git a/test/Http/RegexTest.php b/test/Http/RegexTest.php deleted file mode 100644 index c166749..0000000 --- a/test/Http/RegexTest.php +++ /dev/null @@ -1,178 +0,0 @@ - [ - new Regex('/(?[^/]+)', '/%foo%'), - '/bar', - null, - ['foo' => 'bar'] - ], - 'no-match-without-leading-slash' => [ - new Regex('(?[^/]+)', '%foo%'), - '/bar', - null, - null - ], - 'no-match-with-trailing-slash' => [ - new Regex('/(?[^/]+)', '/%foo%'), - '/bar/', - null, - null - ], - 'offset-skips-beginning' => [ - new Regex('(?[^/]+)', '%foo%'), - '/bar', - 1, - ['foo' => 'bar'] - ], - 'offset-enables-partial-matching' => [ - new Regex('/(?[^/]+)', '/%foo%'), - '/bar/baz', - 0, - ['foo' => 'bar'] - ], - 'url-encoded-parameters-are-decoded' => [ - new Regex('/(?[^/]+)', '/%foo%'), - '/foo%20bar', - null, - ['foo' => 'foo bar'] - ], - 'empty-matches-are-replaced-with-defaults' => [ - new Regex('/foo(?:/(?[^/]+))?/baz-(?[^/]+)', '/foo/baz-%baz%', ['bar' => 'bar']), - '/foo/baz-baz', - null, - ['bar' => 'bar', 'baz' => 'baz'] - ], - ]; - } - - /** - * @dataProvider routeProvider - * @param Regex $route - * @param string $path - * @param int $offset - * @param array $params - */ - public function testMatching(Regex $route, $path, $offset, array $params = null) - { - $request = new Request(); - $request->setUri('http://example.com' . $path); - $match = $route->match($request, $offset); - - if ($params === null) { - $this->assertNull($match); - } else { - $this->assertInstanceOf(RouteMatch::class, $match); - - if ($offset === null) { - $this->assertEquals(strlen($path), $match->getLength()); - } - - foreach ($params as $key => $value) { - $this->assertEquals($value, $match->getParam($key)); - } - } - } - - /** - * @dataProvider routeProvider - * @param Regex $route - * @param string $path - * @param int $offset - * @param array $params - */ - public function testAssembling(Regex $route, $path, $offset, array $params = null) - { - if ($params === null) { - // Data which will not match are not tested for assembling. - return; - } - - $result = $route->assemble($params); - - if ($offset !== null) { - $this->assertEquals($offset, strpos($path, $result, $offset)); - } else { - $this->assertEquals($path, $result); - } - } - - public function testNoMatchWithoutUriMethod() - { - $route = new Regex('/foo', '/foo'); - $request = new BaseRequest(); - - $this->assertNull($route->match($request)); - } - - public function testGetAssembledParams() - { - $route = new Regex('/(?.+)', '/%foo%'); - $route->assemble(['foo' => 'bar', 'baz' => 'bat']); - - $this->assertEquals(['foo'], $route->getAssembledParams()); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - Regex::class, - [ - 'regex' => 'Missing "regex" in options array', - 'spec' => 'Missing "spec" in options array' - ], - [ - 'regex' => '/foo', - 'spec' => '/foo' - ] - ); - } - - public function testRawDecode() - { - // verify all characters which don't absolutely require encoding pass through match unchanged - // this includes every character other than #, %, / and ? - $raw = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',.~!@$^&*()_+{}|:"<>'; - $request = new Request(); - $request->setUri('http://example.com/' . $raw); - $route = new Regex('/(?[^/]+)', '/%foo%'); - $match = $route->match($request); - - $this->assertSame($raw, $match->getParam('foo')); - } - - public function testEncodedDecode() - { - // @codingStandardsIgnoreStart - // every character - $in = '%61%62%63%64%65%66%67%68%69%6a%6b%6c%6d%6e%6f%70%71%72%73%74%75%76%77%78%79%7a%41%42%43%44%45%46%47%48%49%4a%4b%4c%4d%4e%4f%50%51%52%53%54%55%56%57%58%59%5a%30%31%32%33%34%35%36%37%38%39%60%2d%3d%5b%5d%5c%3b%27%2c%2e%2f%7e%21%40%23%24%25%5e%26%2a%28%29%5f%2b%7b%7d%7c%3a%22%3c%3e%3f'; - $out = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',./~!@#$%^&*()_+{}|:"<>?'; - // @codingStandardsIgnoreEnd - - $request = new Request(); - $request->setUri('http://example.com/' . $in); - $route = new Regex('/(?[^/]+)', '/%foo%'); - $match = $route->match($request); - - $this->assertSame($out, $match->getParam('foo')); - } -} diff --git a/test/Http/RouteMatchTest.php b/test/Http/RouteMatchTest.php deleted file mode 100644 index 976bec1..0000000 --- a/test/Http/RouteMatchTest.php +++ /dev/null @@ -1,66 +0,0 @@ - 'bar']); - - $this->assertEquals(['foo' => 'bar'], $match->getParams()); - } - - public function testLengthIsStored() - { - $match = new RouteMatch([], 10); - - $this->assertEquals(10, $match->getLength()); - } - - public function testLengthIsMerged() - { - $match = new RouteMatch([], 10); - $match->merge(new RouteMatch([], 5)); - - $this->assertEquals(15, $match->getLength()); - } - - public function testMatchedRouteNameIsSet() - { - $match = new RouteMatch([]); - $match->setMatchedRouteName('foo'); - - $this->assertEquals('foo', $match->getMatchedRouteName()); - } - - public function testMatchedRouteNameIsPrependedWhenAlreadySet() - { - $match = new RouteMatch([]); - $match->setMatchedRouteName('foo'); - $match->setMatchedRouteName('bar'); - - $this->assertEquals('bar/foo', $match->getMatchedRouteName()); - } - - public function testMatchedRouteNameIsOverriddenOnMerge() - { - $match = new RouteMatch([]); - $match->setMatchedRouteName('foo'); - - $subMatch = new RouteMatch([]); - $subMatch->setMatchedRouteName('bar'); - - $match->merge($subMatch); - - $this->assertEquals('bar', $match->getMatchedRouteName()); - } -} diff --git a/test/Http/SchemeTest.php b/test/Http/SchemeTest.php deleted file mode 100644 index 233418b..0000000 --- a/test/Http/SchemeTest.php +++ /dev/null @@ -1,81 +0,0 @@ -setUri('https://example.com/'); - - $route = new Scheme('https'); - $match = $route->match($request); - - $this->assertInstanceOf(RouteMatch::class, $match); - } - - public function testNoMatchingOnDifferentScheme() - { - $request = new Request(); - $request->setUri('http://example.com/'); - - $route = new Scheme('https'); - $match = $route->match($request); - - $this->assertNull($match); - } - - public function testAssembling() - { - $uri = new HttpUri(); - $route = new Scheme('https'); - $path = $route->assemble([], ['uri' => $uri]); - - $this->assertEquals('', $path); - $this->assertEquals('https', $uri->getScheme()); - } - - public function testNoMatchWithoutUriMethod() - { - $route = new Scheme('https'); - $request = new BaseRequest(); - - $this->assertNull($route->match($request)); - } - - public function testGetAssembledParams() - { - $route = new Scheme('https'); - $route->assemble(['foo' => 'bar']); - - $this->assertEquals([], $route->getAssembledParams()); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - Scheme::class, - [ - 'scheme' => 'Missing "scheme" in options array', - ], - [ - 'scheme' => 'http', - ] - ); - } -} diff --git a/test/Http/SegmentTest.php b/test/Http/SegmentTest.php deleted file mode 100644 index 4275055..0000000 --- a/test/Http/SegmentTest.php +++ /dev/null @@ -1,479 +0,0 @@ - [ - new Segment('/:foo'), - '/bar', - null, - ['foo' => 'bar'] - ], - 'no-match-without-leading-slash' => [ - new Segment(':foo'), - '/bar/', - null, - null - ], - 'no-match-with-trailing-slash' => [ - new Segment('/:foo'), - '/bar/', - null, - null - ], - 'offset-skips-beginning' => [ - new Segment(':foo'), - '/bar', - 1, - ['foo' => 'bar'] - ], - 'offset-enables-partial-matching' => [ - new Segment('/:foo'), - '/bar/baz', - 0, - ['foo' => 'bar'] - ], - 'match-overrides-default' => [ - new Segment('/:foo', [], ['foo' => 'baz']), - '/bar', - null, - ['foo' => 'bar'] - ], - 'constraints-prevent-match' => [ - new Segment('/:foo', ['foo' => '\d+']), - '/bar', - null, - null - ], - 'constraints-allow-match' => [ - new Segment('/:foo', ['foo' => '\d+']), - '/123', - null, - ['foo' => '123'] - ], - 'constraints-override-non-standard-delimiter' => [ - new Segment('/:foo{-}/bar', ['foo' => '[^/]+']), - '/foo-bar/bar', - null, - ['foo' => 'foo-bar'] - ], - 'constraints-with-parantheses-dont-break-parameter-map' => [ - new Segment('/:foo/:bar', ['foo' => '(bar)']), - '/bar/baz', - null, - ['foo' => 'bar', 'bar' => 'baz'] - ], - 'simple-match-with-optional-parameter' => [ - new Segment('/[:foo]', [], ['foo' => 'bar']), - '/', - null, - ['foo' => 'bar'] - ], - 'optional-parameter-is-ignored' => [ - new Segment('/:foo[/:bar]'), - '/bar', - null, - ['foo' => 'bar'] - ], - 'optional-parameter-is-provided-with-default' => [ - new Segment('/:foo[/:bar]', [], ['bar' => 'baz']), - '/bar', - null, - ['foo' => 'bar', 'bar' => 'baz'] - ], - 'optional-parameter-is-consumed' => [ - new Segment('/:foo[/:bar]'), - '/bar/baz', - null, - ['foo' => 'bar', 'bar' => 'baz'] - ], - 'optional-group-is-discared-with-missing-parameter' => [ - new Segment('/:foo[/:bar/:baz]', [], ['bar' => 'baz']), - '/bar', - null, - ['foo' => 'bar', 'bar' => 'baz'] - ], - 'optional-group-within-optional-group-is-ignored' => [ - new Segment('/:foo[/:bar[/:baz]]', [], ['bar' => 'baz', 'baz' => 'bat']), - '/bar', - null, - ['foo' => 'bar', 'bar' => 'baz', 'baz' => 'bat'] - ], - 'non-standard-delimiter-before-parameter' => [ - new Segment('/foo-:bar'), - '/foo-baz', - null, - ['bar' => 'baz'] - ], - 'non-standard-delimiter-between-parameters' => [ - new Segment('/:foo{-}-:bar'), - '/bar-baz', - null, - ['foo' => 'bar', 'bar' => 'baz'] - ], - 'non-standard-delimiter-before-optional-parameter' => [ - new Segment('/:foo{-/}[-:bar]/:baz'), - '/bar-baz/bat', - null, - ['foo' => 'bar', 'bar' => 'baz', 'baz' => 'bat'] - ], - 'non-standard-delimiter-before-ignored-optional-parameter' => [ - new Segment('/:foo{-/}[-:bar]/:baz'), - '/bar/bat', - null, - ['foo' => 'bar', 'baz' => 'bat'] - ], - 'parameter-with-dash-in-name' => [ - new Segment('/:foo-bar'), - '/baz', - null, - ['foo-bar' => 'baz'] - ], - 'url-encoded-parameters-are-decoded' => [ - new Segment('/:foo'), - '/foo%20bar', - null, - ['foo' => 'foo bar'] - ], - 'urlencode-flaws-corrected' => [ - new Segment('/:foo'), - "/!$&'()*,-.:;=@_~+", - null, - ['foo' => "!$&'()*,-.:;=@_~+"] - ], - 'empty-matches-are-replaced-with-defaults' => [ - new Segment('/foo[/:bar]/baz-:baz', [], ['bar' => 'bar']), - '/foo/baz-baz', - null, - ['bar' => 'bar', 'baz' => 'baz'] - ], - ]; - } - - public function l10nRouteProvider() - { - $this->markTestIncomplete( - 'Translation tests need to be updated once zend-i18n is updated for zend-servicemanager v3' - ); - - // @codingStandardsIgnoreStart - $translator = new Translator(); - $translator->setLocale('en-US'); - $enLoader = $this->getMock(FileLoaderInterface::class); - $deLoader = $this->getMock(FileLoaderInterface::class); - $domainLoader = $this->getMock(FileLoaderInterface::class); - $enLoader->expects($this->any())->method('load')->willReturn(new TextDomain(['fw' => 'framework'])); - $deLoader->expects($this->any())->method('load')->willReturn(new TextDomain(['fw' => 'baukasten'])); - $domainLoader->expects($this->any())->method('load')->willReturn(new TextDomain(['fw' => 'fw-alternative'])); - $translator->getPluginManager()->setService('test-en', $enLoader); - $translator->getPluginManager()->setService('test-de', $deLoader); - $translator->getPluginManager()->setService('test-domain', $domainLoader); - $translator->addTranslationFile('test-en', null, 'default', 'en-US'); - $translator->addTranslationFile('test-de', null, 'default', 'de-DE'); - $translator->addTranslationFile('test-domain', null, 'alternative', 'en-US'); - // @codingStandardsIgnoreEnd - - return [ - 'translate-with-default-locale' => [ - new Segment('/{fw}', [], []), - '/framework', - null, - [], - ['translator' => $translator] - ], - 'translate-with-specific-locale' => [ - new Segment('/{fw}', [], []), - '/baukasten', - null, - [], - ['translator' => $translator, 'locale' => 'de-DE'] - ], - 'translate-uses-message-id-as-fallback' => [ - new Segment('/{fw}', [], []), - '/fw', - null, - [], - ['translator' => $translator, 'locale' => 'fr-FR'] - ], - 'translate-with-specific-text-domain' => [ - new Segment('/{fw}', [], []), - '/fw-alternative', - null, - [], - ['translator' => $translator, 'text_domain' => 'alternative'] - ], - ]; - } - - public static function parseExceptionsProvider() - { - return [ - 'unbalanced-brackets' => [ - '[', - RuntimeException::class, - 'Found unbalanced brackets' - ], - 'closing-bracket-without-opening-bracket' => [ - ']', - RuntimeException::class, - 'Found closing bracket without matching opening bracket' - ], - 'empty-parameter-name' => [ - ':', - RuntimeException::class, - 'Found empty parameter name' - ], - 'translated-literal-without-closing-backet' => [ - '{test', - RuntimeException::class, - 'Translated literal missing closing bracket' - ], - ]; - } - - /** - * @dataProvider routeProvider - * @param Segment $route - * @param string $path - * @param int $offset - * @param array $params - * @param array $options - */ - public function testMatching(Segment $route, $path, $offset, array $params = null, array $options = []) - { - $request = new Request(); - $request->setUri('http://example.com' . $path); - $match = $route->match($request, $offset, $options); - - if ($params === null) { - $this->assertNull($match); - } else { - $this->assertInstanceOf(RouteMatch::class, $match); - - if ($offset === null) { - $this->assertEquals(strlen($path), $match->getLength()); - } - - foreach ($params as $key => $value) { - $this->assertEquals($value, $match->getParam($key)); - } - } - } - - /** - * @dataProvider routeProvider - * @param Segment $route - * @param string $path - * @param int $offset - * @param array $params - * @param array $options - */ - public function testAssembling(Segment $route, $path, $offset, array $params = null, array $options = []) - { - if ($params === null) { - // Data which will not match are not tested for assembling. - return; - } - - $result = $route->assemble($params, $options); - - if ($offset !== null) { - $this->assertEquals($offset, strpos($path, $result, $offset)); - } else { - $this->assertEquals($path, $result); - } - } - - /** - * @dataProvider l10nRouteProvider - * @param Segment $route - * @param string $path - * @param int $offset - * @param array $params - * @param array $options - */ - public function testMatchingWithL10n(Segment $route, $path, $offset, array $params = null, array $options = []) - { - $request = new Request(); - $request->setUri('http://example.com' . $path); - $match = $route->match($request, $offset, $options); - - if ($params === null) { - $this->assertNull($match); - } else { - $this->assertInstanceOf(RouteMatch::class, $match); - - if ($offset === null) { - $this->assertEquals(strlen($path), $match->getLength()); - } - - foreach ($params as $key => $value) { - $this->assertEquals($value, $match->getParam($key)); - } - } - } - - /** - * @dataProvider l10nRouteProvider - * @param Segment $route - * @param string $path - * @param int $offset - * @param array $params - * @param array $options - */ - public function testAssemblingWithL10n(Segment $route, $path, $offset, array $params = null, array $options = []) - { - if ($params === null) { - // Data which will not match are not tested for assembling. - return; - } - - $result = $route->assemble($params, $options); - - if ($offset !== null) { - $this->assertEquals($offset, strpos($path, $result, $offset)); - } else { - $this->assertEquals($path, $result); - } - } - - /** - * @dataProvider parseExceptionsProvider - * @param string $route - * @param string $exceptionName - * @param string $exceptionMessage - */ - public function testParseExceptions($route, $exceptionName, $exceptionMessage) - { - $this->expectException($exceptionName); - $this->expectExceptionMessage($exceptionMessage); - new Segment($route); - } - - public function testAssemblingWithMissingParameterInRoot() - { - $route = new Segment('/:foo'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing parameter "foo"'); - $route->assemble(); - } - - public function testTranslatedAssemblingThrowsExceptionWithoutTranslator() - { - $route = new Segment('/{foo}'); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No translator provided'); - $route->assemble(); - } - - public function testTranslatedMatchingThrowsExceptionWithoutTranslator() - { - $route = new Segment('/{foo}'); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No translator provided'); - $route->match(new Request()); - } - - public function testNoMatchWithoutUriMethod() - { - $route = new Segment('/foo'); - $request = new BaseRequest(); - - $this->assertNull($route->match($request)); - } - - public function testAssemblingWithExistingChild() - { - $route = new Segment('/[:foo]', [], ['foo' => 'bar']); - $path = $route->assemble([], ['has_child' => true]); - - $this->assertEquals('/bar', $path); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - Segment::class, - [ - 'route' => 'Missing "route" in options array' - ], - [ - 'route' => '/:foo[/:bar{-}]', - 'constraints' => ['foo' => 'bar'] - ] - ); - } - - public function testRawDecode() - { - // verify all characters which don't absolutely require encoding pass through match unchanged - // this includes every character other than #, %, / and ? - $raw = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',.~!@$^&*()_+{}|:"<>'; - $request = new Request(); - $request->setUri('http://example.com/' . $raw); - $route = new Segment('/:foo'); - $match = $route->match($request); - - $this->assertSame($raw, $match->getParam('foo')); - } - - public function testEncodedDecode() - { - // @codingStandardsIgnoreStart - // every character - $in = '%61%62%63%64%65%66%67%68%69%6a%6b%6c%6d%6e%6f%70%71%72%73%74%75%76%77%78%79%7a%41%42%43%44%45%46%47%48%49%4a%4b%4c%4d%4e%4f%50%51%52%53%54%55%56%57%58%59%5a%30%31%32%33%34%35%36%37%38%39%60%2d%3d%5b%5d%5c%3b%27%2c%2e%2f%7e%21%40%23%24%25%5e%26%2a%28%29%5f%2b%7b%7d%7c%3a%22%3c%3e%3f'; - $out = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',./~!@#$%^&*()_+{}|:"<>?'; - // @codingStandardsIgnoreEnd - - $request = new Request(); - $request->setUri('http://example.com/' . $in); - $route = new Segment('/:foo'); - $match = $route->match($request); - - $this->assertSame($out, $match->getParam('foo')); - } - - public function testEncodeCache() - { - $params1 = ['p1' => 6.123, 'p2' => 7]; - $uri1 = 'example.com/'.implode('/', $params1); - $params2 = ['p1' => 6, 'p2' => 'test']; - $uri2 = 'example.com/'.implode('/', $params2); - - $route = new Segment('example.com/:p1/:p2'); - $request = new Request(); - - $request->setUri($uri1); - $route->match($request); - $this->assertSame($uri1, $route->assemble($params1)); - - $request->setUri($uri2); - $route->match($request); - $this->assertSame($uri2, $route->assemble($params2)); - } -} diff --git a/test/Http/TestAsset/DummyRoute.php b/test/Http/TestAsset/DummyRoute.php deleted file mode 100644 index 796e880..0000000 --- a/test/Http/TestAsset/DummyRoute.php +++ /dev/null @@ -1,66 +0,0 @@ - $pathOffset], -4); - } - - /** - * assemble(): defined by RouteInterface interface. - * - * @see Route::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = null, array $options = null) - { - return ''; - } - - /** - * factory(): defined by RouteInterface interface - * - * @param array|Traversable $options - * @return DummyRoute - */ - public static function factory($options = []) - { - return new static(); - } - - /** - * getAssembledParams(): defined by RouteInterface interface. - * - * @see Route::getAssembledParams - * @return array - */ - public function getAssembledParams() - { - return []; - } -} diff --git a/test/Http/TestAsset/DummyRouteWithParam.php b/test/Http/TestAsset/DummyRouteWithParam.php deleted file mode 100644 index 6d5d6d8..0000000 --- a/test/Http/TestAsset/DummyRouteWithParam.php +++ /dev/null @@ -1,47 +0,0 @@ - 'bar'], -4); - } - - /** - * assemble(): defined by RouteInterface interface. - * - * @see Route::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = null, array $options = null) - { - if (isset($params['foo'])) { - return $params['foo']; - } - - return ''; - } -} diff --git a/test/Http/TranslatorAwareTreeRouteStackTest.php b/test/Http/TranslatorAwareTreeRouteStackTest.php deleted file mode 100644 index b95ca0d..0000000 --- a/test/Http/TranslatorAwareTreeRouteStackTest.php +++ /dev/null @@ -1,166 +0,0 @@ -markTestIncomplete('Re-enable once zend-i18n is updated to zend-servicemanager v3'); - - $this->testFilesDir = __DIR__ . '/_files'; - - $this->translator = new Translator(); - $this->translator->addTranslationFile('phpArray', $this->testFilesDir . '/tokens.en.php', 'route', 'en'); - $this->translator->addTranslationFile('phpArray', $this->testFilesDir . '/tokens.de.php', 'route', 'de'); - - $this->fooRoute = [ - 'type' => 'Segment', - 'options' => [ - 'route' => '/:locale', - ], - 'child_routes' => [ - 'index' => [ - 'type' => 'Segment', - 'options' => [ - 'route' => '/{homepage}', - ], - ], - ], - ]; - } - - public function testTranslatorAwareInterfaceImplementation() - { - $stack = new TranslatorAwareTreeRouteStack(); - $this->assertInstanceOf(TranslatorAwareInterface::class, $stack); - - // Defaults - $this->assertNull($stack->getTranslator()); - $this->assertFalse($stack->hasTranslator()); - $this->assertEquals('default', $stack->getTranslatorTextDomain()); - $this->assertTrue($stack->isTranslatorEnabled()); - - // Inject translator without text domain - $translator = new Translator(); - $stack->setTranslator($translator); - $this->assertSame($translator, $stack->getTranslator()); - $this->assertEquals('default', $stack->getTranslatorTextDomain()); - $this->assertTrue($stack->hasTranslator()); - - // Reset translator - $stack->setTranslator(null); - $this->assertNull($stack->getTranslator()); - $this->assertFalse($stack->hasTranslator()); - - // Inject translator with text domain - $stack->setTranslator($translator, 'alternative'); - $this->assertSame($translator, $stack->getTranslator()); - $this->assertEquals('alternative', $stack->getTranslatorTextDomain()); - - // Set text domain - $stack->setTranslatorTextDomain('default'); - $this->assertEquals('default', $stack->getTranslatorTextDomain()); - - // Disable translator - $stack->setTranslatorEnabled(false); - $this->assertFalse($stack->isTranslatorEnabled()); - } - - public function testTranslatorIsPassedThroughMatchMethod() - { - $translator = new Translator(); - $request = new Request(); - - $route = $this->getMock(RouteInterface::class); - $route->expects($this->once()) - ->method('match') - ->with( - $this->equalTo($request), - $this->isNull(), - $this->equalTo(['translator' => $translator, 'text_domain' => 'default']) - ); - - $stack = new TranslatorAwareTreeRouteStack(); - $stack->addRoute('test', $route); - - $stack->match($request, null, ['translator' => $translator]); - } - - public function testTranslatorIsPassedThroughAssembleMethod() - { - $translator = new Translator(); - $uri = new HttpUri(); - - $route = $this->getMock(RouteInterface::class); - $route->expects($this->once()) - ->method('assemble') - ->with( - $this->equalTo([]), - $this->equalTo(['translator' => $translator, 'text_domain' => 'default', 'uri' => $uri]) - ); - - $stack = new TranslatorAwareTreeRouteStack(); - $stack->addRoute('test', $route); - - $stack->assemble([], ['name' => 'test', 'translator' => $translator, 'uri' => $uri]); - } - - public function testAssembleRouteWithParameterLocale() - { - $stack = new TranslatorAwareTreeRouteStack(); - $stack->setTranslator($this->translator, 'route'); - $stack->addRoute( - 'foo', - $this->fooRoute - ); - - $this->assertEquals('/de/hauptseite', $stack->assemble(['locale' => 'de'], ['name' => 'foo/index'])); - $this->assertEquals('/en/homepage', $stack->assemble(['locale' => 'en'], ['name' => 'foo/index'])); - } - - public function testMatchRouteWithParameterLocale() - { - $stack = new TranslatorAwareTreeRouteStack(); - $stack->setTranslator($this->translator, 'route'); - $stack->addRoute( - 'foo', - $this->fooRoute - ); - - $request = new Request(); - $request->setUri('http://example.com/de/hauptseite'); - - $match = $stack->match($request); - $this->assertNotNull($match); - $this->assertEquals('foo/index', $match->getMatchedRouteName()); - } -} diff --git a/test/Http/TreeRouteStackTest.php b/test/Http/TreeRouteStackTest.php deleted file mode 100644 index ac8fb4f..0000000 --- a/test/Http/TreeRouteStackTest.php +++ /dev/null @@ -1,465 +0,0 @@ -expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Route definition must be an array or Traversable object'); - $stack->addRoute('foo', new \ZendTest\Router\TestAsset\DummyRoute()); - } - - public function testAddRouteViaStringRequiresHttpSpecificRoute() - { - $stack = new TreeRouteStack(); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Given route does not implement HTTP route interface'); - $stack->addRoute('foo', [ - 'type' => \ZendTest\Router\TestAsset\DummyRoute::class - ]); - } - - public function testAddRouteAcceptsTraversable() - { - $stack = new TreeRouteStack(); - $stack->addRoute('foo', new ArrayIterator([ - 'type' => TestAsset\DummyRoute::class - ])); - } - - public function testNoMatchWithoutUriMethod() - { - $stack = new TreeRouteStack(); - $request = new BaseRequest(); - - $this->assertNull($stack->match($request)); - } - - public function testSetBaseUrlFromFirstMatch() - { - $stack = new TreeRouteStack(); - - $request = new PhpRequest(); - $request->setBaseUrl('/foo'); - $stack->match($request); - $this->assertEquals('/foo', $stack->getBaseUrl()); - - $request = new PhpRequest(); - $request->setBaseUrl('/bar'); - $stack->match($request); - $this->assertEquals('/foo', $stack->getBaseUrl()); - } - - public function testBaseUrlLengthIsPassedAsOffset() - { - $stack = new TreeRouteStack(); - $stack->setBaseUrl('/foo'); - $stack->addRoute('foo', [ - 'type' => TestAsset\DummyRoute::class - ]); - - $this->assertEquals(4, $stack->match(new Request())->getParam('offset')); - } - - public function testNoOffsetIsPassedWithoutBaseUrl() - { - $stack = new TreeRouteStack(); - $stack->addRoute('foo', [ - 'type' => TestAsset\DummyRoute::class - ]); - - $this->assertEquals(null, $stack->match(new Request())->getParam('offset')); - } - - public function testAssemble() - { - $stack = new TreeRouteStack(); - $stack->addRoute('foo', new TestAsset\DummyRoute()); - $this->assertEquals('', $stack->assemble([], ['name' => 'foo'])); - } - - public function testAssembleCanonicalUriWithoutRequestUri() - { - $stack = new TreeRouteStack(); - $stack->addRoute('foo', new TestAsset\DummyRoute()); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Request URI has not been set'); - $stack->assemble([], ['name' => 'foo', 'force_canonical' => true]); - } - - public function testAssembleCanonicalUriWithRequestUri() - { - $uri = new HttpUri('http://example.com:8080/'); - $stack = new TreeRouteStack(); - $stack->setRequestUri($uri); - - $stack->addRoute('foo', new TestAsset\DummyRoute()); - $this->assertEquals( - 'http://example.com:8080/', - $stack->assemble([], ['name' => 'foo', 'force_canonical' => true]) - ); - } - - public function testAssembleCanonicalUriWithGivenUri() - { - $uri = new HttpUri('http://example.com:8080/'); - $stack = new TreeRouteStack(); - - $stack->addRoute('foo', new TestAsset\DummyRoute()); - $this->assertEquals( - 'http://example.com:8080/', - $stack->assemble([], ['name' => 'foo', 'uri' => $uri, 'force_canonical' => true]) - ); - } - - public function testAssembleCanonicalUriWithHostnameRoute() - { - $stack = new TreeRouteStack(); - $stack->addRoute('foo', new Hostname('example.com')); - $uri = new HttpUri(); - $uri->setScheme('http'); - - $this->assertEquals('http://example.com/', $stack->assemble([], ['name' => 'foo', 'uri' => $uri])); - } - - public function testAssembleCanonicalUriWithHostnameRouteWithoutScheme() - { - $stack = new TreeRouteStack(); - $stack->addRoute('foo', new Hostname('example.com')); - $uri = new HttpUri(); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Request URI has not been set'); - $stack->assemble([], ['name' => 'foo', 'uri' => $uri]); - } - - public function testAssembleCanonicalUriWithHostnameRouteAndRequestUriWithoutScheme() - { - $uri = new HttpUri(); - $uri->setScheme('http'); - $stack = new TreeRouteStack(); - $stack->setRequestUri($uri); - $stack->addRoute('foo', new Hostname('example.com')); - - $this->assertEquals('http://example.com/', $stack->assemble([], ['name' => 'foo'])); - } - - public function testAssembleWithQueryParams() - { - $stack = new TreeRouteStack(); - $stack->addRoute( - 'index', - [ - 'type' => 'Literal', - 'options' => [ - 'route' => '/', - ], - ] - ); - - $this->assertEquals('/?foo=bar', $stack->assemble([], ['name' => 'index', 'query' => ['foo' => 'bar']])); - } - - public function testAssembleWithEncodedPath() - { - $stack = new TreeRouteStack(); - $stack->addRoute( - 'index', - [ - 'type' => 'Literal', - 'options' => [ - 'route' => '/this%2Fthat', - ], - ] - ); - - $this->assertEquals('/this%2Fthat', $stack->assemble([], ['name' => 'index'])); - } - - public function testAssembleWithEncodedPathAndQueryParams() - { - $stack = new TreeRouteStack(); - $stack->addRoute( - 'index', - [ - 'type' => 'Literal', - 'options' => [ - 'route' => '/this%2Fthat', - ], - ] - ); - - $this->assertEquals( - '/this%2Fthat?foo=bar', - $stack->assemble([], ['name' => 'index', 'query' => ['foo' => 'bar'], 'normalize_path' => false]) - ); - } - - public function testAssembleWithScheme() - { - $uri = new HttpUri(); - $uri->setScheme('http'); - $uri->setHost('example.com'); - $stack = new TreeRouteStack(); - $stack->setRequestUri($uri); - $stack->addRoute( - 'secure', - [ - 'type' => 'Scheme', - 'options' => [ - 'scheme' => 'https' - ], - 'child_routes' => [ - 'index' => [ - 'type' => 'Literal', - 'options' => [ - 'route' => '/', - ], - ], - ], - ] - ); - $this->assertEquals('https://example.com/', $stack->assemble([], ['name' => 'secure/index'])); - } - - public function testAssembleWithFragment() - { - $stack = new TreeRouteStack(); - $stack->addRoute( - 'index', - [ - 'type' => 'Literal', - 'options' => [ - 'route' => '/', - ], - ] - ); - - $this->assertEquals('/#foobar', $stack->assemble([], ['name' => 'index', 'fragment' => 'foobar'])); - } - - public function testAssembleWithoutNameOption() - { - $stack = new TreeRouteStack(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing "name" option'); - $stack->assemble(); - } - - public function testAssembleNonExistentRoute() - { - $stack = new TreeRouteStack(); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Route with name "foo" not found'); - $stack->assemble([], ['name' => 'foo']); - } - - public function testAssembleNonExistentChildRoute() - { - $stack = new TreeRouteStack(); - $stack->addRoute( - 'index', - [ - 'type' => 'Literal', - 'options' => [ - 'route' => '/', - ], - ] - ); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Route with name "index" does not have child routes'); - $stack->assemble([], ['name' => 'index/foo']); - } - - public function testDefaultParamIsAddedToMatch() - { - $stack = new TreeRouteStack(); - $stack->setBaseUrl('/foo'); - $stack->addRoute('foo', new TestAsset\DummyRoute()); - $stack->setDefaultParam('foo', 'bar'); - - $this->assertEquals('bar', $stack->match(new Request())->getParam('foo')); - } - - public function testDefaultParamDoesNotOverrideParam() - { - $stack = new TreeRouteStack(); - $stack->setBaseUrl('/foo'); - $stack->addRoute('foo', new TestAsset\DummyRouteWithParam()); - $stack->setDefaultParam('foo', 'baz'); - - $this->assertEquals('bar', $stack->match(new Request())->getParam('foo')); - } - - public function testDefaultParamIsUsedForAssembling() - { - $stack = new TreeRouteStack(); - $stack->addRoute('foo', new TestAsset\DummyRouteWithParam()); - $stack->setDefaultParam('foo', 'bar'); - - $this->assertEquals('bar', $stack->assemble([], ['name' => 'foo'])); - } - - public function testDefaultParamDoesNotOverrideParamForAssembling() - { - $stack = new TreeRouteStack(); - $stack->addRoute('foo', new TestAsset\DummyRouteWithParam()); - $stack->setDefaultParam('foo', 'baz'); - - $this->assertEquals('bar', $stack->assemble(['foo' => 'bar'], ['name' => 'foo'])); - } - - public function testSetBaseUrl() - { - $stack = new TreeRouteStack(); - - $this->assertEquals($stack, $stack->setBaseUrl('/foo/')); - $this->assertEquals('/foo', $stack->getBaseUrl()); - } - - public function testSetRequestUri() - { - $uri = new HttpUri(); - $stack = new TreeRouteStack(); - - $this->assertEquals($stack, $stack->setRequestUri($uri)); - $this->assertEquals($uri, $stack->getRequestUri()); - } - - public function testPriorityIsPassedToPartRoute() - { - $stack = new TreeRouteStack(); - $stack->addRoutes([ - 'foo' => [ - 'type' => 'Literal', - 'priority' => 1000, - 'options' => [ - 'route' => '/foo', - 'defaults' => [ - 'controller' => 'foo', - ], - ], - 'may_terminate' => true, - 'child_routes' => [ - 'bar' => [ - 'type' => 'Literal', - 'options' => [ - 'route' => '/bar', - 'defaults' => [ - 'controller' => 'foo', - 'action' => 'bar', - ], - ], - ], - ], - ], - ]); - - $reflectedClass = new \ReflectionClass($stack); - $reflectedProperty = $reflectedClass->getProperty('routes'); - $reflectedProperty->setAccessible(true); - $routes = $reflectedProperty->getValue($stack); - - $this->assertEquals(1000, $routes->get('foo')->priority); - } - - public function testPrototypeRoute() - { - $stack = new TreeRouteStack(); - $stack->addPrototype( - 'bar', - ['type' => 'literal', 'options' => ['route' => '/bar']] - ); - $stack->addRoute('foo', 'bar'); - $this->assertEquals('/bar', $stack->assemble([], ['name' => 'foo'])); - } - - public function testChainRouteAssembling() - { - $stack = new TreeRouteStack(); - $stack->addPrototype( - 'bar', - ['type' => 'literal', 'options' => ['route' => '/bar']] - ); - $stack->addRoute( - 'foo', - [ - 'type' => 'literal', - 'options' => [ - 'route' => '/foo' - ], - 'chain_routes' => [ - 'bar' - ], - ] - ); - $this->assertEquals('/foo/bar', $stack->assemble([], ['name' => 'foo'])); - } - - public function testChainRouteAssemblingWithChildrenAndSecureScheme() - { - $stack = new TreeRouteStack(); - - $uri = new \Zend\Uri\Http(); - $uri->setHost('localhost'); - - $stack->setRequestUri($uri); - $stack->addRoute( - 'foo', - [ - 'type' => 'literal', - 'options' => [ - 'route' => '/foo' - ], - 'chain_routes' => [ - ['type' => 'scheme', 'options' => ['scheme' => 'https']] - ], - 'child_routes' => [ - 'baz' => [ - 'type' => 'literal', - 'options' => [ - 'route' => '/baz' - ], - ] - ] - ] - ); - $this->assertEquals('https://localhost/foo/baz', $stack->assemble([], ['name' => 'foo/baz'])); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - TreeRouteStack::class, - [], - [] - ); - } -} diff --git a/test/Http/WildcardTest.php b/test/Http/WildcardTest.php deleted file mode 100644 index a766868..0000000 --- a/test/Http/WildcardTest.php +++ /dev/null @@ -1,192 +0,0 @@ - [ - new Wildcard(), - '/foo/bar/baz/bat', - null, - ['foo' => 'bar', 'baz' => 'bat'] - ], - 'empty-match' => [ - new Wildcard(), - '', - null, - [] - ], - 'no-match-without-leading-delimiter' => [ - new Wildcard(), - '/foo/foo/bar/baz/bat', - 5, - null - ], - 'no-match-with-trailing-slash' => [ - new Wildcard(), - '/foo/bar/baz/bat/', - null, - null - ], - 'match-overrides-default' => [ - new Wildcard('/', '/', ['foo' => 'baz']), - '/foo/bat', - null, - ['foo' => 'bat'] - ], - 'offset-skips-beginning' => [ - new Wildcard(), - '/bat/foo/bar', - 4, - ['foo' => 'bar'] - ], - 'non-standard-key-value-delimiter' => [ - new Wildcard('-'), - '/foo-bar/baz-bat', - null, - ['foo' => 'bar', 'baz' => 'bat'] - ], - 'non-standard-parameter-delimiter' => [ - new Wildcard('/', '-'), - '/foo/-foo/bar-baz/bat', - 5, - ['foo' => 'bar', 'baz' => 'bat'] - ], - 'empty-values-with-non-standard-key-value-delimiter-are-omitted' => [ - new Wildcard('-'), - '/foo', - null, - [], - true - ], - 'url-encoded-parameters-are-decoded' => [ - new Wildcard(), - '/foo/foo%20bar', - null, - ['foo' => 'foo bar'] - ], - ]; - } - - /** - * @dataProvider routeProvider - * @param Wildcard $route - * @param string $path - * @param int $offset - * @param array $params - */ - public function testMatching(Wildcard $route, $path, $offset, array $params = null) - { - $request = new Request(); - $request->setUri('http://example.com' . $path); - $match = $route->match($request, $offset); - - if ($params === null) { - $this->assertNull($match); - } else { - $this->assertInstanceOf(RouteMatch::class, $match); - - if ($offset === null) { - $this->assertEquals(strlen($path), $match->getLength()); - } - - foreach ($params as $key => $value) { - $this->assertEquals($value, $match->getParam($key)); - } - } - } - - /** - * @dataProvider routeProvider - * @param Wildcard $route - * @param string $path - * @param int $offset - * @param array $params - * @param boolean $skipAssembling - */ - public function testAssembling(Wildcard $route, $path, $offset, array $params = null, $skipAssembling = false) - { - if ($params === null || $skipAssembling) { - // Data which will not match are not tested for assembling. - return; - } - - $result = $route->assemble($params); - - if ($offset !== null) { - $this->assertEquals($offset, strpos($path, $result, $offset)); - } else { - $this->assertEquals($path, $result); - } - } - - public function testNoMatchWithoutUriMethod() - { - $route = new Wildcard(); - $request = new BaseRequest(); - - $this->assertNull($route->match($request)); - } - - public function testGetAssembledParams() - { - $route = new Wildcard(); - $route->assemble(['foo' => 'bar']); - - $this->assertEquals(['foo'], $route->getAssembledParams()); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - Wildcard::class, - [], - [] - ); - } - - public function testRawDecode() - { - // verify all characters which don't absolutely require encoding pass through match unchanged - // this includes every character other than #, %, / and ? - $raw = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',.~!@$^&*()_+{}|:"<>'; - $request = new Request(); - $request->setUri('http://example.com/foo/' . $raw); - $route = new Wildcard(); - $match = $route->match($request); - - $this->assertSame($raw, $match->getParam('foo')); - } - - public function testEncodedDecode() - { - // @codingStandardsIgnoreStart - // every character - $in = '%61%62%63%64%65%66%67%68%69%6a%6b%6c%6d%6e%6f%70%71%72%73%74%75%76%77%78%79%7a%41%42%43%44%45%46%47%48%49%4a%4b%4c%4d%4e%4f%50%51%52%53%54%55%56%57%58%59%5a%30%31%32%33%34%35%36%37%38%39%60%2d%3d%5b%5d%5c%3b%27%2c%2e%2f%7e%21%40%23%24%25%5e%26%2a%28%29%5f%2b%7b%7d%7c%3a%22%3c%3e%3f'; - $out = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',./~!@#$%^&*()_+{}|:"<>?'; - // @codingStandardsIgnoreEnd - - $request = new Request(); - $request->setUri('http://example.com/foo/' . $in); - $route = new Wildcard(); - $match = $route->match($request); - - $this->assertSame($out, $match->getParam('foo')); - } -} diff --git a/test/PartialRouteResultTest.php b/test/PartialRouteResultTest.php new file mode 100644 index 0000000..bf38e73 --- /dev/null +++ b/test/PartialRouteResultTest.php @@ -0,0 +1,289 @@ +assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + $this->assertFalse($result->isSuccess()); + } + + public function testFromMethodFailure() + { + $methods = ['GET', 'POST']; + $result = PartialRouteResult::fromMethodFailure($methods, 10, 20); + $this->assertTrue($result->isFailure()); + $this->assertTrue($result->isMethodFailure()); + $this->assertFalse($result->isSuccess()); + $this->assertEquals($methods, $result->getAllowedMethods()); + $this->assertEquals(10, $result->getUsedPathOffset()); + $this->assertEquals(20, $result->getMatchedPathLength()); + } + + public function testFromMethodFailureDeduplicatesAndNormalizesHttpMethods() + { + $methods = ['GeT', 'get', 'POST', 'POST']; + $result = PartialRouteResult::fromMethodFailure($methods, 0, 0); + $this->assertEquals(['GET', 'POST'], $result->getAllowedMethods()); + } + + public function testFromMethodFailureRejectsNegativeOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $result = PartialRouteResult::fromMethodFailure(['GET'], -1, 0); + } + + public function testFromMethodFailureRejectsNegativeMatchedLength() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Matched path length cannot be negative'); + PartialRouteResult::fromMethodFailure(['GET'], 0, -1); + } + + /** + * Empty list can occur on allowed methods intersect in Part route. Eg when + * parent route allows only GET and child only POST. Route must handle + * such occurrence. + */ + public function testFromMethodFailureThrowsOnEmptyAllowedMethodsList() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Method failure requires list of allowed methods'); + PartialRouteResult::fromMethodFailure([], 10, 20); + } + + public function testFromRouteMatchIsSuccessful() + { + $result = PartialRouteResult::fromRouteMatch([], 0, 0); + $this->assertFalse($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + $this->assertTrue($result->isSuccess()); + } + + public function testFromRouteMatchSetsPathOffsetAndMatchedLength() + { + $result = PartialRouteResult::fromRouteMatch([], 10, 5); + $this->assertEquals(10, $result->getUsedPathOffset()); + $this->assertEquals(5, $result->getMatchedPathLength()); + } + + public function testFromRouteMatchWithNoRouteNameProvided() + { + $result = PartialRouteResult::fromRouteMatch([], 0, 0); + $this->assertNull($result->getMatchedRouteName()); + } + + public function testFromRouteMatchSetsMatchedRouteNameWhenProvided() + { + $result = PartialRouteResult::fromRouteMatch([], 0, 0, 'bar'); + $this->assertEquals('bar', $result->getMatchedRouteName()); + } + + public function testFromRouteMatchSetsMatchedParameters() + { + $params = ['foo' => 'bar']; + $result = PartialRouteResult::fromRouteMatch($params, 0, 0); + $this->assertEquals($params, $result->getMatchedParams()); + } + + public function testFromRouteMatchRejectsNegativeOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + PartialRouteResult::fromRouteMatch([], -1, 0); + } + + public function testFromRouteMatchRejectsNegativeMatchedLength() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Matched path length cannot be negative'); + PartialRouteResult::fromRouteMatch([], 0, -1); + } + + public function testWithRouteNameReplacesNameInNewInstance() + { + $result1 = PartialRouteResult::fromRouteMatch([], 0, 0, 'foo'); + $result2 = $result1->withMatchedRouteName('bar'); + $this->assertNotSame($result1, $result2); + $this->assertSame('foo', $result1->getMatchedRouteName()); + $this->assertSame('bar', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameRetainsPathOffsetAndMatchedLength() + { + $result1 = PartialRouteResult::fromRouteMatch([], 10, 5, 'foo'); + $result2 = $result1->withMatchedRouteName('bar'); + $this->assertEquals(10, $result2->getUsedPathOffset()); + $this->assertEquals(5, $result2->getMatchedPathLength()); + } + + public function testWithRouteNameWithPrependFlagPrependsNameToExisting() + { + $result1 = PartialRouteResult::fromRouteMatch([], 0, 0, 'foo'); + $result2 = $result1->withMatchedRouteName('bar', RouteResult::NAME_PREPEND); + $this->assertNotSame($result1, $result2); + $this->assertSame('foo', $result1->getMatchedRouteName()); + $this->assertSame('bar/foo', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameWithPrependFlagSetsNameWhenRouteNameIsNotSet() + { + $result1 = PartialRouteResult::fromRouteMatch([], 0, 0, null); + $result2 = $result1->withMatchedRouteName('bar', RouteResult::NAME_PREPEND); + $this->assertSame('bar', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameWithAppendFlagAppendsNameToExisting() + { + $result1 = PartialRouteResult::fromRouteMatch([], 0, 0, 'foo'); + $result2 = $result1->withMatchedRouteName('bar', RouteResult::NAME_APPEND); + $this->assertNotSame($result1, $result2); + $this->assertSame('foo', $result1->getMatchedRouteName()); + $this->assertSame('foo/bar', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameWithAppendFlagSetsNameWhenRouteNameIsNotSet() + { + $result1 = PartialRouteResult::fromRouteMatch([], 0, 0, null); + $result2 = $result1->withMatchedRouteName('bar', RouteResult::NAME_APPEND); + $this->assertSame('bar', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameThrowsForUnsuccessfulResult() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Only successful routing can have matched route name'); + $result = PartialRouteResult::fromRouteFailure(); + $result->withMatchedRouteName('foo'); + } + + public function testWithRouteNameRejectsEmptyName() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Route name cannot be empty'); + $result = PartialRouteResult::fromRouteMatch([], 0, 0, 'foo'); + $result->withMatchedRouteName(''); + } + + public function testWithRouteNameThrowsOnUnknownFlag() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown flag'); + $result = PartialRouteResult::fromRouteMatch([], 0, 0, 'foo'); + $result->withMatchedRouteName('bar', 'unknown'); + } + + public function testWithMatchedParamsReplacesInNewInstance() + { + $params1 = ['foo' => 'bar']; + $params2 = ['baz' => 'qux']; + $result1 = PartialRouteResult::fromRouteMatch($params1, 0, 0, null); + $result2 = $result1->withMatchedParams($params2); + $this->assertNotSame($result1, $result2); + $this->assertSame($params1, $result1->getMatchedParams()); + $this->assertSame($params2, $result2->getMatchedParams()); + } + + public function testWithMatchedParamsThrowsForUnsuccessfulResult() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Only successful routing can have matched params'); + $result = PartialRouteResult::fromRouteFailure(); + $result->withMatchedParams(['foo' => 'bar']); + } + + public function provideFullPathMatchData() : array + { + return [ + 'full match' => [ + new Uri('/foo'), + 0, + 4, + true, + ], + 'partial match' => [ + new Uri('/foo'), + 0, + 3, + false, + ], + 'offset full match' => [ + new Uri('/foo'), + 1, + 3, + true, + ], + 'offset partial match' => [ + new Uri('/foo/bar'), + 1, + 3, + false, + ], + 'empty uri path' => [ + new Uri(''), + 0, + 0, + true, + ], + ]; + } + + public function testMatchedAllowedMethodsAreNullByDefault() + { + $result = PartialRouteResult::fromRouteMatch([], 0, 0); + $this->assertNull($result->getMatchedAllowedMethods()); + } + + public function testMatchCouldProvideListOfAllowedMethods() + { + $result = PartialRouteResult::fromRouteMatch([], 0, 0, null, ['GET']); + $this->assertEquals(['GET'], $result->getMatchedAllowedMethods()); + } + + public function testWithMatchedAllowedMethodsProducesNewInstance() + { + $result = PartialRouteResult::fromRouteMatch([], 0, 0, null, ['GET']); + $result2 = $result->withMatchedAllowedMethods(['POST']); + $this->assertNotSame($result, $result2); + $this->assertEquals(['GET'], $result->getMatchedAllowedMethods()); + $this->assertEquals(['POST'], $result2->getMatchedAllowedMethods()); + } + + /** + * @dataProvider provideFullPathMatchData + */ + public function testIsFullPathMatch(Uri $uri, int $offset, int $length, bool $fullMatch) + { + $result = PartialRouteResult::fromRouteMatch([], $offset, $length); + $this->assertEquals($fullMatch, $result->isFullPathMatch($uri)); + } + + public function testIsNeverAFullPathMatchOnRouteFailure() + { + $uri = new Uri(''); + $result = PartialRouteResult::fromRouteFailure(); + $this->assertFalse($result->isFullPathMatch($uri)); + } +} diff --git a/test/PriorityListTest.php b/test/PriorityListTest.php index 9c5d61d..9de1cca 100644 --- a/test/PriorityListTest.php +++ b/test/PriorityListTest.php @@ -1,15 +1,20 @@ list->remove('foo'); + $this->addToAssertionCount(1); } public function testClear() diff --git a/test/Route/ChainTest.php b/test/Route/ChainTest.php new file mode 100644 index 0000000..4ff5431 --- /dev/null +++ b/test/Route/ChainTest.php @@ -0,0 +1,273 @@ + new Segment('/:controller', [], ['controller' => 'foo']), + 'bar' => new Segment('/:bar', [], ['bar' => 'bar']), + ]); + } + + public function getRouteWithOptionalParam() : Chain + { + return new Chain([ + 'foo' => new Segment('/:controller', [], ['controller' => 'foo']), + 'bar' => new Segment('[/:bar]', [], ['bar' => 'bar']), + ]); + } + + public function getRouteTestDefinitions() : iterable + { + $params = ['controller' => 'foo', 'bar' => 'bar']; + yield 'simple match' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 8) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['controller' => 'foo', 'bar' => 'bar']; + yield 'offset skips beginning' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/baz/foo/bar') + )) + ->usePathOffset(4) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 4, 8) + ) + ->shouldAssembleAndExpectResult(new Uri('/foo/bar')) + ->useParamsForAssemble($params); + + $params = ['controller' => 'foo', 'bar' => 'baz']; + yield 'parameters are used only once' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 8) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['controller' => 'foo', 'bar' => 'baz']; + yield 'optional parameter' => (new RouteTestDefinition( + $this->getRouteWithOptionalParam(), + new Uri('/foo/baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 8) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['controller' => 'foo', 'bar' => 'bar']; + yield 'optional parameter empty' => (new RouteTestDefinition( + $this->getRouteWithOptionalParam(), + new Uri('/foo') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['controller' => 'foo', 'bar' => 'bar']; + yield 'partial match' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/bar/baz') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 8) + ) + ->shouldAssembleAndExpectResult(new Uri('/foo/bar')) + ->useParamsForAssemble($params); + + $params = ['controller' => 'foo', 'bar' => 'bar']; + yield 'assemble appends path' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 8) + ) + ->shouldAssembleAndExpectResult(new Uri('/prefixed/foo/bar')) + ->useUriForAssemble(new Uri('/prefixed')) + ->useParamsForAssemble($params); + } + + public function testOnlyPartialRoutesAreAllowed() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Chain route can only chain partial routes'); + new Chain([ + 'foo' => $this->prophesize(RouteInterface::class)->reveal(), + ]); + } + + public function testAddRouteAllowsOnlyPartialRoute() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Chain route can only chain partial routes'); + (new Chain([]))->addRoute('foo', $this->prophesize(RouteInterface::class)->reveal()); + } + + public function testMethodFailureReturnsMethodFailureResult() + { + $request = new ServerRequest([], [], new Uri('/foo'), 'PUT'); + $route = new Chain([ + 'method' => new Method('GET,POST'), + 'literal' => new Literal('/foo'), + ]); + $result = $route->match($request); + $this->assertTrue($result->isMethodFailure()); + $this->assertArraySubset(['GET', 'POST'], $result->getAllowedMethods()); + $this->assertCount(2, $result->getAllowedMethods()); + } + + public function testMethodFailureReturnsMethodIntersection() + { + $request = new ServerRequest([], [], new Uri('/foo'), 'PUT'); + $route = new Chain([ + 'method1' => new Method('GET,POST'), + 'method2' => new Method('POST,DELETE'), + 'literal' => new Literal('/foo'), + ]); + $result = $route->match($request); + $this->assertTrue($result->isMethodFailure()); + $this->assertEquals(['POST'], $result->getAllowedMethods()); + } + + public function testMethodFailureWithMethodsNotIntersectingIsAFailure() + { + $request = new ServerRequest([], [], new Uri('/foo'), 'PUT'); + $route = new Chain([ + 'method1' => new Method('GET,POST'), + 'method2' => new Method('PUT,DELETE'), + 'literal' => new Literal('/foo'), + ]); + $result = $route->match($request); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } + + public function testMethodFailureReturnsFailureIfOtherRoutesFail() + { + $request = new ServerRequest([], [], new Uri('/foo'), 'PUT'); + $route = new Chain([ + 'method1' => new Method('GET,POST'), + 'literal' => new Literal('/bar'), + ]); + $result = $route->match($request); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } + + public function testGetAssembledParams() + { + $uri = new Uri(); + + /** @var Chain $route */ + $route = $this->getTestRoute(); + $route->assemble($uri, ['controller' => 'foo', 'bar' => 'baz', 'bat' => 'bat']); + + $this->assertEquals(['controller', 'bar'], $route->getLastAssembledParams()); + $this->assertEquals($route->getLastAssembledParams(), $route->getAssembledParams()); + } + + public function testFactory() + { + $tester = new FactoryTester($this); + $tester->testFactory( + Chain::class, + [ + 'routes' => 'Missing "routes" in options array', + ], + [ + 'routes' => [], + ] + ); + $tester->testFactory( + Chain::class, + [ + 'routes' => 'Missing "routes" in options array', + ], + [ + 'routes' => new ArrayObject(), + ] + ); + } + + public function testFactoryConvertsNumericKeysToString() + { + $chain = Chain::factory([ + 'routes' => [ + new Literal('/'), + new Literal('/'), + new Literal('/'), + ], + ]); + + $chained = $chain->getRoutes(); + $this->assertCount(3, $chained); + + foreach ($chained as $name => $route) { + $this->assertStringMatchesFormat('__chained_route_no_name_%d', $name); + } + } +} diff --git a/test/Route/HostnameTest.php b/test/Route/HostnameTest.php new file mode 100644 index 0000000..e4cdca4 --- /dev/null +++ b/test/Route/HostnameTest.php @@ -0,0 +1,485 @@ + 'bar']; + yield 'simple match' => (new RouteTestDefinition( + new Hostname(':foo.example.com'), + (new Uri())->withHost('bar.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'no match on different hostname' => (new RouteTestDefinition( + new Hostname('foo.example.com'), + (new Uri())->withHost('bar.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + yield 'no match with different number of parts' => (new RouteTestDefinition( + new Hostname('foo.example.com'), + (new Uri())->withHost('example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + yield 'no match with different number of parts 2' => (new RouteTestDefinition( + new Hostname('example.com'), + (new Uri())->withHost('foo.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + $params = ['foo' => 'bat']; + yield 'match overrides default' => (new RouteTestDefinition( + new Hostname(':foo.example.com', [], ['foo' => 'baz']), + (new Uri())->withHost('bat.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'constraints prevent match' => (new RouteTestDefinition( + new Hostname(':foo.example.com', ['foo' => '\d+']), + (new Uri())->withHost('bar.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + $params = ['foo' => '123']; + yield 'constraints allow match' => (new RouteTestDefinition( + new Hostname(':foo.example.com', ['foo' => '\d+']), + (new Uri())->withHost('123.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['domain' => 'mydomain']; + yield 'constraints allow match 2' => (new RouteTestDefinition( + new Hostname( + 'www.:domain.com', + ['domain' => '(mydomain|myaltdomain1|myaltdomain2)'], + ['domain' => 'mydomain'] + ), + (new Uri())->withHost('www.mydomain.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar']; + yield 'optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.]example.com'), + (new Uri())->withHost('bar.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'baz', 'bar' => 'bat']; + yield 'two optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.][:bar.]example.com'), + (new Uri())->withHost('baz.bat.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'missing optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.]example.com'), + (new Uri())->withHost('example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble([]); + + yield 'Assemble with optional parameter equal to null' => (new RouteTestDefinition( + new Hostname('[:foo.]example.com'), + (new Uri())->withHost('example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble(['foo' => null]); + + /* + * This case is left here as a reference of ambiguous use case. It should + * probably be fixed to provide more sensible behavior. + * + * There is a workaround, [[:foo.]:bar.] removes ambiguity. Fix could + * be by emulating such nesting for same level optional parts, but it + * might break other use cases + * + * $params = ['bar' => 'bat']; + * yield 'optional parameters evaluated right to left' => (new RouteTestDefinition( + * new Hostname('[:foo.][:bar.]example.com'), + * (new Uri())->withHost('bat.example.com') + * )) + * ->expectMatchResult( + * RouteResult::fromRouteMatch($params) + * ) + * ->expectPartialMatchResult( + * PartialRouteResult::fromRouteMatch($params, 0, 0) + * ) + * ->shouldAssembleAndExpectResultSameAsUriForMatching() + * ->useParamsForAssemble($params); + */ + + yield 'two missing optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.][:bar.]example.com'), + (new Uri())->withHost('example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching(); + + $params = ['foo' => 'baz', 'bar' => 'bat']; + yield 'two optional subdomain nested' => (new RouteTestDefinition( + new Hostname('[[:foo.]:bar.]example.com'), + (new Uri())->withHost('baz.bat.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['bar' => 'bat']; + yield 'one of two missing optional subdomain nested' => (new RouteTestDefinition( + new Hostname('[[:foo.]:bar.]example.com'), + (new Uri())->withHost('bat.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'two missing optional subdomain nested' => (new RouteTestDefinition( + new Hostname('[[:foo.]:bar.]example.com'), + (new Uri())->withHost('example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching(); + + yield 'no match on different hostname and optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.]example.com'), + (new Uri())->withHost('bar.test.com') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + yield 'no match with different number of parts and optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.]example.com'), + (new Uri())->withHost('bar.baz.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + $params = ['foo' => 'bat', 'bar' => 'qux']; + yield 'match overrides default optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.]:bar.example.com', [], ['bar' => 'baz']), + (new Uri())->withHost('bat.qux.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'constraints prevent match optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.]example.com', ['foo' => '\d+']), + (new Uri())->withHost('bar.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + $params = ['foo' => '123']; + yield 'constraints allow match optional subdomain' => (new RouteTestDefinition( + new Hostname('[:foo.]example.com', ['foo' => '\d+']), + (new Uri())->withHost('123.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'baz', 'bar' => 'bat']; + yield 'middle subdomain optional' => (new RouteTestDefinition( + new Hostname(':foo.[:bar.]example.com'), + (new Uri())->withHost('baz.bat.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + // @TODO Revisit this behavior. It looks error prone and may be dangerous + $params = ['foo' => 'baz']; + yield 'missing middle subdomain optional' => (new RouteTestDefinition( + new Hostname(':foo.[:bar.]example.com'), + (new Uri())->withHost('baz.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['username' => 'jdoe']; + yield 'non standard delimiter' => (new RouteTestDefinition( + new Hostname('user-:username.example.com'), + (new Uri())->withHost('user-jdoe.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['page' => 'article', 'username' => 'jdoe']; + yield 'non standard delimiter optional' => (new RouteTestDefinition( + new Hostname(':page{-}[-:username].example.com'), + (new Uri())->withHost('article-jdoe.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['page' => 'article']; + yield 'missing non standard delimiter optional' => (new RouteTestDefinition( + new Hostname(':page{-}[-:username].example.com'), + (new Uri())->withHost('article.example.com') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 0) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + } + + public function testOnAssembleLeftMostOptionalPartWithProvidedParameterMakesEverythingToTheRightRequired() + { + // @TODO further investigation needed. See todo for 'optional parameters evaluated right to left' + $this->markTestIncomplete(); + $route = new Hostname('[:foo][:bar].example.com'); + $uri = new Uri(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing parameter "bar"'); + $route->assemble($uri, ['foo' => 'baz']); + } + + public function testHostnameDefinitionWithEmptyParameterNameIsThrowing() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('empty parameter name'); + new Hostname(':.example.com'); + } + + public function testHostnameDefinitionWithUnpairedBracketsIsThrowing() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Found unbalanced brackets'); + new Hostname('[:foo[:bar].example.com'); + } + + public function testHostnameDefinitionWithClosingBracketAndMissingOpening() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Found closing bracket without matching opening bracket'); + new Hostname(':foo[:bar]].example.com'); + } + + public function testAssemblingWithMissingParameter() + { + $route = new Hostname(':foo.example.com'); + $uri = new Uri(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing parameter "foo"'); + $route->assemble($uri, []); + } + + public function testGetAssembledParams() + { + $route = new Hostname(':foo.example.com'); + $uri = new Uri(); + $route->assemble($uri, ['foo' => 'bar', 'baz' => 'bat']); + + $this->assertEquals(['foo'], $route->getLastAssembledParams()); + $this->assertEquals($route->getLastAssembledParams(), $route->getAssembledParams()); + } + + public function testFactory() + { + $tester = new FactoryTester($this); + $tester->testFactory( + Hostname::class, + [ + 'route' => 'Missing "route" in options array', + ], + [ + 'route' => 'example.com', + ] + ); + } + + public function testFailedHostnameSegmentMatchDoesNotEmitErrors() + { + $this->expectException(RuntimeException::class); + new Hostname(':subdomain.with_underscore.com'); + } + + public function testRejectsNegativePathOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $request = $this->prophesize(ServerRequestInterface::class); + $route = new Hostname('example.com'); + $route->partialMatch($request->reveal(), -1); + } +} diff --git a/test/Route/LiteralTest.php b/test/Route/LiteralTest.php new file mode 100644 index 0000000..7d5602b --- /dev/null +++ b/test/Route/LiteralTest.php @@ -0,0 +1,139 @@ + (new RouteTestDefinition( + new Literal('/foo'), + new Uri('/foo') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching(); + + yield 'no match without leading slash' => (new RouteTestDefinition( + new Literal('foo'), + new Uri('/foo') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + yield 'only partial match with trailing slash' => (new RouteTestDefinition( + new Literal('/foo'), + new Uri('/foo/') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 4) + ); + yield 'offset skips beginning' => (new RouteTestDefinition( + new Literal('foo'), + new Uri('/foo') + )) + ->usePathOffset(1) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 1, 3) + ); + yield 'offset does not prevent partial match' => (new RouteTestDefinition( + new Literal('foo'), + new Uri('/foo/bar') + )) + ->usePathOffset(1) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 1, 3) + ); + yield 'assemble appends to path present in provided uri' => (new RouteTestDefinition( + new Literal('/foo'), + new Uri('/foo') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 4) + ) + ->useUriForAssemble(new Uri('/bar')) + ->shouldAssembleAndExpectResult(new Uri('/bar/foo')); + } + + public function testGetAssembledParams() + { + $uri = new Uri(); + $route = new Literal('/foo'); + $route->assemble($uri, ['foo' => 'bar']); + + $this->assertEquals([], $route->getLastAssembledParams()); + $this->assertEquals($route->getLastAssembledParams(), $route->getAssembledParams()); + } + + public function testFactory() + { + $tester = new FactoryTester($this); + $tester->testFactory( + Literal::class, + [ + 'route' => 'Missing "route" in options array', + ], + [ + 'route' => '/foo', + ] + ); + } + + public function testEmptyLiteral() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Literal uri path part cannot be empty'); + new Literal(''); + } + + public function testRejectsNegativePathOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $request = $this->prophesize(ServerRequestInterface::class); + $route = new Literal('/foo'); + $route->partialMatch($request->reveal(), -1); + } +} diff --git a/test/Route/MethodTest.php b/test/Route/MethodTest.php new file mode 100644 index 0000000..18d1224 --- /dev/null +++ b/test/Route/MethodTest.php @@ -0,0 +1,131 @@ + (new RouteTestDefinition( + new Method('GET'), + $request->withMethod('GET') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 0, null, ['GET']) + ); + + yield 'match comma separated verbs' => (new RouteTestDefinition( + new Method('get,post'), + $request->withMethod('POST') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 0, null, ['GET', 'POST']) + ); + + yield 'match comma separated verbs with whitespace' => (new RouteTestDefinition( + new Method('get , post , put'), + $request->withMethod('POST') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 0, null, ['GET', 'POST', 'NULL']) + ); + + yield 'match ignores case' => (new RouteTestDefinition( + new Method('Get'), + $request->withMethod('get') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 0, null, ['GET']) + ); + + yield 'no match gives list of allowed methods' => (new RouteTestDefinition( + new Method('POST,PUT,DELETE'), + $request->withMethod('GET') + )) + ->expectMatchResult( + RouteResult::fromMethodFailure(['POST', 'PUT', 'DELETE']) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromMethodFailure(['POST', 'PUT', 'DELETE'], 0, 0) + ); + } + + public function testAssembleSimplyReturnsPassedUri() + { + $uri = new Uri(); + $method = new Method('get'); + + $this->assertSame($uri, $method->assemble($uri)); + } + + public function testSetsAllowedMethodsOnMatch() + { + $request = new ServerRequest([], [], null, 'GET'); + $method = new Method('GET'); + $result = $method->partialMatch($request); + + $this->assertTrue($result->isSuccess()); + $this->assertEquals(['GET'], $result->getMatchedAllowedMethods()); + } + + public function testFactory() + { + $tester = new FactoryTester($this); + $tester->testFactory( + Method::class, + [ + 'verb' => 'Missing "verb" in options array', + ], + [ + 'verb' => 'get', + ] + ); + } + + public function testRejectsNegativePathOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $request = $this->prophesize(ServerRequestInterface::class); + $route = new Method('GET'); + $route->partialMatch($request->reveal(), -1); + } +} diff --git a/test/Route/PartTest.php b/test/Route/PartTest.php new file mode 100644 index 0000000..68a2679 --- /dev/null +++ b/test/Route/PartTest.php @@ -0,0 +1,374 @@ + new Literal('/foo', ['controller' => 'foo']), + 'child_routes' => [ + 'bar' => new Literal('/bar', ['controller' => 'bar']), + 'baz' => Part::factory([ + 'route' => new Literal('/baz'), + 'child_routes' => [ + 'bat' => new Segment('/:controller'), + ], + ]), + 'bat' => Part::factory([ + 'route' => new Segment('/bat[/:foo]', [], ['foo' => 'bar']), + 'may_terminate' => true, + 'child_routes' => [ + 'literal' => new Literal('/bar'), + 'optional' => new Segment('/bat[/:bar]'), + ], + ]), + ], + 'may_terminate' => true, + ]); + } + + public function getRouteAlternative() : Part + { + return new Part( + new Segment('/[:controller[/:action]]', [], [ + 'controller' => 'fo-fo', + 'action' => 'index', + ]), + new TreeRouteStack(), + true + ); + } + + public function getRouteTestDefinitions() : iterable + { + $params = ['controller' => 'foo']; + yield 'simple match' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['controller' => 'foo']; + yield 'offset-skips-beginning' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/bar/foo') + )) + ->usePathOffset(4) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->shouldAssembleAndExpectResult(new Uri('/foo')) + ->useParamsForAssemble($params); + + $params = ['controller' => 'bar']; + yield 'simple child match' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params, 'bar') + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params) + ->useOptionsForAssemble(['name' => 'bar']); + + yield 'non terminating part does not match' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/baz') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ); + + $params = ['controller' => 'bat']; + yield 'child of non terminating part does match' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/baz/bat') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params, 'baz/bat') + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params) + ->useOptionsForAssemble(['name' => 'baz/bat']); + + $params = ['controller' => 'foo', 'foo' => 'bar']; + yield 'optional parameters are dropped without child' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/bat') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params, 'bat') + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params) + ->useOptionsForAssemble(['name' => 'bat']); + + $params = ['controller' => 'foo', 'foo' => 'bar']; + yield 'optional parameters are not dropped with child' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/bat/bar/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params, 'bat/literal') + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params) + ->useOptionsForAssemble(['name' => 'bat/literal']); + + $params = ['controller' => 'foo', 'foo' => 'bar']; + yield 'optional parameters not required in last part' => (new RouteTestDefinition( + $this->getTestRoute(), + new Uri('/foo/bat/bar/bat') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params, 'bat/optional') + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params) + ->useOptionsForAssemble(['name' => 'bat/optional']); + + $params = ['controller' => 'fo-fo', 'action' => 'index']; + yield 'simple match 2' => (new RouteTestDefinition( + $this->getRouteAlternative(), + new Uri('/') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + } + + public function testAssembleNonTerminatedRoute() + { + $uri = new Uri(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Part route may not terminate'); + $this->getTestRoute()->assemble($uri, [], ['name' => 'baz']); + } + + public function testMethodFailureReturnsMethodFailureOnTerminatedMatch() + { + $options = [ + 'route' => new Method('GET,POST'), + 'may_terminate' => true, + ]; + + $route = Part::factory($options); + + $request = new ServerRequest([], [], new Uri('/foo'), 'PUT'); + $result = $route->match($request, 4); + $this->assertTrue($result->isMethodFailure()); + $this->assertArraySubset(['GET', 'POST'], $result->getAllowedMethods()); + $this->assertCount(2, $result->getAllowedMethods()); + } + + public function testMethodFailureReturnsMethodFailureOnFullPathMatch() + { + $options = [ + 'route' => new Method('GET,POST'), + 'may_terminate' => true, + 'child_routes' => [ + 'foo' => new Literal('/foo'), + ], + ]; + + $route = Part::factory($options); + + $request = new ServerRequest([], [], new Uri('/foo'), 'PUT'); + $result = $route->match($request, 0); + $this->assertTrue($result->isMethodFailure()); + $this->assertArraySubset(['GET', 'POST'], $result->getAllowedMethods()); + $this->assertCount(2, $result->getAllowedMethods()); + } + + public function testMethodFailureReturnsFailureIfChildRoutesFail() + { + $options = [ + 'route' => new Method('GET,POST'), + 'may_terminate' => true, + 'child_routes' => [ + 'foo' => new Literal('/foo'), + ], + ]; + $route = Part::factory($options); + + $request = new ServerRequest([], [], new Uri('/bar'), 'PUT'); + $result = $route->match($request, 0); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } + + public function testMethodFailureReturnsMethodIntersectionBetweenPartialAndChildRoutes() + { + $options = [ + 'route' => new Method('GET,POST'), + 'may_terminate' => true, + 'child_routes' => [ + 'foo' => Part::factory([ + 'route' => new Literal('/foo'), + 'child_routes' => [ + 'verb' => new Method('POST,DELETE'), + ], + ]), + ], + ]; + + $route = Part::factory($options); + + $request = new ServerRequest([], [], new Uri('/foo'), 'PUT'); + $result = $route->match($request, 0); + $this->assertTrue($result->isMethodFailure()); + $this->assertEquals(['POST'], $result->getAllowedMethods()); + } + + public function testMethodFailureWithChildMethodsNotIntersectingIsAFailure() + { + $options = [ + 'route' => new Method('GET,POST'), + 'may_terminate' => true, + 'child_routes' => [ + 'foo' => Part::factory([ + 'route' => new Literal('/foo'), + 'child_routes' => [ + 'verb' => new Method('DELETE,OPTIONS'), + ], + ]), + ], + ]; + + $route = Part::factory($options); + + $request = new ServerRequest([], [], new Uri('/foo'), 'PUT'); + $result = $route->match($request, 0); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } + + public function testChildMethodFailureWithParentPartSuccessReturnsFullListOfMethods() + { + $options = [ + 'route' => new Method('GET,POST,DELETE'), + 'may_terminate' => true, + 'child_routes' => [ + 'foo' => Part::factory([ + 'route' => new Literal('/foo'), + 'child_routes' => [ + 'verb' => new Method('POST,DELETE,OPTIONS'), + ], + ]), + ], + ]; + + $route = Part::factory($options); + + $request = new ServerRequest([], [], new Uri('/foo'), 'GET'); + $result = $route->match($request, 0); + $this->assertTrue($result->isMethodFailure()); + $this->assertEquals(['POST', 'DELETE'], $result->getAllowedMethods()); + } + + public function testParentMethodFailureWithChildSuccessReturnsFullListOfMethods() + { + $options = [ + 'route' => new Method('GET,POST,DELETE'), + 'may_terminate' => true, + 'child_routes' => [ + 'foo' => Part::factory([ + 'route' => new Literal('/foo'), + 'child_routes' => [ + 'verb' => new Method('DELETE,OPTIONS'), + ], + ]), + ], + ]; + + $route = Part::factory($options); + + $request = new ServerRequest([], [], new Uri('/foo'), 'OPTIONS'); + $result = $route->match($request, 0); + $this->assertTrue($result->isMethodFailure()); + $this->assertEquals(['DELETE'], $result->getAllowedMethods()); + } + + public function testFactory() + { + $tester = new FactoryTester($this); + $tester->testFactory( + Part::class, + [ + 'route' => 'Missing "route" in options array', + ], + [ + 'route' => new Literal('/foo'), + ] + ); + } + + /** + * @group 3711 + */ + public function testPartRouteMarkedAsMayTerminateCanMatchWhenQueryStringPresent() + { + $options = [ + 'route' => new Literal('/resource', ['controller' => 'ResourceController', 'action' => 'resource']), + 'may_terminate' => true, + 'child_routes' => [ + 'child' => new Literal('/child'), + ], + ]; + + $route = Part::factory($options); + $request = new ServerRequest([], [], new Uri('http://example.com/resource?foo=bar')); + $request = $request->withQueryParams(['foo' => 'bar']); + + $result = $route->match($request); + $this->assertTrue($result->isSuccess()); + $this->assertEquals('resource', $result->getMatchedParams()['action']); + } + + public function testRejectsNegativePathOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $partial = $this->prophesize(PartialRouteInterface::class); + $request = $this->prophesize(ServerRequestInterface::class); + $route = new Part($partial->reveal(), new TreeRouteStack(), false); + $route->match($request->reveal(), -1); + } +} diff --git a/test/Route/PartialRouteTestTrait.php b/test/Route/PartialRouteTestTrait.php new file mode 100644 index 0000000..a9132d5 --- /dev/null +++ b/test/Route/PartialRouteTestTrait.php @@ -0,0 +1,111 @@ +getRouteTestDefinitions(); + foreach ($definitions as $description => $definition) { + /** + * @var RouteTestDefinition $definition + */ + $data[$description] = [ + $definition->getRoute(), + $definition->getRequestToMatch(), + $definition->getPathOffset(), + $definition->getMatchOptions(), + $definition->getExpectedPartialMatchResult(), + ]; + } + return $data; + } + + /** + * We use callback instead of route instance so that we can get coverage + * for all route configuration combinations. + * + * @dataProvider partialRouteMatchingProvider + */ + public function testPartialMatching( + PartialRouteInterface $route, + Request $request, + int $pathOffset, + array $matchOptions, + PartialRouteResult $expectedResult + ) { + $result = $route->partialMatch($request, $pathOffset, $matchOptions); + + if ($expectedResult->isSuccess()) { + $this->assertTrue($result->isSuccess(), 'Expected successful routing'); + $expectedParams = $expectedResult->getMatchedParams(); + ksort($expectedParams); + $actualParams = $result->getMatchedParams(); + ksort($expectedParams); + $this->assertEquals($expectedParams, $actualParams, 'Matched parameters do not meet test expectation'); + + $this->assertSame( + $expectedResult->getMatchedRouteName(), + $result->getMatchedRouteName(), + 'Expected matched route name do not meet test expectation' + ); + $this->assertEquals( + $expectedResult->getMatchedPathLength(), + $result->getMatchedPathLength(), + 'Expected path match length does not meet test expectation' + ); + $this->assertEquals( + $expectedResult->getUsedPathOffset(), + $result->getUsedPathOffset(), + 'Expected path offset does not meet test expectation' + ); + } + if ($expectedResult->isFailure()) { + $this->assertTrue($result->isFailure(), 'Failed routing is expected'); + } + if ($expectedResult->isMethodFailure()) { + $this->assertTrue($result->isMethodFailure(), 'Http method routing failure is expected'); + + $expectedMethods = $expectedResult->getAllowedMethods(); + sort($expectedMethods); + $actualMethods = $result->getAllowedMethods(); + sort($actualMethods); + + $this->assertEquals($expectedMethods, $actualMethods, 'Allowed http methods do not match expectation'); + + $this->assertEquals( + $expectedResult->getMatchedPathLength(), + $result->getMatchedPathLength(), + 'Expected path match length does not meet test expectation' + ); + $this->assertEquals( + $expectedResult->getUsedPathOffset(), + $result->getUsedPathOffset(), + 'Expected path offset does not meet test expectation' + ); + } + } +} diff --git a/test/Route/PartialRouteTraitTest.php b/test/Route/PartialRouteTraitTest.php new file mode 100644 index 0000000..c8838dd --- /dev/null +++ b/test/Route/PartialRouteTraitTest.php @@ -0,0 +1,177 @@ +request = new ServerRequest([], [], new Uri('/path')); + $this->partial = new class() implements PartialRouteInterface { + use PartialRouteTrait; + + /** + * @var ObjectProphecy + */ + public $prophecy; + + public function partialMatch( + ServerRequestInterface $request, + int $pathOffset = 0, + array $options = [] + ) : PartialRouteResult { + return $this->prophecy->reveal()->partialMatch($request, $pathOffset, $options); + } + + public function getLastAssembledParams() : array + { + return $this->prophecy->reveal()->getLastAssembledParams(); + } + + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface + { + return $this->prophecy->reveal()->assemble($uri, $params, $options); + } + }; + + $this->partial->prophecy = $this->prophesize(PartialRouteInterface::class); + } + + public function testInvokesPartialMatchWithMatchParameters() + { + $partialResult = PartialRouteResult::fromRouteMatch([], 5, 0); + $this->partial->prophecy + ->partialMatch($this->request, 5, ['foo' => 'bar']) + ->shouldBeCalled() + ->willReturn($partialResult); + + $this->partial->match($this->request, 5, ['foo' => 'bar']); + } + + public function testRejectsNegativePathOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $this->partial->match($this->request, -1); + } + + public function testReturnsSuccessOnPartialRouteMatchWithFullPathMatch() + { + $pathLength = strlen($this->request->getUri()->getPath()); + $this->partial->prophecy + ->partialMatch($this->request, 0, []) + ->shouldBeCalled() + ->willReturn(PartialRouteResult::fromRouteMatch([], 0, $pathLength)); + + $result = $this->partial->match($this->request); + $this->assertTrue($result->isSuccess()); + } + + public function testReturnsParametersAndRouteNameFromPartialRouteMatch() + { + $pathLength = strlen($this->request->getUri()->getPath()); + $this->partial->prophecy + ->partialMatch($this->request, 0, []) + ->shouldBeCalled() + ->willReturn(PartialRouteResult::fromRouteMatch(['foo' => 'bar'], 0, $pathLength, 'routename')); + + $result = $this->partial->match($this->request); + $this->assertEquals(['foo' => 'bar'], $result->getMatchedParams()); + $this->assertEquals('routename', $result->getMatchedRouteName()); + } + + public function testReturnsFailureOnPartialRouteMatchWithPartialPathMatch() + { + $pathLength = strlen($this->request->getUri()->getPath()); + $this->partial->prophecy + ->partialMatch($this->request, 0, []) + ->shouldBeCalled() + ->willReturn(PartialRouteResult::fromRouteMatch([], 0, $pathLength - 1)); + + $result = $this->partial->match($this->request); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } + + public function testReturnsFailureOnPartialFailure() + { + $this->partial->prophecy + ->partialMatch($this->request, 0, []) + ->shouldBeCalled() + ->willReturn(PartialRouteResult::fromRouteFailure()); + $result = $this->partial->match($this->request); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } + + public function testReturnsFailureOnPartialFailureWithFullPathMatch() + { + $pathLength = strlen($this->request->getUri()->getPath()); + $this->partial->prophecy + ->partialMatch($this->request, $pathLength, []) + ->shouldBeCalled() + ->willReturn(PartialRouteResult::fromRouteFailure()); + $result = $this->partial->match($this->request, $pathLength); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } + + public function testReturnsMethodFailureOnPartialMethodFailureWithFullPathMatch() + { + $pathLength = strlen($this->request->getUri()->getPath()); + $this->partial->prophecy + ->partialMatch($this->request, 0, []) + ->shouldBeCalled() + ->willReturn(PartialRouteResult::fromMethodFailure(['GET', 'POST'], 0, $pathLength)); + + $result = $this->partial->match($this->request); + $this->assertTrue($result->isFailure()); + $this->assertTrue($result->isMethodFailure()); + $this->assertEquals(['GET', 'POST'], $result->getAllowedMethods()); + } + + public function testReturnsFailureOnPartialMethodFailure() + { + $pathLength = strlen($this->request->getUri()->getPath()); + $this->partial->prophecy + ->partialMatch($this->request, 0, []) + ->shouldBeCalled() + ->willReturn(PartialRouteResult::fromMethodFailure(['GET', 'POST'], 0, $pathLength - 1)); + $result = $this->partial->match($this->request, 0, []); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } +} diff --git a/test/Route/RegexTest.php b/test/Route/RegexTest.php new file mode 100644 index 0000000..de78689 --- /dev/null +++ b/test/Route/RegexTest.php @@ -0,0 +1,175 @@ + 'bar']; + yield 'simple match' => (new RouteTestDefinition( + new Regex('/(?[^/]+)', '/%foo%'), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'no match without leading slash' => (new RouteTestDefinition( + new Regex('(?[^/]+)', '%foo%'), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + yield 'only partial match with trailing slash' => (new RouteTestDefinition( + new Regex('/(?[^/]+)', '/%foo%'), + new Uri('/bar/') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch(['foo' => 'bar'], 0, 4) + ); + + $params = ['foo' => 'bar']; + yield 'offset skips beginning' => (new RouteTestDefinition( + new Regex('(?[^/]+)', '%foo%'), + new Uri('/bar') + )) + ->usePathOffset(1) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 1, 3) + ) + ->shouldAssembleAndExpectResult(new Uri('bar')) + ->useParamsForAssemble($params); + + $params = ['foo' => 'foo bar']; + yield 'url encoded parameters are decoded' => (new RouteTestDefinition( + new Regex('/(?[^/]+)', '/%foo%'), + new Uri('/foo%20bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 10) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['bar' => 'bar', 'baz' => 'baz']; + yield 'empty matches are replaced with defaults' => (new RouteTestDefinition( + new Regex('/foo(?:/(?[^/]+))?/baz-(?[^/]+)', '/foo/baz-%baz%', ['bar' => 'bar']), + new Uri('/foo/baz-baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 12) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + } + + public function testGetAssembledParams() + { + $uri = new Uri(); + $route = new Regex('/(?.+)', '/%foo%'); + $route->assemble($uri, ['foo' => 'bar', 'baz' => 'bat']); + + $this->assertEquals(['foo'], $route->getLastAssembledParams()); + $this->assertEquals($route->getLastAssembledParams(), $route->getAssembledParams()); + } + + public function testFactory() + { + $tester = new FactoryTester($this); + $tester->testFactory( + Regex::class, + [ + 'regex' => 'Missing "regex" in options array', + 'spec' => 'Missing "spec" in options array', + ], + [ + 'regex' => '/foo', + 'spec' => '/foo', + ] + ); + } + + public function testRawDecode() + { + // verify all characters which don't absolutely require encoding pass through match unchanged + // this includes every character other than #, %, / and ? + $raw = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',.~!@$^&*()_+{}|:"<>'; + $request = new ServerRequest([], [], new Uri('http://example.com/' . $raw)); + $route = new Regex('/(?[^/]+)', '/%foo%'); + $result = $route->match($request); + + $this->assertTrue($result->isSuccess()); + $this->assertSame($raw, $result->getMatchedParams()['foo']); + } + + public function testEncodedDecode() + { + // @codingStandardsIgnoreStart + // every character + $in = '%61%62%63%64%65%66%67%68%69%6a%6b%6c%6d%6e%6f%70%71%72%73%74%75%76%77%78%79%7a%41%42%43%44%45%46%47%48%49%4a%4b%4c%4d%4e%4f%50%51%52%53%54%55%56%57%58%59%5a%30%31%32%33%34%35%36%37%38%39%60%2d%3d%5b%5d%5c%3b%27%2c%2e%2f%7e%21%40%23%24%25%5e%26%2a%28%29%5f%2b%7b%7d%7c%3a%22%3c%3e%3f'; + $out = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',./~!@#$%^&*()_+{}|:"<>?'; + // @codingStandardsIgnoreEnd + + $request = new ServerRequest([], [], new Uri('http://example.com/' . $in)); + $route = new Regex('/(?[^/]+)', '/%foo%'); + $result = $route->match($request); + + $this->assertSame($out, $result->getMatchedParams()['foo']); + } + + public function testRejectsNegativePathOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $request = $this->prophesize(ServerRequestInterface::class); + $route = new Regex('/foo', '/%foo%'); + $route->partialMatch($request->reveal(), -1); + } +} diff --git a/test/Route/RouteTestTrait.php b/test/Route/RouteTestTrait.php new file mode 100644 index 0000000..07fb625 --- /dev/null +++ b/test/Route/RouteTestTrait.php @@ -0,0 +1,129 @@ +getRouteTestDefinitions(); + foreach ($definitions as $description => $definition) { + /** + * @var RouteTestDefinition $definition + */ + yield $description => [ + $definition->getRoute(), + $definition->getRequestToMatch(), + $definition->getPathOffset(), + $definition->getMatchOptions(), + $definition->getExpectedMatchResult(), + ]; + } + } + + /** + * We use callback instead of route instance so that we can get coverage + * for all route configuration combinations. + * + * @dataProvider routeMatchingProvider + */ + public function testMatching( + RouteInterface $route, + Request $request, + int $pathOffset, + array $matchOptions, + RouteResult $expectedResult + ) { + $result = $route->match($request, $pathOffset, $matchOptions); + + if ($expectedResult->isSuccess()) { + $this->assertTrue($result->isSuccess(), 'Expected successful routing'); + $expectedParams = $expectedResult->getMatchedParams(); + ksort($expectedParams); + $actualParams = $result->getMatchedParams(); + ksort($expectedParams); + $this->assertEquals($expectedParams, $actualParams, 'Matched parameters do not meet test expectation'); + + $this->assertSame( + $expectedResult->getMatchedRouteName(), + $result->getMatchedRouteName(), + 'Expected matched route name do not meet test expectation' + ); + } + if ($expectedResult->isFailure()) { + $this->assertTrue($result->isFailure(), 'Failed routing is expected'); + } + if ($expectedResult->isMethodFailure()) { + $this->assertTrue($result->isMethodFailure(), 'Http method routing failure is expected'); + + $expectedMethods = $expectedResult->getAllowedMethods(); + sort($expectedMethods); + $actualMethods = $result->getAllowedMethods(); + sort($actualMethods); + + $this->assertEquals($expectedMethods, $actualMethods, 'Allowed http methods do not match expectation'); + } + } + + /** + * @uses self::getRouteTestDefinitions() provided definitions to prepare and + * provide data for route assembling uri test + */ + public function routeUriAssemblingProvider() : iterable + { + $definitions = $this->getRouteTestDefinitions(); + foreach ($definitions as $description => $definition) { + /** + * @var RouteTestDefinition $definition + */ + $assembleResult = $definition->getExpectedAssembleResult(); + if (null === $assembleResult) { + continue; + } + yield $description => [ + $definition->getRoute(), + $definition->getUriForAssemble(), + $definition->getParamsForAssemble(), + $definition->getOptionsForAssemble(), + $assembleResult, + ]; + } + } + + /** + * @dataProvider routeUriAssemblingProvider + */ + public function testAssembling( + RouteInterface $route, + UriInterface $uriForAssemble, + array $params, + array $options, + UriInterface $expectedUri + ) { + $uri = $route->assemble($uriForAssemble, $params, $options); + + $this->assertEquals($expectedUri->__toString(), $uri->__toString()); + } +} diff --git a/test/Route/SchemeTest.php b/test/Route/SchemeTest.php new file mode 100644 index 0000000..24f6860 --- /dev/null +++ b/test/Route/SchemeTest.php @@ -0,0 +1,106 @@ +request = new ServerRequest([], [], null, null, 'php://memory'); + } + + public function testMatching() + { + $request = $this->request->withUri((new Uri())->withScheme('https')); + + $route = new Scheme('https'); + $result = $route->match($request); + + $this->assertTrue($result->isSuccess()); + } + + public function testMatchReturnsResultWithDefaultParameters() + { + $request = $this->request->withUri((new Uri())->withScheme('https')); + + $route = new Scheme('https', ['foo' => 'bar']); + $result = $route->match($request); + + $this->assertEquals(['foo' => 'bar'], $result->getMatchedParams()); + } + + public function testNoMatchingOnDifferentScheme() + { + $request = $this->request->withUri((new Uri())->withScheme('http')); + + $route = new Scheme('https'); + $result = $route->match($request); + + $this->assertTrue($result->isFailure()); + } + + public function testAssembling() + { + $uri = new Uri(); + $route = new Scheme('https'); + $resultUri = $route->assemble($uri); + + $this->assertEquals('https', $resultUri->getScheme()); + } + + public function testGetAssembledParams() + { + $uri = new Uri(); + $route = new Scheme('https'); + $route->assemble($uri, ['foo' => 'bar']); + + $this->assertEquals([], $route->getLastAssembledParams()); + $this->assertEquals($route->getLastAssembledParams(), $route->getAssembledParams()); + } + + public function testFactory() + { + $tester = new FactoryTester($this); + $tester->testFactory( + Scheme::class, + [ + 'scheme' => 'Missing "scheme" in options array', + ], + [ + 'scheme' => 'http', + ] + ); + } + + public function testRejectsNegativePathOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $request = $this->prophesize(ServerRequestInterface::class); + $route = new Scheme('https'); + $route->partialMatch($request->reveal(), -1); + } +} diff --git a/test/Route/SegmentTest.php b/test/Route/SegmentTest.php new file mode 100644 index 0000000..183e448 --- /dev/null +++ b/test/Route/SegmentTest.php @@ -0,0 +1,591 @@ + 'bar']; + yield 'simple match' => (new RouteTestDefinition( + new Segment('/:foo'), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'no match without leading slash' => (new RouteTestDefinition( + new Segment(':foo'), + new Uri('/bar/') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + yield 'partial match with trailing slash' => (new RouteTestDefinition( + new Segment('/:foo'), + new Uri('/bar/') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch(['foo' => 'bar'], 0, 4) + ); + + $params = ['foo' => 'bar']; + yield 'offset skips beginning' => (new RouteTestDefinition( + new Segment(':foo'), + new Uri('/bar') + )) + ->usePathOffset(1) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 1, 3) + ); + + $params = ['foo' => 'bar', 'baz' => 'qux']; + yield 'match merges default parameters' => (new RouteTestDefinition( + new Segment('/:foo', [], ['baz' => 'qux']), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar']; + yield 'match overrides default parameters' => (new RouteTestDefinition( + new Segment('/:foo', [], ['foo' => 'baz']), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'constraints prevent match' => (new RouteTestDefinition( + new Segment('/:foo', ['foo' => '\d+']), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteFailure() + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteFailure() + ); + + $params = ['foo' => '123']; + yield 'constraints allow match' => (new RouteTestDefinition( + new Segment('/:foo', ['foo' => '\d+']), + new Uri('/123') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'foo-bar']; + yield 'constraints override non standard delimiter' => (new RouteTestDefinition( + new Segment('/:foo{-}/bar', ['foo' => '[^/]+']), + new Uri('/foo-bar/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar', 'bar' => 'baz']; + yield 'constraints with parentheses dont break parameter map' => (new RouteTestDefinition( + new Segment('/:foo/:bar', ['foo' => '(bar)']), + new Uri('/bar/baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield 'simple match with optional parameter' => (new RouteTestDefinition( + new Segment('/[:foo]', [], ['foo' => 'bar']), + new Uri('/') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch(['foo' => 'bar']) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch(['foo' => 'bar'], 0, 1) + ); + + yield 'optional parameter is ignored' => (new RouteTestDefinition( + new Segment('/:foo[/:baz]'), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch(['foo' => 'bar']) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch(['foo' => 'bar'], 0, 4) + ); + + $params = ['foo' => 'bar', 'bar' => 'baz']; + yield 'optional parameter is provided with default' => (new RouteTestDefinition( + new Segment('/:foo[/:bar]', [], ['bar' => 'baz']), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar', 'bar' => 'baz']; + yield 'optional parameter is consumed' => (new RouteTestDefinition( + new Segment('/:foo[/:bar]'), + new Uri('/bar/baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar', 'bar' => 'baz']; + yield 'optional group is discared with missing parameter' => (new RouteTestDefinition( + new Segment('/:foo[/:bar/:baz]', [], ['bar' => 'baz']), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar', 'bar' => 'baz', 'baz' => 'bat']; + yield 'optional group within optional group is ignored' => (new RouteTestDefinition( + new Segment('/:foo[/:bar[/:baz]]', [], ['bar' => 'baz', 'baz' => 'bat']), + new Uri('/bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['bar' => 'baz']; + yield 'non standard delimiter before parameter' => (new RouteTestDefinition( + new Segment('/foo-:bar'), + new Uri('/foo-baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar', 'bar' => 'baz']; + yield 'non standard delimiter between parameters' => (new RouteTestDefinition( + new Segment('/:foo{-}-:bar'), + new Uri('/bar-baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar', 'bar' => 'baz', 'baz' => 'bat']; + yield 'non standard delimiter before optional parameter' => (new RouteTestDefinition( + new Segment('/:foo{-/}[-:bar]/:baz'), + new Uri('/bar-baz/bat') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'bar', 'baz' => 'bat']; + yield 'non standard delimiter before ignored optional parameter' => (new RouteTestDefinition( + new Segment('/:foo{-/}[-:bar]/:baz'), + new Uri('/bar/bat') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo-bar' => 'baz']; + yield 'parameter with dash in name' => (new RouteTestDefinition( + new Segment('/:foo-bar'), + new Uri('/baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => 'foo bar']; + yield 'url encoded parameters are decoded' => (new RouteTestDefinition( + new Segment('/:foo'), + new Uri('/foo%20bar') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['foo' => "!$&'()*,-.:;=@_~+"]; + yield 'urlencode flaws corrected' => (new RouteTestDefinition( + new Segment('/:foo'), + new Uri("/!$&'()*,-.:;=@_~+") + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + $params = ['bar' => 'bar', 'baz' => 'baz']; + yield 'empty matches are replaced with defaults' => (new RouteTestDefinition( + new Segment('/foo[/:bar]/baz-:baz', [], ['bar' => 'bar']), + new Uri('/foo/baz-baz') + )) + ->expectMatchResult( + RouteResult::fromRouteMatch($params) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch($params, 0, 4) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useParamsForAssemble($params); + + yield from $this->getL10nRouteTestDefinitions(); + } + + public function getL10nRouteTestDefinitions() : iterable + { + // @codingStandardsIgnoreStart + $translator = new Translator(); + $translator->setLocale('en-US'); + $enLoader = $this->createMock(FileLoaderInterface::class); + $deLoader = $this->createMock(FileLoaderInterface::class); + $domainLoader = $this->createMock(FileLoaderInterface::class); + $enLoader->expects($this->any())->method('load')->willReturn(new TextDomain(['fw' => 'framework'])); + $deLoader->expects($this->any())->method('load')->willReturn(new TextDomain(['fw' => 'baukasten'])); + $domainLoader->expects($this->any())->method('load')->willReturn(new TextDomain(['fw' => 'fw-alternative'])); + $translator->getPluginManager()->setService('test-en', $enLoader); + $translator->getPluginManager()->setService('test-de', $deLoader); + $translator->getPluginManager()->setService('test-domain', $domainLoader); + $translator->addTranslationFile('test-en', null, 'default', 'en-US'); + $translator->addTranslationFile('test-de', null, 'default', 'de-DE'); + $translator->addTranslationFile('test-domain', null, 'alternative', 'en-US'); + // @codingStandardsIgnoreEnd + + yield 'translate with default locale' => (new RouteTestDefinition( + new Segment('/{fw}', [], []), + new Uri('/framework') + )) + ->useMatchOptions(['translator' => $translator]) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 10) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useOptionsForAssemble(['translator' => $translator]); + + yield 'translate with default locale' => (new RouteTestDefinition( + new Segment('/{fw}', [], []), + new Uri('/baukasten') + )) + ->useMatchOptions(['translator' => $translator, 'locale' => 'de-DE']) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 10) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useOptionsForAssemble(['translator' => $translator, 'locale' => 'de-DE']); + + yield 'translate uses message id as fallback' => (new RouteTestDefinition( + new Segment('/{fw}', [], []), + new Uri('/fw') + )) + ->useMatchOptions(['translator' => $translator, 'locale' => 'fr-FR']) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 10) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useOptionsForAssemble(['translator' => $translator, 'locale' => 'fr-FR']); + + yield 'translate with specific text domain' => (new RouteTestDefinition( + new Segment('/{fw}', [], []), + new Uri('/fw-alternative') + )) + ->useMatchOptions(['translator' => $translator, 'text_domain' => 'alternative']) + ->expectMatchResult( + RouteResult::fromRouteMatch([]) + ) + ->expectPartialMatchResult( + PartialRouteResult::fromRouteMatch([], 0, 10) + ) + ->shouldAssembleAndExpectResultSameAsUriForMatching() + ->useOptionsForAssemble(['translator' => $translator, 'text_domain' => 'alternative']); + } + + public static function parseExceptionsProvider() : array + { + return [ + 'unbalanced-brackets' => [ + '[', + RuntimeException::class, + 'Found unbalanced brackets', + ], + 'closing-bracket-without-opening-bracket' => [ + ']', + RuntimeException::class, + 'Found closing bracket without matching opening bracket', + ], + 'empty-parameter-name' => [ + ':', + RuntimeException::class, + 'Found empty parameter name', + ], + 'translated-literal-without-closing-backet' => [ + '{test', + RuntimeException::class, + 'Translated literal missing closing bracket', + ], + ]; + } + + /** + * @dataProvider parseExceptionsProvider + */ + public function testParseExceptions(string $route, string $exceptionName, string $exceptionMessage) + { + $this->expectException($exceptionName); + $this->expectExceptionMessage($exceptionMessage); + new Segment($route); + } + + public function testAssemblingWithMissingParameterInRoot() + { + $uri = new Uri(); + $route = new Segment('/:foo'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing parameter "foo"'); + $route->assemble($uri); + } + + public function testTranslatedAssemblingThrowsExceptionWithoutTranslator() + { + $uri = new Uri(); + $route = new Segment('/{foo}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No translator provided'); + $route->assemble($uri); + } + + public function testTranslatedMatchingThrowsExceptionWithoutTranslator() + { + $route = new Segment('/{foo}'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No translator provided'); + $route->match(new ServerRequest()); + } + + public function testAssemblingWithExistingChild() + { + $uri = new Uri(); + $route = new Segment('/[:foo]', [], ['foo' => 'bar']); + $path = $route->assemble($uri, [], ['has_child' => true]); + + $this->assertEquals('/bar', $path); + } + + public function testGetAssembledParams() + { + $uri = new Uri(); + $route = new Segment('/:foo'); + $route->assemble($uri, ['foo' => 'bar', 'baz' => 'bat']); + $this->assertEquals(['foo'], $route->getLastAssembledParams()); + } + + public function testFactory() + { + $tester = new FactoryTester($this); + $tester->testFactory( + Segment::class, + [ + 'route' => 'Missing "route" in options array', + ], + [ + 'route' => '/:foo[/:bar{-}]', + 'constraints' => ['foo' => 'bar'], + ] + ); + } + + public function testRawDecode() + { + // verify all characters which don't absolutely require encoding pass through match unchanged + // this includes every character other than #, %, / and ? + $raw = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',.~!@$^&*()_+{}|:"<>'; + $request = new ServerRequest([], [], new Uri('http://example.com/' . $raw)); + $route = new Segment('/:foo'); + $result = $route->match($request); + + $this->assertTrue($result->isSuccess()); + $this->assertSame($raw, $result->getMatchedParams()['foo']); + } + + public function testEncodedDecode() + { + // @codingStandardsIgnoreStart + // every character + $in = '%61%62%63%64%65%66%67%68%69%6a%6b%6c%6d%6e%6f%70%71%72%73%74%75%76%77%78%79%7a%41%42%43%44%45%46%47%48%49%4a%4b%4c%4d%4e%4f%50%51%52%53%54%55%56%57%58%59%5a%30%31%32%33%34%35%36%37%38%39%60%2d%3d%5b%5d%5c%3b%27%2c%2e%2f%7e%21%40%23%24%25%5e%26%2a%28%29%5f%2b%7b%7d%7c%3a%22%3c%3e%3f'; + $out = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`-=[]\\;\',./~!@#$%^&*()_+{}|:"<>?'; + // @codingStandardsIgnoreEnd + + $request = new ServerRequest([], [], new Uri('http://example.com/' . $in)); + $route = new Segment('/:foo'); + $result = $route->match($request); + + $this->assertTrue($result->isSuccess()); + $this->assertSame($out, $result->getMatchedParams()['foo']); + } + + public function testEncodeCache() + { + $uri = new Uri(); + $params1 = ['p1' => 6.123, 'p2' => 7]; + $uri1 = 'example.com/' . implode('/', $params1); + $params2 = ['p1' => 6, 'p2' => 'test']; + $uri2 = 'example.com/' . implode('/', $params2); + + $route = new Segment('example.com/:p1/:p2'); + $request = new ServerRequest([], [], new Uri($uri1)); + $route->match($request); + $this->assertSame($uri1, $route->assemble($uri, $params1)->getPath()); + + $request = $request->withUri(new Uri($uri2)); + $route->match($request); + $this->assertSame($uri2, $route->assemble($uri, $params2)->getPath()); + } + + public function testRejectsNegativePathOffset() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Path offset cannot be negative'); + $request = $this->prophesize(ServerRequestInterface::class); + $route = new Segment('/foo'); + $route->partialMatch($request->reveal(), -1); + } +} diff --git a/test/Route/TestAsset/RouteTestDefinition.php b/test/Route/TestAsset/RouteTestDefinition.php new file mode 100644 index 0000000..0d0010a --- /dev/null +++ b/test/Route/TestAsset/RouteTestDefinition.php @@ -0,0 +1,223 @@ +route = $route; + if ($requestOrUriToMatch instanceof ServerRequestInterface) { + $this->matchRequest = $requestOrUriToMatch; + } elseif ($requestOrUriToMatch instanceof UriInterface) { + $this->matchRequest = new ServerRequest([], [], $requestOrUriToMatch, 'GET', 'php://memory'); + } else { + throw new Exception('Must provide server request or uri interface to use for matching'); + } + } + + public function getRoute() : RouteInterface + { + return $this->route; + } + + public function getRequestToMatch() : ServerRequestInterface + { + return $this->matchRequest; + } + + public function expectMatchResult(RouteResult $result) : self + { + $this->matchResult = $result; + return $this; + } + + /** + * @throws Exception + */ + public function getExpectedMatchResult() : RouteResult + { + if (! $this->matchResult) { + throw new Exception( + 'Expected match result is not provided. Set it with RouteTestDefinition::expectMatchResult()' + ); + } + return $this->matchResult; + } + + /** + * @throws Exception + */ + public function expectPartialMatchResult(PartialRouteResult $result) : self + { + if (! $this->route instanceof PartialRouteInterface) { + throw new Exception('Only partial route can match partially'); + } + + $this->partialMatchResult = $result; + return $this; + } + + /** + * @throws Exception + */ + public function getExpectedPartialMatchResult() : PartialRouteResult + { + if (! $this->route instanceof PartialRouteInterface) { + throw new Exception('No expected partial match result. Only partial route can match partially'); + } + if (! $this->partialMatchResult) { + throw new Exception( + 'Expected partial match result is not provided. Set it with' + . ' RouteTestDefinition::expectPartialMatchResult()' + ); + } + return $this->partialMatchResult; + } + + public function usePathOffset(int $pathOffset) : self + { + $this->pathOffset = $pathOffset; + return $this; + } + + public function getPathOffset() : int + { + return $this->pathOffset; + } + + public function useMatchOptions(array $options) : self + { + $this->matchOptions = $options; + return $this; + } + + public function getMatchOptions() : array + { + return $this->matchOptions; + } + + public function shouldAssembleAndExpectResult(UriInterface $uri) : self + { + $this->assembleResult = $uri; + return $this; + } + + public function shouldAssembleAndExpectResultSameAsUriForMatching() : self + { + $this->shouldAssembleAndExpectResult($this->getRequestToMatch()->getUri()); + return $this; + } + + public function getExpectedAssembleResult() : ?UriInterface + { + return $this->assembleResult; + } + + public function useUriForAssemble(UriInterface $uri) : self + { + $this->assembleWithUri = $uri; + return $this; + } + + public function getUriForAssemble() : UriInterface + { + return $this->assembleWithUri ?? new Uri(); + } + + public function useParamsForAssemble(array $assembleParams) : self + { + $this->assembleParams = $assembleParams; + return $this; + } + + public function getParamsForAssemble() : array + { + return $this->assembleParams; + } + + public function useOptionsForAssemble(array $assembleOptions) : self + { + $this->assembleOptions = $assembleOptions; + return $this; + } + + public function getOptionsForAssemble() : array + { + return $this->assembleOptions; + } +} diff --git a/test/Http/_files/tokens.de.php b/test/Route/_files/tokens.de.php similarity index 64% rename from test/Http/_files/tokens.de.php rename to test/Route/_files/tokens.de.php index b6ca98d..3d7157d 100644 --- a/test/Http/_files/tokens.de.php +++ b/test/Route/_files/tokens.de.php @@ -1,4 +1,7 @@ 'hauptseite', ]; diff --git a/test/Http/_files/tokens.en.php b/test/Route/_files/tokens.en.php similarity index 64% rename from test/Http/_files/tokens.en.php rename to test/Route/_files/tokens.en.php index 02cdd7e..f3243c4 100644 --- a/test/Http/_files/tokens.en.php +++ b/test/Route/_files/tokens.en.php @@ -1,4 +1,7 @@ 'homepage', ]; diff --git a/test/RouteConfigFactoryTest.php b/test/RouteConfigFactoryTest.php new file mode 100644 index 0000000..b80c47f --- /dev/null +++ b/test/RouteConfigFactoryTest.php @@ -0,0 +1,241 @@ +routes = new RoutePluginManager(new ServiceManager()); + $this->factory = new RouteConfigFactory($this->routes); + } + + public function testCreateFromArray() + { + $spec = [ + 'type' => 'TestRoute', + 'options' => [ + 'foo' => 'bar', + ], + ]; + $route = $this->prophesize(RouteInterface::class); + + $routeFactory = $this->prophesize(FactoryInterface::class); + $routeFactory->__invoke(Argument::any(), 'TestRoute', $spec['options']) + ->shouldBeCalled() + ->willReturn($route->reveal()); + + $this->routes->setFactory('TestRoute', $routeFactory->reveal()); + + $returnedRoute = $this->factory->routeFromSpec($spec); + $this->assertSame($route->reveal(), $returnedRoute); + } + + public function testCreateFromRouteInstanceReturnsSameInstance() + { + $route = $this->prophesize(RouteInterface::class); + $returnedRoute = $this->factory->routeFromSpec($route->reveal()); + $this->assertSame($route->reveal(), $returnedRoute); + } + + public function testCreateFromNonArraySpecShouldThrow() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Route definition must be an array'); + $this->factory->routeFromSpec(123); + } + + public function testCreateRouteWithChained() + { + $spec = [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/foo', + ], + 'chain_routes' => [ + 'chained' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/bar', + ], + ], + ], + ]; + + $chainRoute = $this->factory->routeFromSpec($spec); + $this->assertInstanceOf(Chain::class, $chainRoute); + + $request = new ServerRequest([], [], new Uri('/foo/bar')); + $this->assertTrue($chainRoute->match($request)->isSuccess()); + } + + public function testCreateRouteWithChainedWithNoRouteName() + { + $spec = [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/foo', + ], + 'chain_routes' => [ + [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/bar', + ], + ], + [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/baz', + ], + ], + ], + ]; + + $chainRoute = $this->factory->routeFromSpec($spec); + $this->assertInstanceOf(Chain::class, $chainRoute); + + $request = new ServerRequest([], [], new Uri('/foo/bar/baz')); + $this->assertTrue($chainRoute->match($request)->isSuccess()); + } + + public function testCreateRouteWithChildRoutes() + { + $spec = [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/foo', + ], + 'child_routes' => [ + 'child' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/bar', + ], + ], + ], + ]; + + $partRoute = $this->factory->routeFromSpec($spec); + $this->assertInstanceOf(Part::class, $partRoute); + + $request = new ServerRequest([], [], new Uri('/foo/bar')); + $this->assertTrue($partRoute->match($request)->isSuccess()); + } + + public function testCreateRouteWithChainedAndChildRoutes() + { + $spec = [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/foo', + ], + 'chain_routes' => [ + 'chained' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/bar', + ], + ], + ], + 'child_routes' => [ + 'child' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/baz', + ], + ], + ], + ]; + + $partRoute = $this->factory->routeFromSpec($spec); + $this->assertInstanceOf(Part::class, $partRoute); + + $request = new ServerRequest([], [], new Uri('/foo/bar/baz')); + $this->assertTrue($partRoute->match($request)->isSuccess()); + } + + public function testCreateRouteFromPrototype() + { + $prototypeRoute = new Literal('/'); + $prototypes = ['test' => $prototypeRoute]; + + $route = $this->factory->routeFromSpec('test', $prototypes); + $this->assertSame($prototypeRoute, $route); + } + + public function testCreateChildRouteFromPrototype() + { + $prototypeRoute = new Literal('/'); + $prototypes = ['test-prototype' => $prototypeRoute]; + + $spec = [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/', + ], + 'child_routes' => [ + 'test' => 'test-prototype', + ], + ]; + + $route = $this->factory->routeFromSpec($spec, $prototypes); + $this->assertInstanceOf(Part::class, $route); + $this->assertSame($prototypeRoute, $route->getRoute('test')); + } + + public function testCreateRouteFromNonExistantPrototypeShouldThrow() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not find prototype with name test'); + $this->factory->routeFromSpec('test', []); + } + + public function testCreateRouteFromInvalidPrototypeShouldThrow() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf( + 'Invalid prototype provided. Expected %s got %s', + RouteInterface::class, + 'string' + )); + $this->factory->routeFromSpec('test', ['test' => Literal::class]); + } +} diff --git a/test/RouteMatchTest.php b/test/RouteMatchTest.php index db6b9d2..da906e5 100644 --- a/test/RouteMatchTest.php +++ b/test/RouteMatchTest.php @@ -1,15 +1,22 @@ assertEquals('bar', $match->getParam('foo', 'bar')); } + + public function testCreateFromRouteResult() + { + $routeResult = RouteResult::fromRouteMatch(['foo' => 'bar'], 'baz'); + $match = RouteMatch::fromRouteResult($routeResult); + $this->assertEquals('bar', $match->getParam('foo')); + $this->assertEquals('baz', $match->getMatchedRouteName()); + } + + public function testCantCreateFromFailureRouteResult() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Route match cannot be created from failure route result'); + $routeResult = RouteResult::fromRouteFailure(); + RouteMatch::fromRouteResult($routeResult); + } } diff --git a/test/RoutePluginManagerFactoryTest.php b/test/RoutePluginManagerFactoryTest.php deleted file mode 100644 index 8e15c02..0000000 --- a/test/RoutePluginManagerFactoryTest.php +++ /dev/null @@ -1,56 +0,0 @@ -container = $this->prophesize(ContainerInterface::class); - $this->factory = new RoutePluginManagerFactory(); - } - - public function testInvocationReturnsAPluginManager() - { - $plugins = $this->factory->__invoke($this->container->reveal(), RoutePluginManager::class); - $this->assertInstanceOf(RoutePluginManager::class, $plugins); - } - - public function testCreateServiceReturnsAPluginManager() - { - $container = $this->prophesize(ServiceLocatorInterface::class); - $container->willImplement(ContainerInterface::class); - - $plugins = $this->factory->createService($container->reveal()); - $this->assertInstanceOf(RoutePluginManager::class, $plugins); - } - - public function testInvocationCanProvideOptionsToThePluginManager() - { - $options = ['factories' => [ - 'TestRoute' => function ($container) { - return $this->prophesize(RouteInterface::class)->reveal(); - }, - ]]; - $plugins = $this->factory->__invoke( - $this->container->reveal(), - RoutePluginManager::class, - $options - ); - $this->assertInstanceOf(RoutePluginManager::class, $plugins); - $route = $plugins->get('TestRoute'); - $this->assertInstanceOf(RouteInterface::class, $route); - } -} diff --git a/test/RoutePluginManagerTest.php b/test/RoutePluginManagerTest.php index a0716b5..baf05f8 100644 --- a/test/RoutePluginManagerTest.php +++ b/test/RoutePluginManagerTest.php @@ -1,10 +1,12 @@ [ - 'DummyRoute' => TestAsset\DummyRoute::class, - ]]); + $routes = new RoutePluginManager(new ServiceManager(), [ + 'aliases' => [ + 'DummyRoute' => TestAsset\DummyRoute::class, + ], + ]); $route = $routes->get('DummyRoute'); $this->assertInstanceOf(TestAsset\DummyRoute::class, $route); diff --git a/test/RouteResultTest.php b/test/RouteResultTest.php new file mode 100644 index 0000000..7c28bee --- /dev/null +++ b/test/RouteResultTest.php @@ -0,0 +1,169 @@ +assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + $this->assertFalse($result->isSuccess()); + } + + public function testFromMethodFailure() + { + $methods = ['GET', 'POST']; + $result = RouteResult::fromMethodFailure($methods); + $this->assertTrue($result->isFailure()); + $this->assertTrue($result->isMethodFailure()); + $this->assertFalse($result->isSuccess()); + $this->assertEquals($methods, $result->getAllowedMethods()); + } + + public function testFromMethodFailureDeduplicatesAndNormalizesHttpMethods() + { + $methods = ['GeT', 'get', 'POST', 'POST']; + $result = RouteResult::fromMethodFailure($methods); + $this->assertEquals(['GET', 'POST'], $result->getAllowedMethods()); + } + + /** + * Empty list can occur on allowed methods intersect in Part route. Eg when + * parent route allows only GET and child only POST. Route must handle + * such occurrence. + */ + public function testFromMethodFailureThrowsOnEmptyAllowedMethodsList() + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Method failure requires list of allowed methods'); + RouteResult::fromMethodFailure([]); + } + + public function testFromRouteMatchIsSuccessful() + { + $result = RouteResult::fromRouteMatch([], null); + $this->assertFalse($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + $this->assertTrue($result->isSuccess()); + } + + public function testFromRouteMatchWithNoRouteNameProvided() + { + $result = RouteResult::fromRouteMatch([]); + $this->assertNull($result->getMatchedRouteName()); + } + + public function testFromRouteMatchSetsMatchedRouteNameWhenProvided() + { + $result = RouteResult::fromRouteMatch([], 'bar'); + $this->assertEquals('bar', $result->getMatchedRouteName()); + } + + public function testFromRouteMatchSetsMatchedParameters() + { + $params = ['foo' => 'bar']; + $result = RouteResult::fromRouteMatch($params); + $this->assertEquals($params, $result->getMatchedParams()); + } + + public function testWithRouteNameReplacesNameInNewInstance() + { + $result1 = RouteResult::fromRouteMatch([], 'foo'); + $result2 = $result1->withMatchedRouteName('bar'); + $this->assertNotSame($result1, $result2); + $this->assertSame('foo', $result1->getMatchedRouteName()); + $this->assertSame('bar', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameWithPrependFlagPrependsNameToExisting() + { + $result1 = RouteResult::fromRouteMatch([], 'foo'); + $result2 = $result1->withMatchedRouteName('bar', RouteResult::NAME_PREPEND); + $this->assertNotSame($result1, $result2); + $this->assertSame('foo', $result1->getMatchedRouteName()); + $this->assertSame('bar/foo', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameWithPrependFlagSetsNameWhenRouteNameIsNotSet() + { + $result1 = RouteResult::fromRouteMatch([], null); + $result2 = $result1->withMatchedRouteName('bar', RouteResult::NAME_PREPEND); + $this->assertSame('bar', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameWithAppendFlagAppendsNameToExisting() + { + $result1 = RouteResult::fromRouteMatch([], 'foo'); + $result2 = $result1->withMatchedRouteName('bar', RouteResult::NAME_APPEND); + $this->assertNotSame($result1, $result2); + $this->assertSame('foo', $result1->getMatchedRouteName()); + $this->assertSame('foo/bar', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameWithAppendFlagSetsNameWhenRouteNameIsNotSet() + { + $result1 = RouteResult::fromRouteMatch([], null); + $result2 = $result1->withMatchedRouteName('bar', RouteResult::NAME_APPEND); + $this->assertSame('bar', $result2->getMatchedRouteName()); + } + + public function testWithRouteNameThrowsForUnsuccessfulResult() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Only successful routing can have matched route name'); + $result = RouteResult::fromRouteFailure(); + $result->withMatchedRouteName('foo'); + } + + public function testWithRouteNameRejectsEmptyName() + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Route name cannot be empty'); + $result = RouteResult::fromRouteMatch([], 'foo'); + $result->withMatchedRouteName(''); + } + + public function testWithRouteNameThrowsOnUnknownFlag() + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Unknown flag'); + $result = RouteResult::fromRouteMatch([], 'foo'); + $result->withMatchedRouteName('bar', 'unknown'); + } + + public function testWithMatchedParamsReplacesInNewInstance() + { + $params1 = ['foo' => 'bar']; + $params2 = ['baz' => 'qux']; + $result1 = RouteResult::fromRouteMatch($params1, null); + $result2 = $result1->withMatchedParams($params2); + $this->assertNotSame($result1, $result2); + $this->assertSame($params1, $result1->getMatchedParams()); + $this->assertSame($params2, $result2->getMatchedParams()); + } + + public function testWithMatchedParamsThrowsForUnsuccessfulResult() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Only successful routing can have matched params'); + $result = RouteResult::fromRouteFailure(); + $result->withMatchedParams(['foo' => 'bar']); + } +} diff --git a/test/RouterFactoryTest.php b/test/RouterFactoryTest.php deleted file mode 100644 index 362a4ed..0000000 --- a/test/RouterFactoryTest.php +++ /dev/null @@ -1,70 +0,0 @@ -defaultServiceConfig = [ - 'factories' => [ - 'HttpRouter' => HttpRouterFactory::class, - 'RoutePluginManager' => function ($services) { - return new RoutePluginManager($services); - }, - ], - ]; - - $this->factory = new RouterFactory(); - } - - private function createContainer() - { - return $this->prophesize(ContainerInterface::class)->reveal(); - } - - public function testFactoryCanCreateRouterBasedOnConfiguredName() - { - $config = new Config(array_merge_recursive($this->defaultServiceConfig, [ - 'services' => [ 'config' => [ - 'router' => [ - 'router_class' => TestAsset\Router::class, - ], - ]], - ])); - $services = new ServiceManager(); - $config->configureServiceManager($services); - - $router = $this->factory->__invoke($services, 'router'); - $this->assertInstanceOf(TestAsset\Router::class, $router); - } - - public function testFactoryCanCreateRouterWhenOnlyHttpRouterConfigPresent() - { - $config = new Config(array_merge_recursive($this->defaultServiceConfig, [ - 'services' => [ 'config' => [ - 'router' => [ - 'router_class' => TestAsset\Router::class, - ], - ]], - ])); - $services = new ServiceManager(); - $config->configureServiceManager($services); - - $router = $this->factory->__invoke($services, 'router'); - $this->assertInstanceOf(TestAsset\Router::class, $router); - } -} diff --git a/test/RouterTest.php b/test/RouterTest.php new file mode 100644 index 0000000..00e0c1b --- /dev/null +++ b/test/RouterTest.php @@ -0,0 +1,207 @@ +routeFactory = new RouteConfigFactory(new RoutePluginManager(new ServiceManager())); + $this->routeStack = new TreeRouteStack(); + $this->router = new Router($this->routeFactory, $this->routeStack, $uriFactory); + } + + public function testGetRouteFactoryReturnsComposedFactory() + { + $factory = $this->router->getRouteFactory(); + $this->assertSame($this->routeFactory, $factory); + } + + public function testGetRouteStackReturnsComposedRouteStack() + { + $routeStack = $this->router->getRouteStack(); + $this->assertSame($this->routeStack, $routeStack); + } + + public function testSetRouteStackReplacesRouteStack() + { + $routeStack = new SimpleRouteStack(); + $this->router->setRouteStack($routeStack); + $this->assertSame($routeStack, $this->router->getRouteStack()); + } + + public function testAddRouteAddsToUnderlyingRouteStack() + { + $route = $this->prophesize(RouteInterface::class); + $this->router->addRoute('test', $route->reveal()); + $routeStack = $this->router->getRouteStack(); + $this->assertTrue($routeStack->hasRoute('test')); + $this->assertSame($route->reveal(), $routeStack->getRoute('test')); + } + + public function testAddRouteCreatesRouteFromConfig() + { + $spec = [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/', + ], + ]; + $this->router->addRoute('test', $spec); + $routeStack = $this->router->getRouteStack(); + $this->assertTrue($routeStack->hasRoute('test')); + $this->assertInstanceOf(Literal::class, $routeStack->getRoute('test')); + } + + public function testByDefaultNoPrototypesRegistered() + { + $prototypes = $this->router->getPrototypes(); + $this->assertEmpty($prototypes); + } + + public function testAddPrototypeWithRouteAddsPrototype() + { + $route = $this->prophesize(RouteInterface::class); + $this->router->addPrototype('test', $route->reveal()); + $this->assertSame($route->reveal(), $this->router->getPrototype('test')); + } + + public function testAddPrototypeAsSpecCreatesRouteAndAddsAsPrototype() + { + $spec = [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/', + ], + ]; + $this->router->addPrototype('test', $spec); + $route = $this->router->getPrototype('test'); + $this->assertInstanceOf(Literal::class, $route); + } + + public function testGetPrototypesReturnsRegisteredPrototypes() + { + $route = $this->prophesize(RouteInterface::class); + $this->router->addPrototype('test', $route->reveal()); + $prototypes = $this->router->getPrototypes(); + $this->assertEquals(['test' => $route->reveal()], $prototypes); + } + + public function testAddRouteAsSpecUsesRegisteredPrototypes() + { + $route = $this->prophesize(RouteInterface::class); + $this->router->addPrototype('testPrototype', $route->reveal()); + + $this->router->addRoute('test', 'testPrototype'); + $this->assertSame( + $route->reveal(), + $this->router->getRouteStack()->getRoute('test') + ); + } + + public function testAddRoutePassesRouteConfigToRouteFactory() + { + $spec = [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/', + ], + ]; + $routeFactory = $this->prophesize(RouteConfigFactory::class); + $routeFactory->routeFromSpec($spec, []) + ->shouldBecalled() + ->willReturn($this->prophesize(RouteInterface::class)->reveal()); + $uriFactory = function () { + return new Uri(); + }; + $router = new Router($routeFactory->reveal(), new TreeRouteStack(), $uriFactory); + $router->addRoute('test', $spec); + } + + public function testProxiesMatchToUnderlyingRouteStackAndReturnsItsResult() + { + $request = new ServerRequest(); + $expectedResult = RouteResult::fromRouteFailure(); + $routeStack = $this->prophesize(RouteStackInterface::class); + $routeStack->match($request, 0) + ->shouldBeCalled() + ->willReturn($expectedResult); + + $this->router->setRouteStack($routeStack->reveal()); + + $result = $this->router->match($request); + + $this->assertSame($expectedResult, $result); + } + + public function testProxiesAssembleToUnderlyingRouteStackAndReturnsItsResult() + { + $uri = new Uri(); + $route = $this->prophesize(RouteInterface::class); + $route->assemble(Argument::any(), ['foo' => 'bar'], ['baz' => 'qux']) + ->willReturn($uri) + ->shouldBeCalled(); + + $this->router->addRoute('test', $route->reveal()); + + $returnedUri = $this->router->assemble('test', ['foo' => 'bar'], ['baz' => 'qux']); + $this->assertSame($uri, $returnedUri); + } + + public function testAssembleUsesUriClosureFactoryToCreateUriAndPassToRouteStackAssemble() + { + $uri = new Uri(); + $routeStack = $this->prophesize(RouteStackInterface::class); + $routeStack->assemble($uri, [], ['name' => 'test']) + ->shouldBeCalled(); + + $uriFactory = function () use ($uri) { + return $uri; + }; + $router = new Router($this->routeFactory, $routeStack->reveal(), $uriFactory); + $router->assemble('test', [], []); + } +} diff --git a/test/SimpleRouteStackTest.php b/test/SimpleRouteStackTest.php index f21ab24..ea6af61 100644 --- a/test/SimpleRouteStackTest.php +++ b/test/SimpleRouteStackTest.php @@ -1,162 +1,59 @@ setRoutePluginManager($routes); - - $this->assertEquals($routes, $stack->getRoutePluginManager()); - } - - public function testAddRoutesWithInvalidArgument() - { - $stack = new SimpleRouteStack(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('addRoutes expects an array or Traversable set of routes'); - $stack->addRoutes('foo'); - } - - public function testAddRoutesAsArray() + public function testAddRoutes() { $stack = new SimpleRouteStack(); $stack->addRoutes([ - 'foo' => new TestAsset\DummyRoute() + 'foo' => new TestAsset\DummyRoute(), ]); - $this->assertInstanceOf(RouteMatch::class, $stack->match(new Request())); + $this->assertTrue($stack->match(new ServerRequest())->isSuccess()); } - public function testAddRoutesAsTraversable() - { - $stack = new SimpleRouteStack(); - $stack->addRoutes(new ArrayIterator([ - 'foo' => new TestAsset\DummyRoute() - ])); - - $this->assertInstanceOf(RouteMatch::class, $stack->match(new Request())); - } - - public function testSetRoutesWithInvalidArgument() - { - $stack = new SimpleRouteStack(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('addRoutes expects an array or Traversable set of routes'); - $stack->setRoutes('foo'); - } - - public function testSetRoutesAsArray() + public function testSetRoutes() { $stack = new SimpleRouteStack(); $stack->setRoutes([ - 'foo' => new TestAsset\DummyRoute() + 'foo' => new TestAsset\DummyRoute(), ]); - $this->assertInstanceOf(RouteMatch::class, $stack->match(new Request())); + $this->assertTrue($stack->match(new ServerRequest())->isSuccess()); $stack->setRoutes([]); - $this->assertNull($stack->match(new Request())); - } - - public function testSetRoutesAsTraversable() - { - $stack = new SimpleRouteStack(); - $stack->setRoutes(new ArrayIterator([ - 'foo' => new TestAsset\DummyRoute() - ])); - - $this->assertInstanceOf(RouteMatch::class, $stack->match(new Request())); - - $stack->setRoutes(new ArrayIterator([])); - - $this->assertNull($stack->match(new Request())); + $this->assertFalse($stack->match(new ServerRequest())->isSuccess()); } - public function testremoveRouteAsArray() + public function testRemoveRoute() { $stack = new SimpleRouteStack(); $stack->addRoutes([ - 'foo' => new TestAsset\DummyRoute() - ]); - - $this->assertEquals($stack, $stack->removeRoute('foo')); - $this->assertNull($stack->match(new Request())); - } - - public function testAddRouteWithInvalidArgument() - { - $stack = new SimpleRouteStack(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Route definition must be an array or Traversable object'); - $stack->addRoute('foo', 'bar'); - } - - public function testAddRouteAsArrayWithoutOptions() - { - $stack = new SimpleRouteStack(); - $stack->addRoute('foo', [ - 'type' => TestAsset\DummyRoute::class + 'foo' => new TestAsset\DummyRoute(), ]); - $this->assertInstanceOf(RouteMatch::class, $stack->match(new Request())); - } - - public function testAddRouteAsArrayWithOptions() - { - $stack = new SimpleRouteStack(); - $stack->addRoute('foo', [ - 'type' => TestAsset\DummyRoute::class, - 'options' => [] - ]); - - $this->assertInstanceOf(RouteMatch::class, $stack->match(new Request())); - } - - public function testAddRouteAsArrayWithoutType() - { - $stack = new SimpleRouteStack(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Missing "type" option'); - $stack->addRoute('foo', []); - } - - public function testAddRouteAsArrayWithPriority() - { - $stack = new SimpleRouteStack(); - - $stack->addRoute('foo', [ - 'type' => TestAsset\DummyRouteWithParam::class, - 'priority' => 2 - ])->addRoute('bar', [ - 'type' => TestAsset\DummyRoute::class, - 'priority' => 1 - ]); - - $this->assertEquals('bar', $stack->match(new Request())->getParam('foo')); + $stack->removeRoute('foo'); + $this->assertFalse($stack->match(new ServerRequest())->isSuccess()); } public function testAddRouteWithPriority() @@ -167,47 +64,39 @@ public function testAddRouteWithPriority() $route->priority = 2; $stack->addRoute('baz', $route); - $stack->addRoute('foo', [ - 'type' => TestAsset\DummyRoute::class, - 'priority' => 1 - ]); - - $this->assertEquals('bar', $stack->match(new Request())->getParam('foo')); - } - - public function testAddRouteAsTraversable() - { - $stack = new SimpleRouteStack(); - $stack->addRoute('foo', new ArrayIterator([ - 'type' => TestAsset\DummyRoute::class - ])); + $stack->addRoute('foo', new TestAsset\DummyRoute(), 1); - $this->assertInstanceOf(RouteMatch::class, $stack->match(new Request())); + $result = $stack->match(new ServerRequest()); + $this->assertTrue($result->isSuccess()); + $this->assertArraySubset(['foo' => 'bar'], $result->getMatchedParams()); } public function testAssemble() { + $uri = new Uri(); $stack = new SimpleRouteStack(); $stack->addRoute('foo', new TestAsset\DummyRoute()); - $this->assertEquals('', $stack->assemble([], ['name' => 'foo'])); + $this->assertEquals('', $stack->assemble($uri, [], ['name' => 'foo'])->getPath()); } public function testAssembleWithoutNameOption() { + $uri = new Uri(); $stack = new SimpleRouteStack(); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing "name" option'); - $stack->assemble(); + $stack->assemble($uri); } public function testAssembleNonExistentRoute() { + $uri = new Uri(); $stack = new SimpleRouteStack(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Route with name "foo" not found'); - $stack->assemble([], ['name' => 'foo']); + $stack->assemble($uri, [], ['name' => 'foo']); } public function testDefaultParamIsAddedToMatch() @@ -216,7 +105,9 @@ public function testDefaultParamIsAddedToMatch() $stack->addRoute('foo', new TestAsset\DummyRoute()); $stack->setDefaultParam('foo', 'bar'); - $this->assertEquals('bar', $stack->match(new Request())->getParam('foo')); + $result = $stack->match(new ServerRequest()); + $this->assertTrue($result->isSuccess()); + $this->assertArraySubset(['foo' => 'bar'], $result->getMatchedParams()); } public function testDefaultParamDoesNotOverrideParam() @@ -225,45 +116,38 @@ public function testDefaultParamDoesNotOverrideParam() $stack->addRoute('foo', new TestAsset\DummyRouteWithParam()); $stack->setDefaultParam('foo', 'baz'); - $this->assertEquals('bar', $stack->match(new Request())->getParam('foo')); + $result = $stack->match(new ServerRequest()); + $this->assertTrue($result->isSuccess()); + $this->assertArraySubset(['foo' => 'bar'], $result->getMatchedParams()); } public function testDefaultParamIsUsedForAssembling() { + $uri = new Uri(); $stack = new SimpleRouteStack(); $stack->addRoute('foo', new TestAsset\DummyRouteWithParam()); $stack->setDefaultParam('foo', 'bar'); - $this->assertEquals('bar', $stack->assemble([], ['name' => 'foo'])); + $this->assertEquals('bar', $stack->assemble($uri, [], ['name' => 'foo'])->getPath()); } public function testDefaultParamDoesNotOverrideParamForAssembling() { + $uri = new Uri(); $stack = new SimpleRouteStack(); $stack->addRoute('foo', new TestAsset\DummyRouteWithParam()); $stack->setDefaultParam('foo', 'baz'); - $this->assertEquals('bar', $stack->assemble(['foo' => 'bar'], ['name' => 'foo'])); - } - - public function testFactory() - { - $tester = new FactoryTester($this); - $tester->testFactory( - SimpleRouteStack::class, - [], - [ - 'route_plugins' => new RoutePluginManager(new ServiceManager()), - 'routes' => [], - 'default_params' => [] - ] - ); + $this->assertEquals('bar', $stack->assemble($uri, ['foo' => 'bar'], ['name' => 'foo'])->getPath()); } public function testGetRoutes() { $stack = new SimpleRouteStack(); - $this->assertInstanceOf('Traversable', $stack->getRoutes()); + + $route = new TestAsset\DummyRoute(); + $stack->addRoute('foo', $route); + $this->assertEquals(['foo' => $route], $stack->getRoutes()); } public function testGetRouteByName() diff --git a/test/TestAsset/DummyRoute.php b/test/TestAsset/DummyRoute.php index 7b80254..818c87b 100644 --- a/test/TestAsset/DummyRoute.php +++ b/test/TestAsset/DummyRoute.php @@ -1,54 +1,36 @@ 'bar']); + return RouteResult::fromRouteMatch(['foo' => 'bar']); } - /** - * assemble(): defined by RouteInterface interface. - * - * @see Route::assemble() - * @param array $params - * @param array $options - * @return mixed - */ - public function assemble(array $params = null, array $options = null) + public function assemble(UriInterface $uri, array $params = [], array $options = []) : UriInterface { if (isset($params['foo'])) { - return $params['foo']; + return $uri->withPath($params['foo']); } - return ''; + return $uri; } } diff --git a/test/TestAsset/Router.php b/test/TestAsset/Router.php deleted file mode 100644 index 20babe9..0000000 --- a/test/TestAsset/Router.php +++ /dev/null @@ -1,88 +0,0 @@ -translator = $this->prophesize(TranslatorInterface::class); + $this->routeStack = $this->prophesize(RouteStackInterface::class); + $this->decorator = new TranslatorAwareRouteStackDecorator( + $this->routeStack->reveal(), + $this->translator->reveal() + ); + } + + public function testGetDecoratedRouteStack() + { + $this->assertSame($this->routeStack->reveal(), $this->decorator->getDecoratedRouteStack()); + } + + public function testGetTranslator() + { + $this->assertSame($this->translator->reveal(), $this->decorator->getTranslator()); + } + + public function testSetTranslator() + { + $translator = $this->prophesize(TranslatorInterface::class) + ->reveal(); + $this->decorator->setTranslator($translator); + $this->assertSame($translator, $this->decorator->getTranslator()); + } + + public function testHasTranslator() + { + // translator is constructor injected and always present + $this->assertTrue($this->decorator->hasTranslator()); + } + + public function testSetTranslatorEnabled() + { + $this->assertTrue($this->decorator->isTranslatorEnabled()); + $this->decorator->setTranslatorEnabled(false); + $this->assertFalse($this->decorator->isTranslatorEnabled()); + } + + public function testTranslatorEnabledByDefault() + { + $this->assertTrue($this->decorator->isTranslatorEnabled()); + } + + public function testSetTranslatorTextDomain() + { + $this->decorator->setTranslatorTextDomain('foo'); + $this->assertEquals('foo', $this->decorator->getTranslatorTextDomain()); + + $this->decorator->setTranslator($this->translator->reveal(), 'bar'); + $this->assertEquals('bar', $this->decorator->getTranslatorTextDomain()); + } + + public function testGetTranslatorTextDomain() + { + $this->assertEquals('default', $this->decorator->getTranslatorTextDomain()); + } + + public function testMatchProxiesToDecoratedRouteStack() + { + $request = new ServerRequest(); + $options = ['foo' => 'bar']; + $result = RouteResult::fromRouteFailure(); + $this->routeStack->match($request, 1, $options) + ->willReturn($result) + ->shouldBeCalled(); + + $this->decorator->setTranslatorEnabled(false); + $returnedResult = $this->decorator->match($request, 1, $options); + $this->assertSame($result, $returnedResult); + } + + public function testMatchAddsTextDomainAndTranslatorWhenTranslatorEnabled() + { + $request = new ServerRequest(); + $options = ['foo' => 'bar']; + $result = RouteResult::fromRouteFailure(); + $expectedOptions = $options; + $expectedOptions['text_domain'] = 'default'; + $expectedOptions['translator'] = $this->translator->reveal(); + $this->routeStack->match($request, 1, $expectedOptions) + ->willReturn($result) + ->shouldBeCalled(); + + $this->decorator->setTranslatorEnabled(true); + $returnedResult = $this->decorator->match($request, 1, $options); + $this->assertSame($result, $returnedResult); + } + + /** + * Matches v3 TranslatorAwareTreeRouteStack behavior. From design + * standpoint it should at least hard fail if 'translator' option is not + * instance of TranslatorInterface to avoid hard to debug unexpected behavior. + */ + public function testMatchDoesNotOverrideTranslatorOrTextDomainOptions() + { + $request = new ServerRequest(); + $options = ['foo' => 'bar', 'text_domain' => 'another', 'translator' => 'foo']; + $result = RouteResult::fromRouteFailure(); + $this->routeStack->match($request, 1, $options) + ->willReturn($result) + ->shouldBeCalled(); + + $this->decorator->setTranslatorEnabled(true); + $returnedResult = $this->decorator->match($request, 1, $options); + $this->assertSame($result, $returnedResult); + } + + public function testAssembleProxiesToDecoratedRouteStack() + { + $uri = new Uri(); + $expectUri = new Uri(); + $options = ['foo' => 'bar']; + $params = ['baz' => 'qux']; + $this->routeStack->assemble($uri, $params, $options) + ->willReturn($expectUri) + ->shouldBeCalled(); + + $this->decorator->setTranslatorEnabled(false); + $returnedUri = $this->decorator->assemble($uri, $params, $options); + $this->assertSame($expectUri, $returnedUri); + } + + public function testAssembleAddsTextDomainAndTranslatorWhenTranslatorEnabled() + { + $uri = new Uri(); + $expectUri = new Uri(); + $options = ['foo' => 'bar']; + $params = ['baz' => 'qux']; + $expectedOptions = $options; + $expectedOptions['text_domain'] = 'default'; + $expectedOptions['translator'] = $this->translator->reveal(); + $this->routeStack->assemble($uri, $params, $expectedOptions) + ->willReturn($expectUri) + ->shouldBeCalled(); + + $this->decorator->setTranslatorEnabled(true); + $returnedUri = $this->decorator->assemble($uri, $params, $options); + $this->assertSame($expectUri, $returnedUri); + } + + public function testAssembleDoesNotOverrideTextDomainAndTranslatorOptions() + { + $uri = new Uri(); + $expectUri = new Uri(); + $options = ['foo' => 'bar', 'text_domain' => 'another', 'translator' => 'foo']; + $params = ['baz' => 'qux']; + $this->routeStack->assemble($uri, $params, $options) + ->willReturn($expectUri) + ->shouldBeCalled(); + + $this->decorator->setTranslatorEnabled(true); + $returnedUri = $this->decorator->assemble($uri, $params, $options); + $this->assertSame($expectUri, $returnedUri); + } + + public function testAddRoute() + { + $route = $this->prophesize(RouteInterface::class)->reveal(); + $this->routeStack->addRoute('test', $route, 10) + ->shouldBeCalled(); + $this->decorator->addRoute('test', $route, 10); + } + + public function testAddRoutes() + { + $route = $this->prophesize(RouteInterface::class)->reveal(); + $this->routeStack->addRoutes(['test' => $route]) + ->shouldBeCalled(); + $this->decorator->addRoutes(['test' => $route]); + } + + public function testRemoveRoute() + { + $this->routeStack->removeRoute('test') + ->shouldBeCalled(); + $this->decorator->removeRoute('test'); + } + + public function testSetRoutes() + { + $route = $this->prophesize(RouteInterface::class)->reveal(); + $this->routeStack->setRoutes(['test' => $route]) + ->shouldBeCalled(); + $this->decorator->setRoutes(['test' => $route]); + } + + public function testGetRoutes() + { + $route = $this->prophesize(RouteInterface::class)->reveal(); + $this->routeStack->getRoutes() + ->willReturn(['test' => $route]) + ->shouldBeCalled(); + $routes = $this->decorator->getRoutes(); + $this->assertEquals(['test' => $route], $routes); + } + + public function testHasRoute() + { + $this->routeStack->hasRoute('test') + ->shouldBeCalled(); + $this->decorator->hasRoute('test'); + } + + public function testGetRoute() + { + $route = $this->prophesize(RouteInterface::class)->reveal(); + $this->routeStack->getRoute('test') + ->willReturn($route) + ->shouldBeCalled(); + $returned = $this->decorator->getRoute('test'); + $this->assertSame($route, $returned); + } +} diff --git a/test/TreeRouteStackTest.php b/test/TreeRouteStackTest.php new file mode 100644 index 0000000..7c2c24b --- /dev/null +++ b/test/TreeRouteStackTest.php @@ -0,0 +1,224 @@ +addRoute('foo', new TestAsset\DummyRoute()); + $this->assertEquals('', $stack->assemble($uri, [], ['name' => 'foo'])->getPath()); + } + + public function testAssembleCanonicalUriWithHostnameRoute() + { + $stack = new TreeRouteStack(); + $stack->addRoute('foo', new Hostname('example.com')); + $uri = new Uri(); + $uri = $uri->withScheme('http'); + + $this->assertEquals( + 'http://example.com', + $stack->assemble($uri, [], ['name' => 'foo'])->__toString() + ); + } + + public function testAssembleCanonicalUriWithHostnameRouteWithoutScheme() + { + $stack = new TreeRouteStack(); + $stack->addRoute('foo', new Hostname('example.com')); + $uri = new Uri(); + + $this->assertEquals( + '//example.com', + $stack->assemble($uri, [], ['name' => 'foo'])->__toString() + ); + } + + public function testAssembleWithEncodedPath() + { + $uri = new Uri(); + $stack = new TreeRouteStack(); + $stack->addRoute('index', new Literal('/this%2Fthat')); + + $this->assertEquals('/this%2Fthat', $stack->assemble($uri, [], ['name' => 'index'])->getPath()); + } + + public function testAssembleWithScheme() + { + $uri = new Uri(); + $uri = $uri->withScheme('http'); + $uri = $uri->withHost('example.com'); + $stack = new TreeRouteStack(); + $stack->addRoute( + 'secure', + Part::factory([ + 'route' => new Scheme('https'), + 'child_routes' => [ + 'index' => new Literal('/'), + ], + ]) + ); + $this->assertEquals( + 'https://example.com/', + $stack->assemble($uri, [], ['name' => 'secure/index'])->__toString() + ); + } + + public function testAssembleWithoutNameOption() + { + $uri = new Uri(); + $stack = new TreeRouteStack(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing "name" option'); + $stack->assemble($uri); + } + + public function testAssembleNonExistentRoute() + { + $uri = new Uri(); + $stack = new TreeRouteStack(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Route with name "foo" not found'); + $stack->assemble($uri, [], ['name' => 'foo']); + } + + public function testAssembleNonExistentChildRoute() + { + $uri = new Uri(); + $stack = new TreeRouteStack(); + $stack->addRoute('index', new Literal('/')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Route with name "index" does not have child routes'); + $stack->assemble($uri, [], ['name' => 'index/foo']); + } + + public function testDefaultParamIsAddedToMatch() + { + $stack = new TreeRouteStack(); + $request = new ServerRequest(); + $route = $this->prophesize(RouteInterface::class); + $route->match($request, Argument::any(), Argument::any()) + ->willReturn(RouteResult::fromRouteMatch([])); + $stack->addRoute('foo', $route->reveal()); + $stack->setDefaultParam('foo', 'bar'); + + $result = $stack->match($request); + $this->assertTrue($result->isSuccess()); + $this->assertArraySubset(['foo' => 'bar'], $result->getMatchedParams()); + } + + public function testMethodFailureVerbsAreCombined() + { + $stack = new TreeRouteStack(); + $stack->addRoute('foo', new Method('POST,DELETE')); + $stack->addRoute('bar', new Method('GET,POST')); + + $request = new ServerRequest([], [], new Uri('/'), 'PUT'); + $result = $stack->match($request, 1); + $this->assertTrue($result->isMethodFailure()); + $this->assertEquals(['GET', 'POST', 'DELETE'], $result->getAllowedMethods()); + } + + public function testRoutingFailure() + { + $stack = new TreeRouteStack(); + $stack->addRoute('foo', new Literal('/foo')); + + $request = new ServerRequest([], [], new Uri('/bar')); + $result = $stack->match($request); + $this->assertTrue($result->isFailure()); + $this->assertFalse($result->isMethodFailure()); + } + + public function testDefaultParamDoesNotOverrideMatchParam() + { + $stack = new TreeRouteStack(); + $route = $this->prophesize(RouteInterface::class); + $route->match(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(RouteResult::fromRouteMatch(['foo' => 'bar'])); + $stack->addRoute('foo', $route->reveal()); + $stack->setDefaultParam('foo', 'baz'); + + $result = $stack->match(new ServerRequest()); + $this->assertTrue($result->isSuccess()); + $this->assertArraySubset(['foo' => 'bar'], $result->getMatchedParams()); + } + + public function testDefaultParamIsUsedForAssembling() + { + $uri = new Uri(); + $stack = new TreeRouteStack(); + $route = $this->prophesize(RouteInterface::class); + $route->assemble($uri, ['foo' => 'bar'], []) + ->shouldBeCalled(); + $stack->addRoute('foo', $route->reveal()); + $stack->setDefaultParam('foo', 'bar'); + + $stack->assemble($uri, [], ['name' => 'foo']); + } + + public function testDefaultParamDoesNotOverrideParamForAssembling() + { + $uri = new Uri(); + $stack = new TreeRouteStack(); + $route = $this->prophesize(RouteInterface::class); + $route->assemble($uri, ['foo' => 'bar'], []) + ->shouldBeCalled(); + $stack->addRoute('foo', $route->reveal()); + $stack->setDefaultParam('foo', 'baz'); + + $stack->assemble($uri, ['foo' => 'bar'], ['name' => 'foo']); + } + + public function testPriorityIsPassedToPartRoute() + { + $stack = new TreeRouteStack(); + $stack->addRoute('foo', Part::factory([ + 'route' => new Literal('/foo', ['controller' => 'foo']), + 'may_terminate' => true, + 'child_routes' => [ + 'bar' => new Literal('/bar', ['controller' => 'foo', 'action' => 'bar']), + ], + ]), 1000); + + $reflectedClass = new ReflectionClass($stack); + $reflectedProperty = $reflectedClass->getProperty('routes'); + $reflectedProperty->setAccessible(true); + $routes = $reflectedProperty->getValue($stack); + + $this->assertEquals(1000, $routes->toArray(PriorityList::EXTR_PRIORITY)['foo']); + } +}