diff --git a/docs/api/app.md b/docs/api/app.md
index f881269..e18aa3a 100644
--- a/docs/api/app.md
+++ b/docs/api/app.md
@@ -40,9 +40,9 @@ which also highlights how you can use [request attributes](request.md#attributes
to access values from URI templates.
An HTTP `GET` request for `/foo` would automatically reject the HTTP request with
-a 404 (Not Found) error response unless this route is registered.
-Likewise, an HTTP `POST` request for `/user` would reject with a 405 (Method Not
-Allowed) error response unless a route for this method is also registered.
+a `404 Not Found` error response unless this route is registered.
+Likewise, an HTTP `POST` request for `/user` would reject with a `405 Method Not
+Allowed` error response unless a route for this method is also registered.
You can route any number of incoming HTTP requests to controller functions by
using the matching API methods like this:
@@ -71,6 +71,27 @@ you can use this additional shortcut:
$app->any('/user/{id}', $controller);
```
+## Redirects
+
+The `App` also offers a convenient helper method to redirect a matching route to
+a new URL like this:
+
+```php
+$app->redirect('/promo/reactphp', 'http://reactphp.org/');
+```
+
+Browsers and search engine crawlers will automatically follow the redirect with
+the `302 Found` status code by default. You can optionally pass a custom redirect
+status code in the `3xx` range to use. If this is a permanent redirect, you may
+want to use the `301 Permanent Redirect` status code to instruct search engine
+crawlers to update their index like this:
+
+```php
+$app->redirect('/blog.html', '/blog', 301);
+```
+
+See [response status codes](response.md#status-codes) for more details.
+
## Controllers
The above examples use inline closures as controller functions to make these
diff --git a/docs/api/response.md b/docs/api/response.md
index 7010fad..d5c530a 100644
--- a/docs/api/response.md
+++ b/docs/api/response.md
@@ -164,12 +164,12 @@ Each HTTP response message contains a status code that describes whether the
HTTP request has been successfully completed.
Here's a list of the most common HTTP status codes:
-* 200 (OK)
-* 301 (Permanent Redirect)
-* 302 (Temporary Redirect)
-* 403 (Forbidden)
-* 404 (Not Found)
-* 500 (Internal Server Error)
+* `200 OK`
+* `301 Permanent Redirect`
+* `302 Found` (previously `302 Temporary Redirect`)
+* `403 Forbidden`
+* `404 Not Found`
+* `500 Internal Server Error`
* …
See [list of HTTP status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) for more details.
@@ -210,7 +210,7 @@ know what you're doing.
Each controller function needs to return a response object in order to send
an HTTP response message.
If the controller functions throws an `Exception` (or `Throwable`) or any other type, the
-HTTP request will automatically be rejected with a 500 (Internal Server Error)
+HTTP request will automatically be rejected with a `500 Internal Server Error`
HTTP error response:
```php
diff --git a/src/App.php b/src/App.php
index 7b9636d..00519db 100644
--- a/src/App.php
+++ b/src/App.php
@@ -7,7 +7,6 @@
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Http\HttpServer;
-use React\Http\Message\Response;
use React\Http\Message\ServerRequest;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
@@ -113,18 +112,9 @@ public function map(array $methods, string $route, callable $handler, callable .
$this->router->map($methods, $route, $handler, ...$handlers);
}
- public function redirect($route, $target, $code = 302)
+ public function redirect(string $route, string $target, int $code = 302): void
{
- return $this->get($route, function () use ($target, $code) {
- return new Response(
- $code,
- [
- 'Content-Type' => 'text/html',
- 'Location' => $target
- ],
- 'See ' . $target . '...' . "\n"
- );
- });
+ $this->any($route, new RedirectHandler($target, $code));
}
public function run()
diff --git a/src/FilesystemHandler.php b/src/FilesystemHandler.php
index cdd0f7b..acc2642 100644
--- a/src/FilesystemHandler.php
+++ b/src/FilesystemHandler.php
@@ -68,12 +68,7 @@ public function __invoke(ServerRequestInterface $request)
\clearstatcache();
if ($valid && \is_dir($path)) {
if ($local !== '' && \substr($local, -1) !== '/') {
- return new Response(
- 302,
- [
- 'Location' => \basename($path) . '/'
- ]
- );
+ return (new RedirectHandler(\basename($path) . '/'))();
}
$response = '' . $this->html->escape($local === '' ? '/' : $local) . '' . "\n
\n";
@@ -102,12 +97,7 @@ public function __invoke(ServerRequestInterface $request)
);
} elseif ($valid && \is_file($path)) {
if ($local !== '' && \substr($local, -1) === '/') {
- return new Response(
- 302,
- [
- 'Location' => '../' . \basename($path)
- ]
- );
+ return (new RedirectHandler('../' . \basename($path)))();
}
// Assign MIME type based on file extension (same as nginx/Apache) or fall back to given default otherwise.
diff --git a/src/HtmlHandler.php b/src/HtmlHandler.php
index a81d666..81e277b 100644
--- a/src/HtmlHandler.php
+++ b/src/HtmlHandler.php
@@ -25,6 +25,7 @@ public function statusResponse(int $statusCode, string $title, string $subtitle,
strong { color: #111827; font-size: 3em; }
p { margin: .5em 0 0 0; grid-column: 2; color: #6b7280; }
code { padding: 0 .3em; background-color: #f5f6f9; }
+a { color: inherit; }
diff --git a/src/RedirectHandler.php b/src/RedirectHandler.php
new file mode 100644
index 0000000..80a9875
--- /dev/null
+++ b/src/RedirectHandler.php
@@ -0,0 +1,43 @@
+= 400) {
+ throw new \InvalidArgumentException('Invalid redirect status code given');
+ }
+
+ $this->target = $target;
+ $this->code = $redirectStatusCode;
+ $this->reason = \ucwords((new Response($redirectStatusCode))->getReasonPhrase()) ?: 'Redirect';
+ $this->html = new HtmlHandler();
+ }
+
+ public function __invoke(): ResponseInterface
+ {
+ $url = $this->html->escape($this->target);
+
+ return $this->html->statusResponse(
+ $this->code,
+ 'Redirecting to ' . $url,
+ $this->reason,
+ "Redirecting to $url...
\n"
+ )->withHeader('Location', $this->target);
+ }
+}
diff --git a/tests/AppTest.php b/tests/AppTest.php
index f2beb6e..78fbe58 100644
--- a/tests/AppTest.php
+++ b/tests/AppTest.php
@@ -282,13 +282,13 @@ public function testMapMethodAddsRouteOnRouter()
$app->map(['GET', 'POST'], '/', function () { });
}
- public function testRedirectMethodAddsGetRouteOnRouterWhichWhenInvokedReturnsRedirectResponseWithTargetLocation()
+ public function testRedirectMethodAddsAnyRouteOnRouterWhichWhenInvokedReturnsRedirectResponseWithTargetLocation()
{
$app = new App();
$handler = null;
$router = $this->createMock(RouteHandler::class);
- $router->expects($this->once())->method('map')->with(['GET'], '/', $this->callback(function ($fn) use (&$handler) {
+ $router->expects($this->once())->method('map')->with(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], '/', $this->callback(function ($fn) use (&$handler) {
$handler = $fn;
return true;
}));
@@ -305,19 +305,22 @@ public function testRedirectMethodAddsGetRouteOnRouterWhichWhenInvokedReturnsRed
/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertStringMatchesFormat("\n%a\n", (string) $response->getBody());
+
$this->assertEquals(302, $response->getStatusCode());
- $this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
$this->assertEquals('/users', $response->getHeaderLine('Location'));
- $this->assertEquals("See /users...\n", (string) $response->getBody());
+ $this->assertStringContainsString("Redirecting to /users\n", (string) $response->getBody());
+ $this->assertStringContainsString("Redirecting to /users...
\n", (string) $response->getBody());
}
- public function testRedirectMethodWithCustomRedirectCodeAddsGetRouteOnRouterWhichWhenInvokedReturnsRedirectResponseWithCustomRedirectCode()
+ public function testRedirectMethodWithCustomRedirectCodeAddsAnyRouteOnRouterWhichWhenInvokedReturnsRedirectResponseWithCustomRedirectCode()
{
$app = new App();
$handler = null;
$router = $this->createMock(RouteHandler::class);
- $router->expects($this->once())->method('map')->with(['GET'], '/', $this->callback(function ($fn) use (&$handler) {
+ $router->expects($this->once())->method('map')->with(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], '/', $this->callback(function ($fn) use (&$handler) {
$handler = $fn;
return true;
}));
@@ -334,10 +337,13 @@ public function testRedirectMethodWithCustomRedirectCodeAddsGetRouteOnRouterWhic
/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertStringMatchesFormat("\n%a\n", (string) $response->getBody());
+
$this->assertEquals(307, $response->getStatusCode());
- $this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
$this->assertEquals('/users', $response->getHeaderLine('Location'));
- $this->assertEquals("See /users...\n", (string) $response->getBody());
+ $this->assertStringContainsString("Redirecting to /users\n", (string) $response->getBody());
+ $this->assertStringContainsString("Redirecting to /users...
\n", (string) $response->getBody());
}
public function testRequestFromGlobalsWithNoServerVariablesDefaultsToGetRequestToLocalhost()
diff --git a/tests/FilesystemHandlerTest.php b/tests/FilesystemHandlerTest.php
index 9a5acd2..6fe898a 100644
--- a/tests/FilesystemHandlerTest.php
+++ b/tests/FilesystemHandlerTest.php
@@ -267,8 +267,13 @@ public function testInvokeWithValidPathToDirectoryButWithoutTrailingSlashWillRet
/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertStringMatchesFormat("\n%a\n", (string) $response->getBody());
+
$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('.github/', $response->getHeaderLine('Location'));
+ $this->assertStringContainsString("Redirecting to .github/\n", (string) $response->getBody());
+ $this->assertStringContainsString("Redirecting to .github/...
\n", (string) $response->getBody());
}
public function testInvokeWithValidPathToFileButWithTrailingSlashWillReturnRedirectToPathWithoutSlash()
@@ -282,7 +287,12 @@ public function testInvokeWithValidPathToFileButWithTrailingSlashWillReturnRedir
/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertStringMatchesFormat("\n%a\n", (string) $response->getBody());
+
$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('../LICENSE', $response->getHeaderLine('Location'));
+ $this->assertStringContainsString("Redirecting to ../LICENSE\n", (string) $response->getBody());
+ $this->assertStringContainsString("Redirecting to ../LICENSE...
\n", (string) $response->getBody());
}
}
diff --git a/tests/RedirectHandlerTest.php b/tests/RedirectHandlerTest.php
new file mode 100644
index 0000000..1a111c6
--- /dev/null
+++ b/tests/RedirectHandlerTest.php
@@ -0,0 +1,100 @@
+assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertStringMatchesFormat("\n%a\n", (string) $response->getBody());
+ $this->assertStringMatchesFormat("%a