Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor to move redirects to new RedirectHandler #43

Merged
merged 4 commits into from
Sep 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions docs/api/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/api/response.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 2 additions & 12 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 2 additions & 12 deletions src/FilesystemHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<strong>' . $this->html->escape($local === '' ? '/' : $local) . '</strong>' . "\n<ul>\n";
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/HtmlHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
</style>
</head>
<body>
Expand Down
43 changes: 43 additions & 0 deletions src/RedirectHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace FrameworkX;

use Psr\Http\Message\ResponseInterface;
use React\Http\Message\Response;

/**
* @internal
*/
class RedirectHandler
{
private $target;
private $code;
private $reason;

/** @var HtmlHandler */
private $html;

public function __construct(string $target, int $redirectStatusCode = 302)
{
if ($redirectStatusCode < 300 || $redirectStatusCode === 304 || $redirectStatusCode >= 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,
"<p>Redirecting to <a href=\"$url\"><code>$url</code></a>...</p>\n"
)->withHeader('Location', $this->target);
}
}
22 changes: 14 additions & 8 deletions tests/AppTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}));
Expand All @@ -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("<!DOCTYPE html>\n<html>%a</html>\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("<title>Redirecting to /users</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Redirecting to <a href=\"/users\"><code>/users</code></a>...</p>\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;
}));
Expand All @@ -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("<!DOCTYPE html>\n<html>%a</html>\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("<title>Redirecting to /users</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Redirecting to <a href=\"/users\"><code>/users</code></a>...</p>\n", (string) $response->getBody());
}

public function testRequestFromGlobalsWithNoServerVariablesDefaultsToGetRequestToLocalhost()
Expand Down
10 changes: 10 additions & 0 deletions tests/FilesystemHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!DOCTYPE html>\n<html>%a</html>\n", (string) $response->getBody());

$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('.github/', $response->getHeaderLine('Location'));
$this->assertStringContainsString("<title>Redirecting to .github/</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Redirecting to <a href=\".github/\"><code>.github/</code></a>...</p>\n", (string) $response->getBody());
}

public function testInvokeWithValidPathToFileButWithTrailingSlashWillReturnRedirectToPathWithoutSlash()
Expand All @@ -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("<!DOCTYPE html>\n<html>%a</html>\n", (string) $response->getBody());

$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('../LICENSE', $response->getHeaderLine('Location'));
$this->assertStringContainsString("<title>Redirecting to ../LICENSE</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Redirecting to <a href=\"../LICENSE\"><code>../LICENSE</code></a>...</p>\n", (string) $response->getBody());
}
}
100 changes: 100 additions & 0 deletions tests/RedirectHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace Framework\Tests;

use FrameworkX\RedirectHandler;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;

class RedirectHandlerTest extends TestCase
{
public function testInvokeReturnsResponseWithRedirectToGivenLocation()
{
$handler = new RedirectHandler('http://example.com/');

$response = $handler();

/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertStringMatchesFormat("<!DOCTYPE html>\n<html>%a</html>\n", (string) $response->getBody());
$this->assertStringMatchesFormat("%a<style nonce=\"%s\">\n%a", (string) $response->getBody());
$this->assertStringMatchesFormat('style-src \'nonce-%s\'; img-src \'self\'; default-src \'none\'', $response->getHeaderLine('Content-Security-Policy'));

$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('http://example.com/', $response->getHeaderLine('Location'));
$this->assertStringContainsString("<title>Redirecting to http://example.com/</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<h1>302</h1>\n", (string) $response->getBody());
$this->assertStringContainsString("<strong>Found</strong>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Redirecting to <a href=\"http://example.com/\"><code>http://example.com/</code></a>...</p>\n", (string) $response->getBody());
}

public function testInvokeReturnsResponseWithPermanentRedirectToGivenLocationAndCode()
{
$handler = new RedirectHandler('/', 301);

$response = $handler();

/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);

$this->assertEquals(301, $response->getStatusCode());
$this->assertEquals('/', $response->getHeaderLine('Location'));
$this->assertStringContainsString("<title>Redirecting to /</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<h1>301</h1>\n", (string) $response->getBody());
$this->assertStringContainsString("<strong>Moved Permanently</strong>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Redirecting to <a href=\"/\"><code>/</code></a>...</p>\n", (string) $response->getBody());
}

