Skip to content

Commit

Permalink
Fix #136: Fix and refactor CORS and OPTIONS auto-response
Browse files Browse the repository at this point in the history
Co-authored-by: yiiliveext <yiiliveext@gmail.com>
Co-authored-by: Alexander Makarov <sam@rmcreative.ru>
  • Loading branch information
3 people committed Dec 16, 2021
1 parent 315b5bb commit b1d59b1
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 51 deletions.
26 changes: 26 additions & 0 deletions README.md
Expand Up @@ -110,6 +110,32 @@ $routerMiddleware = new Yiisoft\Router\Middleware\Router($router, $responseFacto
In case of a route match router middleware executes handler middleware attached to the route. If there is no match, next
application middleware processes the request.

## Automatic OPTIONS response and CORS

By default, router responds automatically to OPTIONS requests based on the routes defined:

```
HTTP/1.1 204 No Content
Allow: GET, HEAD
```

Generally that is fine unless you need [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). In this
case, you can add a middleware for handling it such as [tuupola/cors-middleware](https://github.com/tuupola/cors-middleware):

```php
use Yiisoft\Router\Group;
use \Tuupola\Middleware\CorsMiddleware;

return [
Group::create('/api')
->withCors(CorsMiddleware::class)
->routes([
// ...
]
);
];
```

## Creating URLs

URLs could be created using `UrlGeneratorInterface::generate()`. Let's assume a route is defined like the following:
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Expand Up @@ -34,7 +34,8 @@
"roave/infection-static-analysis-plugin": "^1.10",
"spatie/phpunit-watcher": "^1.23",
"vimeo/psalm": "^4.12",
"yiisoft/dummy-provider": "^1.0.0"
"yiisoft/dummy-provider": "^1.0.0",
"yiisoft/test-support": "^1.3"
},
"autoload": {
"psr-4": {
Expand Down
25 changes: 25 additions & 0 deletions src/Group.php
Expand Up @@ -25,6 +25,7 @@ final class Group implements GroupInterface
private bool $routesAdded = false;
private bool $middlewareAdded = false;
private array $disabledMiddlewareDefinitions = [];
private $corsMiddleware = null;
private ?MiddlewareDispatcher $dispatcher;

private function __construct(?string $prefix = null, MiddlewareDispatcher $dispatcher = null)
Expand Down Expand Up @@ -87,6 +88,30 @@ public function withDispatcher(MiddlewareDispatcher $dispatcher): GroupInterface
return $group;
}

public function withCors($middlewareDefinition): GroupInterface
{
$group = clone $this;
$group->corsMiddleware = $middlewareDefinition;

return $group;
}

/**
* @return mixed Middleware definition for CORS requests.
*/
public function getCorsMiddleware()
{
return $this->corsMiddleware;
}

/**
* @return bool Middleware definition for CORS requests.
*/
public function hasCorsMiddleware(): bool
{
return $this->corsMiddleware !== null;
}

public function hasDispatcher(): bool
{
return $this->dispatcher !== null;
Expand Down
10 changes: 10 additions & 0 deletions src/GroupInterface.php
Expand Up @@ -46,4 +46,14 @@ public function host(string $host): self;
public function namePrefix(string $namePrefix): self;

public function routes(...$routes): self;

/**
* Adds a middleware definition that handles CORS requests.
* If set, routes for {@see Method::OPTIONS} request will be added automatically.
*
* @param mixed $middlewareDefinition Middleware definition for CORS requests.
*
* @return self
*/
public function withCors($middlewareDefinition): self;
}
17 changes: 1 addition & 16 deletions src/Middleware/Router.php
Expand Up @@ -22,7 +22,6 @@ final class Router implements MiddlewareInterface
private ResponseFactoryInterface $responseFactory;
private MiddlewareDispatcher $dispatcher;
private CurrentRoute $currentRoute;
private ?bool $autoResponseOptions = true;

public function __construct(
UrlMatcherInterface $matcher,
Expand All @@ -43,7 +42,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
$this->currentRoute->setUri($request->getUri());

if ($result->isMethodFailure()) {
if ($this->autoResponseOptions && $request->getMethod() === Method::OPTIONS) {
if ($request->getMethod() === Method::OPTIONS) {
return $this->responseFactory->createResponse(Status::NO_CONTENT)
->withHeader('Allow', implode(', ', $result->methods()));
}
Expand All @@ -60,18 +59,4 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface

return $result->withDispatcher($this->dispatcher)->process($request, $handler);
}

public function withAutoResponseOptions(): self
{
$new = clone $this;
$new->autoResponseOptions = true;
return $new;
}

public function withoutAutoResponseOptions(): self
{
$new = clone $this;
$new->autoResponseOptions = false;
return $new;
}
}
42 changes: 40 additions & 2 deletions src/RouteCollection.php
Expand Up @@ -5,6 +5,8 @@
namespace Yiisoft\Router;

use InvalidArgumentException;
use Psr\Http\Message\ResponseFactoryInterface;
use Yiisoft\Http\Method;

final class RouteCollection implements RouteCollectionInterface
{
Expand Down Expand Up @@ -96,10 +98,11 @@ private function injectGroup(Group $group, array &$tree, string $prefix = '', st
$prefix .= $group->getPrefix();
$namePrefix .= $group->getNamePrefix();
$items = $group->getItems();
$pattern = null;
$host = null;
foreach ($items as $item) {
if ($item instanceof Group || $item->hasMiddlewares()) {
$groupMiddlewares = $group->getMiddlewareDefinitions();
foreach ($groupMiddlewares as $middleware) {
foreach ($group->getMiddlewareDefinitions() as $middleware) {
$item = $item->prependMiddleware($middleware);
}
}
Expand All @@ -109,6 +112,10 @@ private function injectGroup(Group $group, array &$tree, string $prefix = '', st
}

if ($item instanceof Group) {
if ($group->hasCorsMiddleware()) {
$item = $item->withCors($group->getCorsMiddleware());
}
/** @var Group $item */
if (empty($item->getPrefix())) {
$this->injectGroup($item, $tree, $prefix, $namePrefix);
continue;
Expand All @@ -125,6 +132,10 @@ private function injectGroup(Group $group, array &$tree, string $prefix = '', st
$modifiedItem = $modifiedItem->name($namePrefix . $modifiedItem->getName());
}

if ($group->hasCorsMiddleware()) {
$this->processCors($group, $host, $pattern, $modifiedItem, $tree);
}

if (empty($tree[$group->getPrefix()])) {
$tree[] = $modifiedItem->getName();
} else {
Expand All @@ -139,6 +150,33 @@ private function injectGroup(Group $group, array &$tree, string $prefix = '', st
}
}

private function processCors(Group $group, ?string &$host, ?string &$pattern, Route &$modifiedItem, array &$tree): void
{
$middleware = $group->getCorsMiddleware();
$isNotDuplicate = !in_array(Method::OPTIONS, $modifiedItem->getMethods(), true)
&& ($pattern !== $modifiedItem->getPattern() || $host !== $modifiedItem->getHost());

$pattern = $modifiedItem->getPattern();
$host = $modifiedItem->getHost();
/** @var Route $optionsRoute */
$optionsRoute = Route::options($pattern);
if ($host !== null) {
$optionsRoute = $optionsRoute->host($host);
}
if ($isNotDuplicate) {
$optionsRoute = $optionsRoute->middleware($middleware);
if (empty($tree[$group->getPrefix()])) {
$tree[] = $optionsRoute->getName();
} else {
$tree[$group->getPrefix()][] = $optionsRoute->getName();
}
$this->routes[$optionsRoute->getName()] = $optionsRoute->action(
static fn (ResponseFactoryInterface $responseFactory) => $responseFactory->createResponse(204)
);
}
$modifiedItem = $modifiedItem->prependMiddleware($middleware);
}

/**
* Builds route tree from items
*
Expand Down
91 changes: 91 additions & 0 deletions tests/GroupTest.php
Expand Up @@ -281,6 +281,97 @@ public function testDispatcherInjected(): void
$this->assertAllRoutesAndGroupsHaveDispatcher($items);
}

public function testWithCors(): void
{
$group = Group::create()->routes(
Route::get('/info')->action(static fn () => 'info'),
Route::post('/info')->action(static fn () => 'info'),
)->withCors(
static function () {
return new Response(204);
}
);

$collector = new RouteCollector();
$collector->addGroup($group);
$routeCollection = new RouteCollection($collector);

$this->assertCount(3, $routeCollection->getRoutes());
}

public function testWithCorsDoesntDuplicateRoutes(): void
{
$group = Group::create()->routes(
Route::get('/info')->action(static fn () => 'info')->host('yii.dev'),
Route::post('/info')->action(static fn () => 'info')->host('yii.dev'),
Route::put('/info')->action(static fn () => 'info')->host('yii.test'),
)->withCors(
static function () {
return new Response(204);
}
);

$collector = new RouteCollector();
$collector->addGroup($group);
$routeCollection = new RouteCollection($collector);

$this->assertCount(5, $routeCollection->getRoutes());
}

public function testWithCorsWithNestedGroups(): void
{
$group = Group::create()->routes(
Route::get('/info')->action(static fn () => 'info'),
Route::post('/info')->action(static fn () => 'info'),
Group::create('/v1')->routes(
Route::get('/post')->action(static fn () => 'post'),
Route::post('/post')->action(static fn () => 'post'),
Route::options('/options')->action(static fn () => 'options'),
)->withCors(
static function () {
return new Response(201);
}
)
)->withCors(
static function () {
return new Response(204);
}
);

$collector = new RouteCollector();
$collector->addGroup($group);

$routeCollection = new RouteCollection($collector);
$this->assertCount(7, $routeCollection->getRoutes());
$this->assertInstanceOf(Route::class, $routeCollection->getRoute('OPTIONS /v1/post'));
}

public function testWithCorsWithNestedGroups2(): void
{
$group = Group::create()->routes(
Route::get('/info')->action(static fn () => 'info'),
Route::post('/info')->action(static fn () => 'info'),
Route::get('/v1/post')->action(static fn () => 'post'),
Group::create('/v1')->routes(
Route::post('/post')->action(static fn () => 'post'),
Route::options('/options')->action(static fn () => 'options'),
),
Group::create('/v1')->routes(
Route::put('/post')->action(static fn () => 'post'),
)
)->withCors(
static function () {
return new Response(204);
}
);
$collector = new RouteCollector();
$collector->addGroup($group);

$routeCollection = new RouteCollection($collector);
$this->assertCount(8, $routeCollection->getRoutes());
$this->assertInstanceOf(Route::class, $routeCollection->getRoute('OPTIONS /v1/post'));
}

private function getRequestHandler(): RequestHandlerInterface
{
return new class () implements RequestHandlerInterface {
Expand Down

0 comments on commit b1d59b1

Please sign in to comment.