├── CHANGELOG.md ├── .gitignore ├── .coveralls.yml ├── .editorconfig ├── tests ├── FakeRouter.php └── RouterTest.php ├── wp-router.php ├── phpunit.xml ├── autoload.php ├── .travis.yml ├── LICENSE.md ├── composer.json ├── README.md └── src └── Router.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | docs 4 | vendor 5 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | coverage_clover: tests/logs/clover.xml 2 | json_path: tests/logs/coveralls-upload.json 3 | service_name: travis-ci 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /tests/FakeRouter.php: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rareloop/wp-router", 3 | "description": "Router", 4 | "keywords": [ 5 | "rareloop", 6 | "router" 7 | ], 8 | "homepage": "https://github.com/rareloop/wp-router", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Joe Lambert", 13 | "email": "joe@rareloop.com", 14 | "homepage": "https://rareloop.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.0", 20 | "rareloop/router": "^1.0.0" 21 | }, 22 | "require-dev": { 23 | "brain/monkey": "^2.0.2", 24 | "mockery/mockery": "~0.9.9", 25 | "phpunit/phpunit": "~5.7", 26 | "satooshi/php-coveralls": "^1.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Rareloop\\WordPress\\Router\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Rareloop\\WordPress\\Router\\Test\\": "tests" 36 | } 37 | }, 38 | "scripts": { 39 | "test": "vendor/bin/phpunit" 40 | }, 41 | "config": { 42 | "sort-packages": true 43 | }, 44 | "type": "wordpress-plugin" 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **This package is no longer supported. Use at your own risk. We recommend using the underlying router: https://github.com/Rareloop/router** 2 | 3 | # Rare WordPress Router 4 | ![CI](https://travis-ci.org/Rareloop/wp-router.svg?branch=master) 5 | 6 | A WordPress wrapper around the [Rareloop PHP Router](https://github.com/rareloop/router). Easily handle custom endpoints on your WordPress site with this plugin. 7 | 8 | ## Installation 9 | 10 | Although not a requirement, using Composer and a setup like [Bedrock](https://roots.io/bedrock/) is the recommended installation method. 11 | 12 | ``` 13 | composer require rareloop/wp-router 14 | ``` 15 | 16 | ## Usage 17 | 18 | ### Creating Routes 19 | 20 | #### Map 21 | 22 | Creating a route is done using the `map` function: 23 | 24 | ```php 25 | use Rareloop\WordPress\Router\Router; 26 | 27 | // Creates a route that matches the uri `/posts/list` both GET 28 | // and POST requests. 29 | Router::map(['GET', 'POST'], 'posts/list', function () { 30 | return 'Hello World'; 31 | }); 32 | ``` 33 | 34 | `map()` takes 3 parameters: 35 | 36 | - `methods` (array): list of matching request methods, valid values: 37 | + `GET` 38 | + `POST` 39 | + `PUT` 40 | + `PATCH` 41 | + `DELETE` 42 | + `OPTIONS` 43 | - `uri` (string): The URI to match against 44 | - `action` (function|string): Either a closure or a Controller string 45 | 46 | #### Route Parameters 47 | Parameters can be defined on routes using the `{keyName}` syntax. When a route matches that contains parameters, an instance of the `RouteParams` object is passed to the action. 48 | 49 | ```php 50 | Router::map(['GET'], 'posts/{id}', function(RouteParams $params) { 51 | return $params->id; 52 | }); 53 | ``` 54 | 55 | #### Named Routes 56 | Routes can be named so that their URL can be generated programatically: 57 | 58 | ```php 59 | Router::map(['GET'], 'posts/all', function () {})->name('posts.index'); 60 | 61 | $url = Router::url('posts.index'); 62 | ``` 63 | 64 | If the route requires parameters you can be pass an associative array as a second parameter: 65 | 66 | ```php 67 | Router::map(['GET'], 'posts/{id}', function () {})->name('posts.show'); 68 | 69 | $url = Router::url('posts.show', ['id' => 123]); 70 | ``` 71 | 72 | #### HTTP Verb Shortcuts 73 | Typically you only need to allow one HTTP verb for a route, for these cases the following shortcuts can be used: 74 | 75 | ```php 76 | Router::get('test/route', function () {}); 77 | Router::post('test/route', function () {}); 78 | Router::put('test/route', function () {}); 79 | Router::patch('test/route', function () {}); 80 | Router::delete('test/route', function () {}); 81 | Router::options('test/route', function () {}); 82 | ``` 83 | 84 | #### Setting the basepath 85 | The router assumes you're working from the route of a domain. If this is not the case you can set the base path: 86 | 87 | ```php 88 | Router::setBasePath('base/path'); 89 | Router::map(['GET'], 'route/uri', function () {}); // `/base/path/route/uri` 90 | ``` 91 | 92 | #### Controllers 93 | If you'd rather use a class to group related route actions together you can pass a Controller String to `map()` instead of a closure. The string takes the format `{name of class}@{name of method}`. It is important that you use the complete namespace with the class name. 94 | 95 | Example: 96 | 97 | ```php 98 | // TestController.php 99 | namespace \MyNamespace; 100 | 101 | class TestController 102 | { 103 | public function testMethod() 104 | { 105 | return 'Hello World'; 106 | } 107 | } 108 | 109 | // routes.php 110 | Router::map(['GET'], 'route/uri', '\MyNamespace\TestController@testMethod'); 111 | ``` 112 | 113 | ### Creating Groups 114 | It is common to group similar routes behind a common prefix. This can be achieved using Route Groups: 115 | 116 | ```php 117 | Router::group('prefix', function ($group) { 118 | $group->map(['GET'], 'route1', function () {}); // `/prefix/route1` 119 | $group->map(['GET'], 'route2', function () {}); // `/prefix/route2§` 120 | }); 121 | ``` 122 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | setBasePath($basePath); 36 | 37 | // Give a chance for the outside app to modify the Router object post configuration 38 | $router = static::$singleton = apply_filters('rareloop_router_configured', $router); 39 | 40 | // Listen for when we should check whether any defined routes match 41 | add_action('wp_loaded', [static::class, 'processRequest']); 42 | } 43 | 44 | /** 45 | * Attempt to match the current request against the defined routes 46 | * 47 | * If a route matches the Response will be sent to the client and PHP will exit. 48 | * 49 | * @return void 50 | */ 51 | public static function processRequest() 52 | { 53 | $request = Request::createFromGlobals(); 54 | $response = static::match($request); 55 | 56 | if ($response->getStatusCode() === 404) { 57 | return; 58 | } 59 | 60 | $response->send(); 61 | static::shutdown(); 62 | } 63 | 64 | /** 65 | * Shutdown PHP 66 | * 67 | * @return void 68 | * @codeCoverageIgnore 69 | */ 70 | protected static function shutdown() 71 | { 72 | exit(); 73 | } 74 | 75 | /** 76 | * Get the singleton instance of the Router 77 | * 78 | * @return Rareloop\Router\Router 79 | */ 80 | private static function instance() : RareRouter 81 | { 82 | if (!isset(static::$singleton)) { 83 | static::$singleton = apply_filters('rareloop_router_created', new RareRouter); 84 | } 85 | 86 | return static::$singleton; 87 | } 88 | 89 | /** 90 | * Match the provided Request against the defined routes and return a Response 91 | * 92 | * @param Symfony\Component\HttpFoundation\Request $request 93 | * @return Symfony\Component\HttpFoundation\Response 94 | */ 95 | public static function match(Request $request) : Response 96 | { 97 | return static::instance()->match($request); 98 | } 99 | 100 | /** 101 | * Map a route 102 | * 103 | * @param array $verbs 104 | * @param string $uri 105 | * @param callable|string $callback 106 | * @return Rareloop\Router\Route 107 | */ 108 | public static function map(array $verbs, string $uri, $callback): Route 109 | { 110 | return static::instance()->map($verbs, $uri, $callback); 111 | } 112 | 113 | /** 114 | * Map a route using the GET method 115 | * 116 | * @param string $uri 117 | * @param callable|string $callback 118 | * @return Rareloop\Router\Route 119 | */ 120 | public static function get(string $uri, $callback) : Route 121 | { 122 | return static::instance()->get($uri, $callback); 123 | } 124 | 125 | /** 126 | * Map a route using the POST method 127 | * 128 | * @param string $uri 129 | * @param callable|string $callback 130 | * @return Rareloop\Router\Route 131 | */ 132 | public static function post(string $uri, $callback) : Route 133 | { 134 | return static::instance()->post($uri, $callback); 135 | } 136 | 137 | /** 138 | * Map a route using the PATCH method 139 | * 140 | * @param string $uri 141 | * @param callable|string $callback 142 | * @return Rareloop\Router\Route 143 | */ 144 | public static function patch(string $uri, $callback) : Route 145 | { 146 | return static::instance()->patch($uri, $callback); 147 | } 148 | 149 | /** 150 | * Map a route using the PUT method 151 | * 152 | * @param string $uri 153 | * @param callable|string $callback 154 | * @return Rareloop\Router\Route 155 | */ 156 | public static function put(string $uri, $callback) : Route 157 | { 158 | return static::instance()->put($uri, $callback); 159 | } 160 | 161 | /** 162 | * Map a route using the DELETE method 163 | * 164 | * @param string $uri 165 | * @param callable|string $callback 166 | * @return Rareloop\Router\Route 167 | */ 168 | public static function delete(string $uri, $callback) : Route 169 | { 170 | return static::instance()->delete($uri, $callback); 171 | } 172 | 173 | /** 174 | * Map a route using the OPTIONS method 175 | * 176 | * @param string $uri 177 | * @param callable|string $callback 178 | * @return Rareloop\Router\Route 179 | */ 180 | public static function options(string $uri, $callback) : Route 181 | { 182 | return static::instance()->options($uri, $callback); 183 | } 184 | 185 | /** 186 | * Create a Route group 187 | * 188 | * @param string $prefix 189 | * @param callable $callback 190 | * @return Rareloop\Router\Router 191 | */ 192 | public static function group(string $prefix, $callback) : RareRouter 193 | { 194 | return static::instance()->group($prefix, $callback); 195 | } 196 | 197 | /** 198 | * Get the URL for a named route 199 | * 200 | * @param string $name 201 | * @param array $params 202 | * @return string 203 | */ 204 | public static function url(string $name, $params = []) 205 | { 206 | return static::instance()->url($name, $params); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /tests/RouterTest.php: -------------------------------------------------------------------------------- 1 | alias(function ($key) use ($url) { 32 | if ($key === 'url') { 33 | return $url; 34 | } 35 | }); 36 | } 37 | 38 | /** @test */ 39 | public function map_returns_a_route_object() 40 | { 41 | $this->setSiteUrl('http://example.com/'); 42 | Router::init(); 43 | 44 | $route = Router::map(['GET'], '/test/123', function () {}); 45 | 46 | $this->assertInstanceOf(Route::class, $route); 47 | $this->assertSame(['GET'], $route->getMethods()); 48 | $this->assertSame('/test/123', $route->getUri()); 49 | } 50 | 51 | /** @test */ 52 | public function get_returns_a_route_object() 53 | { 54 | $this->setSiteUrl('http://example.com/'); 55 | Router::init(); 56 | 57 | $route = Router::get('/test/123', function () {}); 58 | 59 | $this->assertInstanceOf(Route::class, $route); 60 | $this->assertSame(['GET'], $route->getMethods()); 61 | $this->assertSame('/test/123', $route->getUri()); 62 | } 63 | 64 | /** @test */ 65 | public function post_returns_a_route_object() 66 | { 67 | $this->setSiteUrl('http://example.com/'); 68 | Router::init(); 69 | 70 | $route = Router::post('/test/123', function () {}); 71 | 72 | $this->assertInstanceOf(Route::class, $route); 73 | $this->assertSame(['POST'], $route->getMethods()); 74 | $this->assertSame('/test/123', $route->getUri()); 75 | } 76 | 77 | /** @test */ 78 | public function patch_returns_a_route_object() 79 | { 80 | $this->setSiteUrl('http://example.com/'); 81 | Router::init(); 82 | 83 | $route = Router::patch('/test/123', function () {}); 84 | 85 | $this->assertInstanceOf(Route::class, $route); 86 | $this->assertSame(['PATCH'], $route->getMethods()); 87 | $this->assertSame('/test/123', $route->getUri()); 88 | } 89 | 90 | /** @test */ 91 | public function put_returns_a_route_object() 92 | { 93 | $this->setSiteUrl('http://example.com/'); 94 | Router::init(); 95 | 96 | $route = Router::put('/test/123', function () {}); 97 | 98 | $this->assertInstanceOf(Route::class, $route); 99 | $this->assertSame(['PUT'], $route->getMethods()); 100 | $this->assertSame('/test/123', $route->getUri()); 101 | } 102 | 103 | /** @test */ 104 | public function delete_returns_a_route_object() 105 | { 106 | $this->setSiteUrl('http://example.com/'); 107 | Router::init(); 108 | 109 | $route = Router::delete('/test/123', function () {}); 110 | 111 | $this->assertInstanceOf(Route::class, $route); 112 | $this->assertSame(['DELETE'], $route->getMethods()); 113 | $this->assertSame('/test/123', $route->getUri()); 114 | } 115 | 116 | /** @test */ 117 | public function options_returns_a_route_object() 118 | { 119 | $this->setSiteUrl('http://example.com/'); 120 | Router::init(); 121 | 122 | $route = Router::options('/test/123', function () {}); 123 | 124 | $this->assertInstanceOf(Route::class, $route); 125 | $this->assertSame(['OPTIONS'], $route->getMethods()); 126 | $this->assertSame('/test/123', $route->getUri()); 127 | } 128 | 129 | /** @test */ 130 | public function match_returns_a_response_object() 131 | { 132 | $this->setSiteUrl('http://example.com/'); 133 | Router::init(); 134 | 135 | $request = Request::create('/test/123', 'GET'); 136 | $count = 0; 137 | 138 | $route = Router::get('/test/123', function () use (&$count) { 139 | $count++; 140 | 141 | return 'abc123'; 142 | }); 143 | $response = Router::match($request); 144 | 145 | $this->assertSame(1, $count); 146 | $this->assertInstanceOf(Response::class, $response); 147 | } 148 | 149 | /** @test */ 150 | public function basepath_is_correctly_set_from_wordpress_url() 151 | { 152 | $this->setSiteUrl('http://example.com/sub-path/'); 153 | $request = Request::create('/sub-path/test/123', 'GET'); 154 | Router::init(); 155 | 156 | $route = Router::get('/test/123', function () { 157 | return 'abc123'; 158 | }); 159 | 160 | $response = Router::match($request); 161 | 162 | $this->assertSame(200, $response->getStatusCode()); 163 | $this->assertSame('abc123', $response->getContent()); 164 | } 165 | 166 | /** @test */ 167 | public function basepath_is_correctly_set_from_wordpress_url_when_no_trailing_slash() 168 | { 169 | $this->setSiteUrl('http://example.com/sub-path'); 170 | $request = Request::create('/sub-path/test/123', 'GET'); 171 | Router::init(); 172 | 173 | $route = Router::get('/test/123', function () { 174 | return 'abc123'; 175 | }); 176 | 177 | $response = Router::match($request); 178 | 179 | $this->assertSame(200, $response->getStatusCode()); 180 | $this->assertSame('abc123', $response->getContent()); 181 | } 182 | 183 | /** @test */ 184 | public function can_add_routes_in_a_group() 185 | { 186 | $request = Request::create('/prefix/all', 'GET'); 187 | $count = 0; 188 | 189 | Router::group('prefix', function ($group) use (&$count) { 190 | $count++; 191 | $this->assertInstanceOf(RouteGroup::class, $group); 192 | 193 | $group->get('all', function () { 194 | return 'abc123'; 195 | }); 196 | }); 197 | $response = Router::match($request); 198 | 199 | $this->assertSame(1, $count); 200 | $this->assertSame(200, $response->getStatusCode()); 201 | $this->assertSame('abc123', $response->getContent()); 202 | } 203 | 204 | /** @test */ 205 | public function can_generate_canonical_uri_with_trailing_slash_for_named_route() 206 | { 207 | $route = Router::get('/posts/all', function () {})->name('test.name'); 208 | 209 | $this->assertSame('/posts/all/', Router::url('test.name')); 210 | } 211 | 212 | /** @test */ 213 | public function filter_is_fired_when_router_is_created() 214 | { 215 | $this->setSiteUrl('http://example.com/sub-path'); 216 | 217 | Filters\expectApplied('rareloop_router_created') 218 | ->once() 219 | ->with(\Mockery::type(RareRouter::class)); 220 | 221 | Router::init(); 222 | } 223 | 224 | /** @test */ 225 | public function filter_is_fired_when_router_is_configured() 226 | { 227 | $this->setSiteUrl('http://example.com/sub-path'); 228 | 229 | Filters\expectApplied('rareloop_router_configured') 230 | ->once() 231 | ->with(\Mockery::type(RareRouter::class)); 232 | 233 | Router::init(); 234 | } 235 | 236 | /** @test */ 237 | public function router_will_match_request_when_wp_loaded_is_fired() 238 | { 239 | $this->setSiteUrl('http://example.com/'); 240 | Actions\expectAdded('wp_loaded')->with([Router::class, 'processRequest'])->once(); 241 | 242 | Router::init(); 243 | } 244 | 245 | /** @test */ 246 | public function response_will_be_sent_when_a_route_matches() 247 | { 248 | $this->setSiteUrl('http://example.com/'); 249 | $count = 0; 250 | 251 | // Create a mock response that the router will return 252 | $mockResponse = \Mockery::mock('Symfony\Component\HttpFoundation\Response')->makePartial(); 253 | $mockResponse->setStatusCode(200); 254 | $mockResponse->shouldReceive('send')->times(1); 255 | 256 | // Use a mock instead of the real router 257 | $mockRouter = \Mockery::mock('Rareloop\Router\Router')->makePartial(); 258 | $mockRouter->shouldReceive('match')->times(1)->andReturn($mockResponse); 259 | Filters\expectApplied('rareloop_router_configured') 260 | ->once() 261 | ->andReturn($mockRouter); 262 | 263 | // Provide a callback to test whether shutdown is called 264 | Router::setShutdownCallback(function () use (&$count) { 265 | $count++; 266 | }); 267 | Router::init(); 268 | Router::processRequest(); 269 | 270 | $this->assertSame(1, $count); 271 | } 272 | 273 | /** @test */ 274 | public function response_will_be_ignored_when_a_route_does_not_match() 275 | { 276 | $this->setSiteUrl('http://example.com/'); 277 | $count = 0; 278 | 279 | // Create a mock response that the router will return 280 | $mockResponse = \Mockery::mock('Symfony\Component\HttpFoundation\Response')->makePartial(); 281 | $mockResponse->setStatusCode(404); 282 | $mockResponse->shouldReceive('send')->times(0); 283 | 284 | // Use a mock instead of the real router 285 | $mockRouter = \Mockery::mock('Rareloop\Router\Router')->makePartial(); 286 | $mockRouter->shouldReceive('match')->times(1)->andReturn($mockResponse); 287 | Filters\expectApplied('rareloop_router_configured') 288 | ->once() 289 | ->andReturn($mockRouter); 290 | 291 | // Provide a callback to test whether shutdown is called 292 | Router::setShutdownCallback(function () use (&$count) { 293 | $count++; 294 | }); 295 | Router::init(); 296 | Router::processRequest(); 297 | 298 | $this->assertSame(0, $count); 299 | } 300 | } 301 | --------------------------------------------------------------------------------