public function testInvokeReturnsResponseWithCustomRedirectStatusCodeAndGivenLocation()
{
$handler = new RedirectHandler('/', 399);

$response = $handler();

/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);

$this->assertEquals(399, $response->getStatusCode());
$this->assertEquals('/', $response->getHeaderLine('Location'));
$this->assertStringContainsString("<title>Redirecting to /</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<h1>399</h1>\n", (string) $response->getBody());
$this->assertStringContainsString("<strong>Redirect</strong>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Redirecting to <a href=\"/\"><code>/</code></a>...</p>\n", (string) $response->getBody());
}

public function testInvokeReturnsResponseWithRedirectToGivenLocationWithSpecialCharsEncoded()
{
$handler = new RedirectHandler('/hello%20w%7Frld?a=1&b=2');

$response = $handler();

/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);

$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('/hello%20w%7Frld?a=1&b=2', $response->getHeaderLine('Location'));
$this->assertStringContainsString("<title>Redirecting to /hello%20w%7Frld?a=1&amp;b=2</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<h1>302</h1>\n", (string) $response->getBody());
$this->assertStringContainsString("<strong>Found</strong>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Redirecting to <a href=\"/hello%20w%7Frld?a=1&amp;b=2\"><code>/hello%20w%7Frld?a=1&amp;b=2</code></a>...</p>\n", (string) $response->getBody());
}

public function testConstructWithSuccessCodeThrows()
{
$this->expectException(\InvalidArgumentException::class);
new RedirectHandler('/', 200);
}

public function testConstructWithNotModifiedCodeThrows()
{
$this->expectException(\InvalidArgumentException::class);
new RedirectHandler('/', 304);
}

public function testConstructWithBadRequestCodeThrows()
{
$this->expectException(\InvalidArgumentException::class);
new RedirectHandler('/', 400);
}
}
6 changes: 3 additions & 3 deletions tests/acceptance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,14 @@ out=$(curl -v $base/users/ 2>&1); match "HTTP/.* 404"
out=$(curl -v $base/users/a/b 2>&1); match "HTTP/.* 404"

out=$(curl -v $base/LICENSE 2>&1); match "HTTP/.* 200" && match -iP "Content-Type: text/plain[\r\n]"
out=$(curl -v $base/source 2>&1); match -i "Location: /source/" && match -iP "Content-Type: text/html[\r\n]"
out=$(curl -v $base/source 2>&1); match -i "Location: /source/" && match -iP "Content-Type: text/html; charset=utf-8[\r\n]"
out=$(curl -v $base/source/ 2>&1); match "HTTP/.* 200"
out=$(curl -v $base/source/composer.json 2>&1); match "HTTP/.* 200" && match -iP "Content-Type: application/json[\r\n]"
out=$(curl -v $base/source/LICENSE 2>&1); match "HTTP/.* 200" && match -iP "Content-Type: text/plain[\r\n]"
out=$(curl -v $base/source/LICENSE/ 2>&1); match -i "Location: ../LICENSE"
out=$(curl -v $base/source/LICENSE/ 2>&1); match -i "Location: ../LICENSE" && match -iP "Content-Type: text/html; charset=utf-8[\r\n]"
out=$(curl -v $base/source/LICENSE// 2>&1); match "HTTP/.* 404"
out=$(curl -v $base/source//LICENSE 2>&1); match "HTTP/.* 404"
out=$(curl -v $base/source/tests 2>&1); match -i "Location: tests/"
out=$(curl -v $base/source/tests 2>&1); match -i "Location: tests/" && match -iP "Content-Type: text/html; charset=utf-8[\r\n]"
out=$(curl -v $base/source/invalid 2>&1); match "HTTP/.* 404"
out=$(curl -v $base/source/bin%00ary 2>&1); match "HTTP/.* 40[40]" # expects 404, but not processed with nginx (400) and Apache (404)

Expand Down