diff --git a/.gitattributes b/.gitattributes index ab732073..3aa6270a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,9 @@ .gitignore export-ignore .github export-ignore .travis.yml export-ignore +ecs.php export-ignore +phpstan.neon export-ignore tests/ export-ignore + *.sh eol=lf +*.php* diff=php linguist-language=PHP diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 00000000..25adc952 --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1,2 @@ +github: dg +custom: "https://nette.org/donate" diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml new file mode 100644 index 00000000..38e63c0c --- /dev/null +++ b/.github/workflows/coding-style.yml @@ -0,0 +1,31 @@ +name: Coding Style + +on: [push, pull_request] + +jobs: + nette_cc: + name: Nette Code Checker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v1 + with: + php-version: 7.1 + coverage: none + + - run: composer create-project nette/code-checker temp/code-checker ^3 --no-progress + - run: php temp/code-checker/code-checker --strict-types + + + nette_cs: + name: Nette Coding Standard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v1 + with: + php-version: 7.4 + coverage: none + + - run: composer create-project nette/coding-standard temp/coding-standard ^3 --no-progress + - run: php temp/coding-standard/ecs check diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 00000000..e129028c --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,21 @@ +name: Static Analysis (only informative) + +on: + push: + branches: + - master + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v1 + with: + php-version: 7.4 + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: composer phpstan + continue-on-error: true # is only informative diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..aa8766a2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,58 @@ +name: Tests + +on: [push, pull_request] + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.1', '7.2', '7.3', '7.4', '8.0'] + + fail-fast: false + + name: PHP ${{ matrix.php }} tests + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: vendor/bin/tester tests -s -C + - if: failure() + uses: actions/upload-artifact@v2 + with: + name: output + path: tests/**/output + + + lowest_dependencies: + name: Lowest Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: 7.1 + coverage: none + + - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable + - run: vendor/bin/tester tests -s -C + + + code_coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + coverage: none + + - run: composer install --no-progress --prefer-dist + - run: wget https://github.com/satooshi/php-coveralls/releases/download/v1.0.1/coveralls.phar + - run: vendor/bin/tester -p phpdbg tests -s -C --coverage ./coverage.xml --coverage-src ./src + - run: php coveralls.phar --verbose --config tests/.coveralls.yml diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php new file mode 100644 index 00000000..cd9d3a75 --- /dev/null +++ b/.phpstorm.meta.php @@ -0,0 +1,8 @@ +import(PRESET_DIR . '/php71.php'); + + $parameters = $containerConfigurator->parameters(); + + $parameters->set('skip', [ + // use function + PhpCsFixer\Fixer\Import\SingleImportPerStatementFixer::class => ['src/Routing/Route.php'], + PhpCsFixer\Fixer\Import\OrderedImportsFixer::class => ['src/Routing/Route.php'], + ]); +}; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..a0846930 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 5 + + paths: + - src diff --git a/readme.md b/readme.md index afe7c9e4..6c183cbd 100644 --- a/readme.md +++ b/readme.md @@ -1,185 +1,127 @@ Nette Routing: two-ways URL conversion -====================================== +************************************** [![Downloads this Month](https://img.shields.io/packagist/dm/nette/routing.svg)](https://packagist.org/packages/nette/routing) [![Build Status](https://travis-ci.org/nette/routing.svg?branch=master)](https://travis-ci.org/nette/routing) [![Coverage Status](https://coveralls.io/repos/github/nette/routing/badge.svg?branch=master)](https://coveralls.io/github/nette/routing?branch=master) -Routing is a two-way conversion between URL and presenter action. *Two-way* means that we can both determine what presenter URL links to, but also vice versa: generate URL for given action. +Introduction +============ +The router is responsible for everything about URLs so that you no longer have to think about them. We will show: -SimpleRouter ------------- +- how to set up the router so that the URLs look like you want +- a few notes about SEO redirection +- and we'll show you how to write your own router -Desired URL format is set by a *router*. The most plain implementation of router is SimpleRouter. It can be used when there's no need for a specific URL format, or when `mod_rewrite` (or alternatives) is not available. +It requires PHP version 7.1 and supports PHP up to 8.0. -Addresses look like this: +Documentation can be found on the [website](https://doc.nette.org/routing). If you like it, **[please make a donation now](https://github.com/sponsors/dg)**. Thank you! -``` -http://example.com/index.php?presenter=Product&action=detail&id=123 -``` +More human URLs (or cool or pretty URLs) are more usable, more memorable, and contribute positively to SEO. Nette Framework keeps this in mind and fully meets developers' desires. -The first parameter of `SimpleRouter` constructor is an array of default parameters. +Let's start a little technically. A router is an object that implements the [Nette\Routing\Router](https://api.nette.org/3.0/Nette/Routing/Router.html) interface, which can decompose a URL into an array of parameters (method `match`) and, conversely, build a URL from an array of parameters (method `constructUrl`). Therefore, it is also said that the router is bidirectional. +Nette brings a very elegant way to define how the URLs of your application look like. -```php -// defaults to controller 'Homepage' and action 'default' -$router = new Nette\Routing\SimpleRouter(['controller' => 'homepage', 'action' => 'default']); -``` - -The second constructor parameter is optional and is used to pass additional flags (only `Router::ONE_WAY` is supported). +The router plays an important role in [Nette Application](https://doc.nette.org/application). Thanks to router, it will find out which presenter and action to run. And it also uses the router to generate URLs in the template, for example: +```html +detail +``` -Route: for prettier URLs ------------------------- +However, the router is not limited to this use, you can use it in the same way in completely different cases, for the REST API, for non-Nette applications, etc. -Human-friendly URLs (also more cool & prettier) are easier to remember and do help SEO. Nette Framework keeps current trends in mind and fully meets developers' desires. +Thus, routing is a separate and sophisticated layer of the application, thanks to which the look of URL addresses can be easily designed or changed when the entire application is ready, because it can be done without modification of the code or templates. Which gives developers huge freedom. -All requests must be handled by `index.php` file. This can be accomplished e.g. by using Apache module `mod_rewrite` or Nginx's try_files. -Class Route can create addresses in pretty much any format one can though of. Let's start with a simple example generating a pretty URL for action `Product:default` with `id = 123`: +Route Collection +================ -``` -http://example.com/product/detail/123 -``` +The most pleasant way to define the URL addresses in the application is via the class [Nette\Routing\RouteList](https://api.nette.org/3.0/Nette/Routing/RouteList.html). The big advantage is that the whole router +is defined in one place and is not so scattered in the form of annotations in all controllers. -The following snippet creates a `Route` object, passing path mask as the first argument and specifying default action in the second argument. +The definition consists of a list of so-called routes, ie masks of URL addresses and their associated controllers and actions using a simple API. We do not have to name the routes. ```php -// action defaults to controller Homepage and action default -$route = new Route('/[/]', ['controller' => 'homepage', 'action' => 'default']); +$router = new Nette\Routing\RouteList; +$router->addRoute('rss.xml', ['controller' => 'Feed', 'action' => 'rss']); +$router->addRoute('article/', ['controller' => 'Article', 'action' => 'view'); +... ``` -This route is usable by all controllers and actions. Accepts paths such as `/article/edit/10` or `/catalog/list`, because the `id` part is wrapped in square brackets, which marks it as optional. +The example says that if we open `https://any-domain.com/rss.xml` in the browser, the controller `Feed` with the action `rss` will be displayed, etc. If no suitable route is found, router returns `null`. -Because other parameters (`controller` and `action`) do have default values (`Homepage` and `default`), they are optional too. If their value is the same as the default one, they are skipped while URL is generated. Link to `Product:default` generates only `http://example.com/product` and link to `Homepage:default` generates only `http://example.com/`. +Order of routes is important because they are tried sequentially from the first one to the last one. Basic rule is to **declare routes from the most specific to the most general**. -Path Mask ---------- +Mask and Parameters +------------------- -The simplest path mask consists only of a **static URL** and a target action. +The mask describes the relative path based on the site root. The simplest mask is a static URL: ```php -$route = new Route('products', ...); +$router->addRoute('products', ['controller' => 'Products', 'action' => 'default']); ``` -Most real masks however contain some **parameters**. Parameters are enclosed in angle brackets (e.g. ``) and are passed to target presenter. +Often masks contain so-called **parameters**. They are enclosed in angle brackets (e.g. ``) and are passed to the target controller, for example to the `renderShow(int $year)` method or to persistent parameter `$year`: ```php -$route = new Route('history/', ...); -``` - -Mask can also contain traditional GET arguments (query after a question mark). Neither validation expressions nor more complex structures are supported in this part of path mask, but you can set what key belongs to which variable: - -``` php -$route = new Route('/ ? id= & cat=', ...); +$router->addRoute('chronicle/', ['controller' => 'History', 'action' => 'show']); ``` -The parameters before a question mark are called *path parameters* and the parameters after a question mark are called *query parameters*. +The example says that if we open `https://any-domain.com/chronicle/2020` in the browser, the controller `History` and the action `show` with parameter `year => 2020` will be displayed. - -Mask can not only describe path relative to application document root (web root), but can as well contain path relative to server document root (starts with a single slash) or absolute path with domain (starts with a double slash). +We can specify a default value for the parameters directly in the mask and thus it becomes optional: ```php -// relative to application document root (www directory) -$route = new Route('/', ...); - -// relative to server document root -$route = new Route('//', ...); - -// absolute path including hostname -$route = new Route('//.example.com//', ...); +$router->addRoute('chronicle/', ['controller' => 'History', 'action' => 'show']); ``` -Absolute path mask may utilize the following variables: -- `%tld%` = top level domain, e.g. `com` or `org` -- `%domain%` = second level domain, e.g. `example.com` -- `%basePath%` - -```php -$route = new Route('//www.%domain%/%basePath%//', ...); -``` - - +The route will now accept the URL `https://any-domain.com/chronicle/`, which will again display `History:show` with parameter `year => 2020`. -Route Collection ----------------- - -Because we usually define more than one route, we wrap them into a RouteList. +Of course, the name of the controller and the action can also be a parameter. For example: ```php -use Nette\Routing\RouteList; -use Nette\Routing\Route; - -$router = new RouteList(); -$router[] = new Route('rss.xml', ...); -$router[] = new Route('article/', ...); -$router[] = new Route('/[/]', ...); +$router->addRoute('/', ['controller' => 'Homepage', 'action' => 'default']); ``` -Unlike other frameworks, Nette does not require routes to be named. - -It's important in which order are the routes defined as they are tried from top to bottom. The rule of thumb here is that routes are declared from the **most specific at the top** to the **most vague at the bottom**. Keep in mind that huge amount of routes can negatively effect application speed, mostly when generating links. It's worth to keep routes as simple as possible. +This route accepts, for example, a URL in the form `/article/edit` resp. `/catalog/list` and translates them to controllers and actions `Article:edit` resp. `Catalog:list`. +It also gives to parameters `controller` and `action` default values โ€‹โ€‹`Homepage` and `default` and therefore they are optional. So the route also accepts a URL `/article` and translates it as `Article:default`. Or vice versa, a link to `Product:default` generates a path `/product`, a link to the default `Homepage:default` generates a path `/`. - -Default Values --------------- - -Each parameter may have defined a default value in the mask: +The mask can describe not only the relative path based on the site root, but also the absolute path when it begins with a slash, or even the entire absolute URL when it begins with two slashes: ```php -$route = new Route('//'); -``` +// relative path to application document root +$router->addRoute('/', ...); -Or utilizing an array: +// absolute path, relative to server hostname +$router->addRoute('//', ...); -```php -$route = new Route('//', [ - 'presenter' => 'Homepage', - 'action' => 'default', - 'id' => null, -]); +// absolute URL including hostname (but scheme-relative) +$router->addRoute('//.example.com//', ...); -// equals to the following complex notation -$route = new Route('//', [ - 'presenter' => array( - Route::VALUE => 'Homepage', - ), - 'action' => array( - Route::VALUE => 'default', - ), - 'id' => array( - Route::VALUE => null, - ), -]); +// absolute URL including schema +$router->addRoute('https://.example.com//', ...); ``` - Validation Expressions ---------------------- -Each parameter may have defined a **regular expression** which it needs to match. This regular expression is checked both when matching and generating URL. For example let's set `id` to be only numerical, using `\d+` regexp: +A validation condition can be specified for each parameter using [regular expression ](https://www.php.net/manual/en/reference.pcre.pattern.syntax.php). For example, let's set `id` to be only numerical, using `\d+` regexp: ```php -// regexp can be defined directly in the path mask after parameter name -$route = new Route('/[/]', ...); - -// equals to the following complex notation -$route = new Route('/[/]', [ - 'presenter' => 'Homepage', - 'action' => 'default', - 'id' => [ - Route::PATTERN => '\d+', - ], -]); +$router->addRoute('/[/]', ...); ``` +The default regular expression for all parameters is `[^/]+`, ie everything except the slash. If a parameter is supposed to match a slash as well, we set the regular expression to `.+`. -Default validation expression for path parameters is `[^/]+`, meaning all characters but a slash. If a parameter is supposed to match a slash as well, we can set the regular expression to `.+`. +```php +// accepts https://example.com/a/b/c, path is 'a/b/c' +$router->addRoute('', ...); +``` -Regular expressions are *case-sensitive* by default. Optional Sequences @@ -188,26 +130,27 @@ Optional Sequences Square brackets denote optional parts of mask. Any part of mask may be set as optional, including those containing parameters: ```php -$route = new Route('[/]', ...); +$router->addRoute('[/]', ...); // Accepted URLs: Parameters: // /en/download lang => en, name => download // /download lang => null, name => download ``` -Obviously, if a parameter is inside an optional sequence, it's optional too and defaults to `null`. Sequence should define it's surroundings, in this case a slash which must follow a parameter, if set. -The technique may be used for example for optional language subdomains: +Of course, when a parameter is part of an optional sequence, it also becomes optional. If it does not have a default value, it will be null. + +Optional sections can also be in the domain: ```php -$route = new Route('//[.]%domain%//', ...); +$router->addRoute('//[.]example.com//', ...); ``` Sequences may be freely nested and combined: ```php -$route = new Route( +$router->addRoute( '[[-]/][/page-]', - ... + ['controller' => 'Homepage'] ); // Accepted URLs: @@ -217,55 +160,102 @@ $route = new Route( // /hello/page-12 ``` - -URL generator tries to keep the URL as short as possible (while unique), so what can be omitted is not used. That's why `index[.html]` route generates `/index`. This behavior can be inverted by writing an exclamation mark after the leftmost square bracket that denotes the respective optional sequence: +URL generator tries to keep the URL as short as possible, so what can be omitted is omitted. Therefore, for example, a route `index[.html]` generates a path `/index`. You can reverse this behavior by writing an exclamation mark after the left square bracket: ```php // accepts both /hello and /hello.html, generates /hello -$route = new Route('[.html]'); +$router->addRoute('[.html]', ...); // accepts both /hello and /hello.html, generates /hello.html -$route = new Route('[!.html]'); +$router->addRoute('[!.html]', ...); ``` - Optional parameters (ie. parameters having default value) without square brackets do behave as if wrapped like this: ```php -$route = new Route('//'); +$router->addRoute('//', ...); // equals to: -$route = new Route('[/[/[]]]'); +$router->addRoute('[/[/[]]]', ...); ``` -If we would like to change how the rightmost slashes are generated, that is instead of `/homepage/` get a `/homepage`, we can adjust the route: +To change how the rightmost slash is generated, i.e. instead of `/homepage/` get a `/homepage`, adjust the route this way: + +```php +$router->addRoute('[[/[/]]]', ...); +``` + + +Wildcards +--------- + +In the absolute path mask, we can use the following wildcards to avoid, for example, the need to write a domain to the mask, which may differ in the development and production environment: + +- `%tld%` = top level domain, e.g. `com` or `org` +- `%sld%` = second level domain, e.g. `example` +- `%domain%` = domain without subdomains, e.g. `example.com` +- `%host%` = whole host, e.g. `www.example.com` +- `%basePath%` = path to the root directory ```php -$route = new Route('[[/[/]]]'); +$router->addRoute('//www.%domain%/%basePath%//', ...); +$router->addRoute('//www.%sld%.%tld%/%basePath%////', [ - 'presenter' => 'Homepage', +$router->addRoute('/[/]', [ + 'controller' => 'Homepage', 'action' => 'default', - 'id' => null, ]); ``` -will generate English URLs, such as `/product/detail/123`, `/cart` or `/catalog/view`. If we would like to translate those URLs, we can use a *dictionary* defined under `Route::FILTER_TABLE` key. We'd extend the route so: +Or we can use this form, notice the rewriting of the validation regular expression: + +```php +use Nette\Routing\Route; + +$router->addRoute('/[/]', [ + 'controller' => [ + Route::VALUE => 'Homepage', + ], + 'action' => [ + Route::VALUE => 'default', + ], + 'id' => [ + Route::PATTERN => '\d+', + ], +]); +``` + +These more talkative formats are useful for adding other metadata. + + + +Filters and Translations +------------------------ + +It's a good practice to write source code in English, but what if you need your website to have translated URL to different language? Simple routes such as: + +```php +$router->addRoute('/', ['controller' => 'Homepage', 'action' => 'default']); +``` + +will generate English URLs, such as `/product/123` or `/cart`. If we want to have controllers and actions in the URL translated to Deutsch (e.g. `/produkt/123` or `/einkaufswagen`), we can use a translation dictionary. To add it, we already need a "more talkative" variant of the second parameter: ```php -$route = new Route('//', [ - 'presenter' => [ - Route::VALUE => 'Homepage', // default value +use Nette\Routing\Route; + +$router->addRoute('/', [ + 'controller' => [ + Route::VALUE => 'Homepage', Route::FILTER_TABLE => [ - // translated string in URL => presenter + // string in URL => controller 'produkt' => 'Product', 'einkaufswagen' => 'Cart', 'katalog' => 'Catalog', @@ -274,53 +264,265 @@ $route = new Route('//', [ 'action' => [ Route::VALUE => 'default', Route::FILTER_TABLE => [ - 'sehen' => 'view', + 'liste' => 'list', ], ], - 'id' => null, ]); ``` -Multiple keys under `Route::FILTER_TABLE` may have the same value. That's how aliases are created. The last value is the canonical one (used for generating links). - -Dictionaries may be applied to any path parameter. If a translation is not found, the original (non-translated) value is used. The route by default accepts both translated (e.g. `/einkaufswagen`) and original (e.g. `/cart`) URLs. If you would like to accept only translated URLs, you need to add `Route::FILTER_STRICT => true` to the route definition. +Multiple dictionary keys can by used for the same controller. They will create various aliases for it. The last key is considered to be the canonical variant (i.e. the one that will be in the generated URL). +The translation table can be applied to any parameter in this way. However, if the translation does not exist, the original value is taken. We can change this behavior by adding `Router::FILTER_STRICT => true` and the route will then reject the URL if the value is not in the dictionary. -Besides setting dictionaries as arrays, it's possible to set **input and output filters**. Input and output filter are callbacks defined under `Route::FILTER_IN` and `Route::FILTER_OUT` keys respectively. +In addition to the translation dictionary in the form of an array, it is possible to set own translation functions: ```php -$route = new Route('/', [ - 'action' => [ - Route::FILTER_IN => function ($action) { - return strrev($action); - }, - Route::FILTER_OUT => function ($action) { - return strrev($action); - }, +use Nette\Routing\Route; + +$router->addRoute('//', [ + 'controller' => [ + Route::VALUE => 'Homepage', + Route::FILTER_IN => function (string $s): string { ... }, + Route::FILTER_OUT => function (string $s): string { ... }, ], + 'action' => 'default', + 'id' => null, ]); ``` -The **input filter** accepts value from URL and should return a value which will be passed to a presenter. The **output filter** accepts value from presenter and should return a value which will be used in URL. If any of those filters is unable to filter the value (usually because it is invalid), it should return `null`. +The function `Route::FILTER_IN` converts between the parameter in the URL and the string, which is then passed to the controller, the function `FILTER_OUT` ensures the conversion in the opposite direction. -Besides filters for specific parameters, you can also define **global filters** which accepts an associative array with all parameters and returns an array with filtered parameters. Global filters are defined under `null` key. +Global Filters +-------------- + +Besides filters for specific parameters, you can also define global filters that receive an associative array of all parameters that they can modify in any way and then return. Global filters are defined under `null` key. ```php -$route = new Route('/', [ - 'presenter' => 'Homepage', +use Nette\Routing\Route; + +$router->addRoute('/', [ + 'controller' => 'Homepage', 'action' => 'default', null => [ - Route::FILTER_IN => function (array $params) { - // ... - return $params; - }, - Route::FILTER_OUT => function (array $params) { - // ... - return $params; - }, + Route::FILTER_IN => function (array $params): array { ... }, + Route::FILTER_OUT => function (array $params): array { ... }, ], ]); ``` -You can use *global filters* to filter certain parameter based on a value of another parameter, e.g. translate `` and `` based on ``. +Global filters give you the ability to adjust the behavior of the route in absolutely any way. We can use them, for example, to modify parameters based on other parameters. For example, translation `` and `` based on the current value of parameter ``. + +If a parameter has a custom filter defined and a global filter exists at the same time, custom `FILTER_IN` is executed before the global and vice versa global `FILTER_OUT` is executed before the custom. Thus, inside the global filter are the values of the parameters `controller` resp. `action` written in PascalCase resp. camelCase style. + + +ONE_WAY flag +------------ + +One-way routes are used to preserve the functionality of old URLs that the application no longer generates but still accepts. We flag them with `ONE_WAY`: + +```php +// old URL /product-info?id=123 +$router->addRoute('product-info', ['controller' => 'Product'], $router::ONE_WAY); +// new URL /product/123 +$router->addRoute('product/', ['controller' => 'Product']); +``` + +When accessing the old URL, the controller automatically redirects to the new URL so that search engines do not index these pages twice (see [#SEO and canonization]). + + +Subdomains +---------- + +Route collections can be grouped by subdomains: + +```php +$router = new RouteList; +$router->withDomain('example.com') + ->addRoute('rss', ['controller' => 'Feed', 'action' => 'rss']) + ->addRoute('/'); +``` + +You can also use [wildcards](#wildcards) in your domain name: + +```php +$router = new RouteList; +$router->withDomain('example.%tld%') + ... +``` + +Path Prefix +----------- + +Route collections can be grouped by path in URL: + +```php +$router = new RouteList; +$router->withPath('/eshop') + // matches URL /eshop/rss + ->addRoute('rss', ['controller' => 'Feed', 'action' => 'rss']) + // matches URL /eshop// + ->addRoute('/'); +``` + +Combinations +------------ + +The above usage can be combined: + +```php +$router = (new RouteList) + ->withDomain('admin.example.com') + ->addRoute(...) + ->addRoute(...) + ->end() + ->withDomain('example.com') + ->withPath('export') + ->addRoute(...) + ... +``` + + +Query Parameters +---------------- + +Masks can also contain query parameters (parameters after the question mark in the URL). They cannot define a validation expression, but they can change the name under which they are passed to the controller: + +```php +// use query parameter 'cat' as a 'categoryId' in application +$router->addRoute('product ? id= & cat=', ...); +``` + + +Foo Parameters +-------------- + +We're going deeper now. Foo parameters are basically unnamed parameters which allow to match a regular expression. The following route matches `/index`, `/index.html`, `/index.htm` and `/index.php`: + +```php +$router->addRoute('index', ['controller' => 'Homepage']); +``` + +It's also possible to explicitly define a string which will be used for URL generation. The string must be placed directly after the question mark. The following route is similar to the previous one, but generates `/index.html` instead of `/index` because the string `.html` is set as a "generated value". + +```php +$router->addRoute('index', ['controller' => 'Homepage']); +``` + + + +SimpleRouter +============ + +A much simpler router than the Route Collection is [SimpleRouter](https://api.nette.org/3.0/Nette/Routing/SimpleRouter.html). It can be used when there's no need for a specific URL format, when `mod_rewrite` (or alternatives) is not available or when we simply do not want to bother with user-friendly URLs yet. + +Generates addresses in roughly this form: + +``` +http://example.com/?controller=Product&action=detail&id=123 +``` + +The parameter of the `SimpleRouter` constructor is a default controller & action, ie. action to be executed if we open e.g. `http://example.com/` without additional parameters. + +```php +// defaults to controller 'Homepage' and action 'default' +$router = new Nette\Routing\SimpleRouter(['controller' => 'Homepage', 'action' => 'default']); +``` + + +HTTPS +===== + +In order to use the HTTPS protocol, it is necessary to activate it on hosting and to configure the server. + +Redirection of the entire site to HTTPS must be performed at the server level, for example using the .htaccess file in the root directory of our application, with HTTP code 301. The settings may differ depending on the hosting and looks something like this: + +```php + + RewriteEngine On + ... + RewriteCond %{HTTPS} off + RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + ... + +``` + +The router generates a URL with the same protocol as the page was loaded, so there is no need to set anything else. + +However, if we exceptionally need different routes to run under different protocols, we will put it in the route mask: + +```php +// Will generate an HTTP address +$router->addRoute('http://%host%//', ...); + +// Will generate an HTTPS address +$router->addRoute('https://%host%//', ...); +``` + + +Custom Router +============= + +The following lines are intended for very advanced users. You can create your own router and naturally add it into your route collection. The router is an implementation of the [Router](https://api.nette.org/3.0/Nette/Routing/Router.html) interface with two methods: + +```php +use Nette\Http\IRequest as HttpRequest; +use Nette\Http\UrlScript; + +class MyRouter implements Nette\Routing\Router +{ + public function match(HttpRequest $httpRequest): ?array + { + // ... + } + + public function constructUrl(array $params, UrlScript $refUrl): ?string + { + // ... + } +} +``` + +Method `match` processes the current request in the parameter [$httpRequest](https://doc.nette.org/http-request-response#toc-http-request) (which offers more than just URL) into the an array containing the name of the controller and its parameters. If it cannot process the request, it returns null. + +Method `constructUrl`, on the other hand, generates an absolute URL from the array of parameters. It can use the information from parameter `$refUrl`, which is the current URL. + +To add custom router to the route collection, use `add()`: + +```php +$router = new Nette\Routing\RouteList; +$router->add(new MyRouter); +$router->addRoute(...); +... +``` + +Usage of Router +=============== + +We will create router and [HTTP Request](https://doc.nette.org/http-request-response) objects: + +```php +$router = ...; +$httpRequest = (new Nette\Http\RequestFactory)->fromGlobals(); +``` + +Now we have to let the router to work: + +```php +$params = $router->match($httpRequest); +if ($params === null) { + // no matching route found, we will send a 404 error + exit; +} + +// we process the received parameters +$controller = $params['controller']; +... +``` + +And vice versa, we will use the router to create the link: + +```php +$params = ['controller' => 'Article', 'id' => 123]; +$url = $router->constructUrl($params, $httpRequest->getUrl()); +``` diff --git a/src/Routing/Route.php b/src/Routing/Route.php index d81731a4..03e60537 100644 --- a/src/Routing/Route.php +++ b/src/Routing/Route.php @@ -11,6 +11,7 @@ use Nette; use Nette\Utils\Strings; +use function array_key_exists, is_array, count, strlen; /** @@ -52,7 +53,7 @@ class Route implements Router protected $defaultMeta = [ '#' => [ // default style for path parameters self::PATTERN => '[^/]+', - self::FILTER_OUT => [__CLASS__, 'param2path'], + self::FILTER_OUT => [self::class, 'param2path'], ], ]; @@ -104,7 +105,9 @@ public function match(Nette\Http\IRequest $httpRequest): ?array if ($this->type === self::HOST) { $host = $url->getHost(); $path = '//' . $host . $url->getPath(); - $parts = ip2long($host) ? [$host] : array_reverse(explode('.', $host)); + $parts = ip2long($host) + ? [$host] + : array_reverse(explode('.', $host)); $re = strtr($re, [ '/%basePath%/' => preg_quote($url->getBasePath(), '#'), '%tld%' => preg_quote($parts[0], '#'), @@ -212,7 +215,9 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri } if (is_scalar($params[$name])) { - $params[$name] = $params[$name] === false ? '0' : (string) $params[$name]; + $params[$name] = $params[$name] === false + ? '0' + : (string) $params[$name]; } if (isset($meta[self::FIXITY])) { @@ -235,7 +240,10 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri $params[$name] = $meta[self::FILTER_OUT]($params[$name]); } - if (isset($meta[self::PATTERN]) && !preg_match("#(?:{$meta[self::PATTERN]})\\z#A", rawurldecode((string) $params[$name]))) { + if ( + isset($meta[self::PATTERN]) + && !preg_match("#(?:{$meta[self::PATTERN]})$#DA", rawurldecode((string) $params[$name])) + ) { return null; // pattern not match } } @@ -271,17 +279,15 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri } elseif ($name[0] === '?') { // "foo" parameter continue; - } elseif (isset($params[$name]) && $params[$name] != '') { // intentionally == + } elseif (isset($params[$name]) && $params[$name] !== '') { $required = count($brackets); // make this level required $url = $params[$name] . $url; unset($params[$name]); } elseif (isset($metadata[$name][self::FIXITY])) { // has default value? - if ($required === null && !$brackets) { // auto-optional - $url = ''; - } else { - $url = $metadata[$name][self::DEFAULT] . $url; - } + $url = $required === null && !$brackets // auto-optional + ? '' + : $metadata[$name][self::DEFAULT] . $url; } else { return null; // missing parameter '$name' @@ -299,7 +305,9 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri } else { $host = $refUrl->getHost(); - $parts = ip2long($host) ? [$host] : array_reverse(explode('.', $host)); + $parts = ip2long($host) + ? [$host] + : array_reverse(explode('.', $host)); $url = strtr($url, [ '/%basePath%/' => $refUrl->getBasePath(), '%tld%' => $parts[0], @@ -318,7 +326,7 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri $sep = ini_get('arg_separator.input'); $query = http_build_query($params, '', $sep ? $sep[0] : '&'); - if ($query != '') { // intentionally == + if ($query !== '') { $url .= '?' . $query; } @@ -352,14 +360,16 @@ private function setMask(string $mask, array $metadata): void if (array_key_exists(self::VALUE, $meta)) { if (is_scalar($meta[self::VALUE])) { - $metadata[$name][self::VALUE] = $meta[self::VALUE] === false ? '0' : (string) $meta[self::VALUE]; + $metadata[$name][self::VALUE] = $meta[self::VALUE] === false + ? '0' + : (string) $meta[self::VALUE]; } $metadata[$name]['fixity'] = self::CONSTANT; } } if (strpbrk($mask, '?<>[]') === false) { - $this->re = '#' . preg_quote($mask, '#') . '/?\z#A'; + $this->re = '#' . preg_quote($mask, '#') . '/?$#DA'; $this->sequence = [$mask]; $this->metadata = $metadata; return; @@ -385,7 +395,9 @@ private function setMask(string $mask, array $metadata): void } unset($meta[self::PATTERN]); - $meta[self::FILTER_TABLE_OUT] = empty($meta[self::FILTER_TABLE]) ? null : array_flip($meta[self::FILTER_TABLE]); + $meta[self::FILTER_TABLE_OUT] = empty($meta[self::FILTER_TABLE]) + ? null + : array_flip($meta[self::FILTER_TABLE]); $metadata[$name] = $meta; if ($param !== '') { @@ -432,7 +444,9 @@ private function setMask(string $mask, array $metadata): void if ($name[0] === '?') { // "foo" parameter $name = substr($name, 1); - $re = $pattern ? '(?:' . preg_quote($name, '#') . "|$pattern)$re" : preg_quote($name, '#') . $re; + $re = $pattern + ? '(?:' . preg_quote($name, '#') . "|$pattern)$re" + : preg_quote($name, '#') . $re; $sequence[1] = $name . $sequence[1]; continue; } @@ -440,7 +454,7 @@ private function setMask(string $mask, array $metadata): void // pattern, condition & metadata $meta = ($metadata[$name] ?? []) + ($this->defaultMeta[$name] ?? $this->defaultMeta['#']); - if ($pattern == '' && isset($meta[self::PATTERN])) { + if ($pattern === '' && isset($meta[self::PATTERN])) { $pattern = $meta[self::PATTERN]; } @@ -449,7 +463,9 @@ private function setMask(string $mask, array $metadata): void $meta[self::FIXITY] = self::PATH_OPTIONAL; } - $meta[self::FILTER_TABLE_OUT] = empty($meta[self::FILTER_TABLE]) ? null : array_flip($meta[self::FILTER_TABLE]); + $meta[self::FILTER_TABLE_OUT] = empty($meta[self::FILTER_TABLE]) + ? null + : array_flip($meta[self::FILTER_TABLE]); if (array_key_exists(self::VALUE, $meta)) { if (isset($meta[self::FILTER_TABLE_OUT][$meta[self::VALUE]])) { $meta[self::DEFAULT] = $meta[self::FILTER_TABLE_OUT][$meta[self::VALUE]]; @@ -490,7 +506,7 @@ private function setMask(string $mask, array $metadata): void } $this->aliases = $aliases; - $this->re = '#' . $re . '/?\z#A'; + $this->re = '#' . $re . '/?$#DA'; $this->metadata = $metadata; $this->sequence = $sequence; } diff --git a/src/Routing/SimpleRouter.php b/src/Routing/SimpleRouter.php index 9c1decd7..411e9536 100644 --- a/src/Routing/SimpleRouter.php +++ b/src/Routing/SimpleRouter.php @@ -47,7 +47,7 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri { // remove default values; null values are retain foreach ($this->defaults as $key => $value) { - if (isset($params[$key]) && $params[$key] == $value) { // intentionally == + if (isset($params[$key]) && (string) $params[$key] === (string) $value) { unset($params[$key]); } } @@ -55,7 +55,7 @@ public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?stri $url = $refUrl->getHostUrl() . $refUrl->getPath(); $sep = ini_get('arg_separator.input'); $query = http_build_query($params, '', $sep ? $sep[0] : '&'); - if ($query != '') { // intentionally == + if ($query !== '') { $url .= '?' . $query; } return $url; diff --git a/tests/Route/scalarParams.phpt b/tests/Route/scalarParams.phpt index 33c4664d..44491594 100644 --- a/tests/Route/scalarParams.phpt +++ b/tests/Route/scalarParams.phpt @@ -13,7 +13,7 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -test(function () { +test('', function () { $route = new Route('/', [ ]); @@ -42,7 +42,7 @@ test(function () { }); -test(function () { +test('', function () { $route = new Route('/', [ 'param' => 12, ]); @@ -76,7 +76,7 @@ test(function () { }); -test(function () { +test('', function () { $route = new Route('/', [ 'param' => 12.1, ]); @@ -110,7 +110,7 @@ test(function () { }); -test(function () { +test('', function () { $route = new Route('/', [ 'param' => true, ]); @@ -144,7 +144,7 @@ test(function () { }); -test(function () { +test('', function () { $route = new Route('/', [ 'param' => false, ]); @@ -178,7 +178,7 @@ test(function () { }); -test(function () { +test('', function () { $route = new Route('/', [ 'param' => null, ]); diff --git a/tests/Route/withNamedParamsInQuery.phpt b/tests/Route/withNamedParamsInQuery.phpt index b476f2c2..0efde865 100644 --- a/tests/Route/withNamedParamsInQuery.phpt +++ b/tests/Route/withNamedParamsInQuery.phpt @@ -23,8 +23,12 @@ testRouteIn($route, '/?act=action', [ 'test' => 'testvalue', ], '/?act=action&test=testvalue'); -testRouteIn($route, '/?act=default', [ +testRouteIn($route, '/?act=default', PHP_VERSION_ID < 80000 ? [ 'presenter' => 'default', 'action' => 'default', 'test' => 'testvalue', +] : [ + 'action' => 'default', + 'presenter' => 'default', + 'test' => 'testvalue', ], '/?test=testvalue'); diff --git a/tests/SimpleRouter/basic.phpt b/tests/SimpleRouter/basic.phpt index 39dbe248..d1603d7a 100644 --- a/tests/SimpleRouter/basic.phpt +++ b/tests/SimpleRouter/basic.phpt @@ -46,7 +46,7 @@ $res = $router->constructUrl($params, $url); Assert::same('https://nette.org/file.php?presenter=myPresenter&action=action&test=testvalue', $res); -test(function () { +test('', function () { $router = new SimpleRouter; $url = new Http\UrlScript('http://nette.org'); $httpRequest = new Http\Request($url); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 670d63a5..f770cd65 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -17,7 +17,7 @@ date_default_timezone_set('Europe/Prague'); -function test(\Closure $function): void +function test(string $title, Closure $function): void { $function(); } @@ -44,7 +44,9 @@ function testRouteIn(Nette\Routing\Router $route, string $relativeUrl, array $ex unset($params['extra']); $result = $route->constructUrl($params, $url); - $result = $result && !strncmp($result, 'http://example.com', 18) ? substr($result, 18) : $result; + $result = $result && !strncmp($result, 'http://example.com', 18) + ? substr($result, 18) + : $result; Assert::same($expectedUrl, $result); } }