diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..27b765f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.github export-ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0f7d23f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Release + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0250909 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +composer.lock +*.cache +*.log +.idea/ +.DS_Store diff --git a/.php_cs b/.php_cs new file mode 100755 index 0000000..b977920 --- /dev/null +++ b/.php_cs @@ -0,0 +1,91 @@ +setRiskyAllowed(true) + ->setRules([ + '@PSR2' => true, + '@Symfony' => true, + '@DoctrineAnnotation' => true, + '@PhpCsFixer' => true, + 'header_comment' => [ + 'commentType' => 'PHPDoc', + 'header' => $header, + 'separate' => 'none', + 'location' => 'after_declare_strict', + ], + 'array_syntax' => [ + 'syntax' => 'short' + ], + 'list_syntax' => [ + 'syntax' => 'short' + ], + 'concat_space' => [ + 'spacing' => 'one' + ], + 'blank_line_before_statement' => [ + 'statements' => [ + 'declare', + ], + ], + 'general_phpdoc_annotation_remove' => [ + 'annotations' => [ + 'author' + ], + ], + 'ordered_imports' => [ + 'imports_order' => [ + 'class', 'function', 'const', + ], + 'sort_algorithm' => 'alpha', + ], + 'single_line_comment_style' => [ + 'comment_types' => [ + ], + ], + 'yoda_style' => [ + 'always_move_variable' => false, + 'equal' => false, + 'identical' => false, + ], + 'phpdoc_align' => [ + 'align' => 'left', + ], + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line', + ], + 'constant_case' => [ + 'case' => 'lower', + ], + 'class_attributes_separation' => true, + 'combine_consecutive_unsets' => true, + 'declare_strict_types' => true, + 'linebreak_after_opening_tag' => true, + 'lowercase_static_reference' => true, + 'no_useless_else' => true, + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'not_operator_with_space' => false, + 'ordered_class_elements' => true, + 'php_unit_strict' => false, + 'phpdoc_separation' => false, + 'single_quote' => true, + 'standardize_not_equals' => true, + 'multiline_comment_opening_closing' => true, + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->exclude('bin') + ->exclude('public') + ->exclude('runtime') + ->exclude('vendor') + ->in(__DIR__) + ) + ->setUsingCache(false); diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c1ce37a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,38 @@ +language: php + +sudo: required + +matrix: + include: + - php: 7.2 + env: SW_VERSION="4.5.3RC1" + - php: 7.3 + env: SW_VERSION="4.5.3RC1" + - php: 7.4 + env: SW_VERSION="4.5.3RC1" + + allow_failures: + - php: master + +services: + - docker + +before_install: + - export PHP_MAJOR="$(`phpenv which php` -r 'echo phpversion();' | cut -d '.' -f 1)" + - export PHP_MINOR="$(`phpenv which php` -r 'echo phpversion();' | cut -d '.' -f 2)" + - echo $PHP_MAJOR + - echo $PHP_MINOR + +install: + - cd $TRAVIS_BUILD_DIR + - bash .travis/swoole.install.sh + - phpenv config-rm xdebug.ini || echo "xdebug not available" + - phpenv config-add .travis/ci.ini + +before_script: + - cd $TRAVIS_BUILD_DIR + - composer config -g process-timeout 900 && composer update + +script: + - composer analyse + - composer test \ No newline at end of file diff --git a/.travis/ci.ini b/.travis/ci.ini new file mode 100644 index 0000000..101b1e3 --- /dev/null +++ b/.travis/ci.ini @@ -0,0 +1,5 @@ +[opcache] +opcache.enable_cli=1 + +[swoole] +extension = "swoole.so" diff --git a/.travis/swoole.install.sh b/.travis/swoole.install.sh new file mode 100644 index 0000000..0067690 --- /dev/null +++ b/.travis/swoole.install.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +wget https://github.com/swoole/swoole-src/archive/v"${SW_VERSION}".tar.gz -O swoole.tar.gz +mkdir -p swoole +tar -xf swoole.tar.gz -C swoole --strip-components=1 +rm swoole.tar.gz +cd swoole || exit +phpize +./configure --enable-openssl --enable-mysqlnd --enable-http2 +make -j "$(nproc)" +make install diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..074518c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Eric Zhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b573d93 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Hyperf HTTP 服务器命名路由扩展组件 + +## 概要 + +该组件通过绑定 `HyperfExt\HttpServer\Router\DispatcherFactory` 到 `Hyperf\HttpServer\Router\DispatcherFactory` 来实现扩展路由功能,由于修改了返回类型,PHP 版本必须 >= 7.4。 + +## 安装 + +```shell +composer require hyperf-ext/http-server-router +``` + +## 使用 + +### 定义命名路由 + +在路由选项中定义 `name` 参数来对路由命名,支持对路由组命名。 + +```php +use Hyperf\HttpServer\Router\Router; + +Router::addGroup('/users/{id}', function () { + Router::get('/comments', 'App\Controller\IndexController@index', ['name' => 'comments.index']); // 该路由名称将被组合为 `users.comments.index` +}, ['name' => 'users']); +``` + +### 获取路由对象 + +#### 通过路由名称获取指定路由 + +```php +use Hyperf\HttpServer\Router\Router; + +/** @var \HyperfExt\HttpServer\Router\Route $route */ +$route = Router::getNamedRoute('users.comments.index'); +``` + +#### 通过当前请求获取当前路由 + +```php +use Hyperf\HttpServer\Contract\RequestInterface; +use Hyperf\HttpServer\Router\Dispatched; +use Hyperf\Utils\ApplicationContext; + +/** @var \HyperfExt\HttpServer\Router\Route $route */ +$route = ApplicationContext::getContainer() + ->get(RequestInterface::class) + ->getAttribute(Dispatched::class) + ->handler + ->routeInstance; // 为避免过多修改原始组件,该组件将路由实例放到了 Handler 中 +``` + +### 生成指定路由的 URI + +```php +/** + * @var \HyperfExt\HttpServer\Router\Route $route + * @var \Hyperf\HttpMessage\Uri\Uri $uri + */ +$uri = $route->createUri([ + 'id' => 123, + 'page_num' => 2, + 'page_size' => 20, +]); +$link = (string) $uri; // 结果为 `/users/123/comments?page_num=2&page_size=20` +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9b2cd34 --- /dev/null +++ b/composer.json @@ -0,0 +1,53 @@ +{ + "name": "hyperf-ext/http-server-router", + "type": "library", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "router" + ], + "description": "The named router for Hyperf HTTP server package.", + "authors": [ + { + "name": "Eric Zhu", + "email": "eric@zhu.email" + } + ], + "autoload": { + "psr-4": { + "HyperfExt\\HttpServer\\Router\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "HyperfTest\\HyperfExt\\HttpServer\\Router\\": "tests" + } + }, + "require": { + "php": ">=7.4", + "ext-swoole": ">=4.5", + "hyperf/di": "~2.0.0", + "hyperf/framework": "~2.0.0", + "hyperf/http-server": "~2.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.14", + "hyperf/testing": "~2.0.0", + "phpstan/phpstan": "^0.12", + "swoole/ide-helper": "dev-master" + }, + "config": { + "sort-packages": true + }, + "scripts": { + "test": "co-phpunit -c phpunit.xml --colors=always", + "analyse": "phpstan analyse --memory-limit 1024M -l 0 ./src", + "cs-fix": "php-cs-fixer fix $1" + }, + "extra": { + "hyperf": { + "config": "HyperfExt\\HttpServer\\Router\\ConfigProvider" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d2c615a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,15 @@ + + + + ./tests/ + + \ No newline at end of file diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php new file mode 100644 index 0000000..784275f --- /dev/null +++ b/src/ConfigProvider.php @@ -0,0 +1,25 @@ + [ + HyperfDispatcherFactory::class => DispatcherFactory::class, + ], + ]; + } +} diff --git a/src/DispatcherFactory.php b/src/DispatcherFactory.php new file mode 100644 index 0000000..5ebf72d --- /dev/null +++ b/src/DispatcherFactory.php @@ -0,0 +1,29 @@ +routers[$serverName])) { + return $this->routers[$serverName]; + } + + $parser = new Std(); + $generator = new DataGenerator(); + return $this->routers[$serverName] = new RouteCollector($parser, $generator, $serverName); + } +} diff --git a/src/Handler.php b/src/Handler.php new file mode 100644 index 0000000..7b62fdc --- /dev/null +++ b/src/Handler.php @@ -0,0 +1,24 @@ +routeInstance = $routeInstance; + } +} diff --git a/src/Route.php b/src/Route.php new file mode 100644 index 0000000..105b069 --- /dev/null +++ b/src/Route.php @@ -0,0 +1,69 @@ +rule = $rule; + $this->data = $data; + $this->name = empty($name) ? null : $name; + } + + public function createUri(array $parameters = []): Uri + { + for ($i = count($this->data) - 1; $i >= 0; --$i) { + $url = ''; + $keys = []; + $last = count($this->data[$i]) - 1; + foreach ($this->data[$i] as $n => $part) { + if (is_string($part)) { + $url .= $part; + } else { + [$key, $pattern] = $part; + if (isset($parameters[$key]) && preg_match('~^' . $pattern . '$~', (string) $parameters[$key])) { + $url .= $parameters[$key]; + $keys[] = $key; + } else { + unset($url, $keys); + continue 2; + } + } + + if ($n === $last) { + if ($i === 0) { + throw new InvalidArgumentException( + sprintf('The parameters does not matched for the route \'%s\'.', $this->rule) + ); + } + break 2; + } + } + } + + if (! empty($keys)) { + Arr::forget($parameters, $keys); + } + + return (new Uri($url ?? '/'))->withQuery(empty($parameters) ? '' : http_build_query($parameters)); + } +} diff --git a/src/RouteCollector.php b/src/RouteCollector.php new file mode 100644 index 0000000..d5f265f --- /dev/null +++ b/src/RouteCollector.php @@ -0,0 +1,100 @@ +currentGroupPrefix . $route; + $routeDatas = $this->routeParser->parse($route); + $name = $this->currentGroupName . (empty($this->currentGroupName) ? '' : '.') . $name; + $routeInstance = new Route($route, $routeDatas, empty($name) ? null : $name); + if (! empty($name)) { + $this->namedRoutes[$name] = $routeInstance; + } + $options = $this->mergeOptions($this->currentGroupOptions, $options); + foreach ((array) $httpMethod as $method) { + $method = strtoupper($method); + foreach ($routeDatas as $routeData) { + $this->dataGenerator->addRoute($method, $routeData, new Handler($handler, $route, $options, $routeInstance)); + MiddlewareManager::addMiddlewares($this->server, $route, $method, $options['middleware'] ?? []); + } + } + } + + /** + * Create a route group with a common prefix. + * + * All routes created in the passed callback will have the given group prefix prepended. + */ + public function addGroup(string $prefix, callable $callback, array $options = []) + { + if (isset($options['name'])) { + $name = $options['name']; + unset($options['name']); + } else { + $name = ''; + } + + $previousGroupPrefix = $this->currentGroupPrefix; + $previousGroupName = $this->currentGroupName; + $currentGroupOptions = $this->currentGroupOptions; + + $this->currentGroupPrefix = $previousGroupPrefix . $prefix; + $this->currentGroupName = $previousGroupName . (empty($previousGroupName) ? '' : '.') . $name; + $this->currentGroupOptions = $this->mergeOptions($currentGroupOptions, $options); + $callback($this); + + $this->currentGroupPrefix = $previousGroupPrefix; + $this->currentGroupName = $previousGroupName; + $this->currentGroupOptions = $currentGroupOptions; + } + + /** + * Get a route instance by its name. + * + * @throws \HyperfExt\HttpServer\Router\RouteNotFoundException + */ + public function getNamedRoute(string $name): Route + { + if (isset($this->namedRoutes[$name])) { + return $this->namedRoutes[$name]; + } + + throw new RouteNotFoundException("Route [{$name}] not defined."); + } +} diff --git a/src/RouteNotFoundException.php b/src/RouteNotFoundException.php new file mode 100644 index 0000000..4ccd9be --- /dev/null +++ b/src/RouteNotFoundException.php @@ -0,0 +1,17 @@ +