Skip to content

Commit

Permalink
[HttpServer] Add RoutingHandler
Browse files Browse the repository at this point in the history
  • Loading branch information
il-masaru-yamagishi committed Sep 1, 2024
1 parent 6ffe3b2 commit 24d204c
Show file tree
Hide file tree
Showing 10 changed files with 762 additions and 1 deletion.
84 changes: 84 additions & 0 deletions src/HttpMessage/Tests/UriPlaceholderResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

/**
* Class UriTest
* @package Rayleigh\HttpMessage
* @author Masaru Yamagishi <akai_inu@live.jp>
* @license Apache-2.0
*/

namespace Rayleigh\HttpMessage\Tests;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use Rayleigh\HttpMessage\HeaderBag;
use Rayleigh\HttpMessage\Internal\UriPartsParser;
use Rayleigh\HttpMessage\ServerRequest;
use Rayleigh\HttpMessage\Uri;
use Rayleigh\HttpMessage\UriPlaceholderResolver;

/**
* Class UriTest
* @package Rayleigh\HttpMessage\Tests
*/
#[CoversClass(UriPlaceholderResolver::class)]
#[UsesClass(ServerRequest::class)]
#[UsesClass(HeaderBag::class)]
#[UsesClass(UriPartsParser::class)]
#[UsesClass(Uri::class)]
final class UriPlaceholderResolverTest extends TestCase
{
#[Test]
public function testNoPlaceholder(): void
{
$resolver = new UriPlaceholderResolver();
$request = new ServerRequest('GET', '/');

$actual = $resolver->resolve($request, '/');

self::assertFalse($actual);
}

/**
* Get resolve data
* @return iterable<string, array{0: ServerRequest, 1: array<string, string|int>, 2: string}>
*/
public static function getResolveData(): iterable
{
return [
'Single placeholder' => [
new ServerRequest('GET', '/users/1'),
['user_id' => '1'],
'/users/{user_id}',
],
'Multiple placeholders' => [
new ServerRequest('GET', '/users/1/posts/slug'),
['user_id' => '1', 'posts' => 'slug'],
'/users/{user_id}/posts/{posts}',
],
];
}

/**
* Test resolve
* @param ServerRequest $request
* @param array<string, string|int> $expected
* @param string $path
* @return void
*/
#[Test]
#[DataProvider('getResolveData')]
public function testResolve(ServerRequest $request, array $expected, string $path): void
{
$resolver = new UriPlaceholderResolver();
$request = $resolver->resolve($request, $path);

self::assertInstanceOf(ServerRequest::class, $request);
self::assertSame($expected, $request->getAttributes());
}
}
40 changes: 40 additions & 0 deletions src/HttpMessage/UriPlaceholderResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

/**
* @author Masaru Yamagishi <akai_inu@live.jp>
* @license Apache-2.0
*/

namespace Rayleigh\HttpMessage;

use Psr\Http\Message\ServerRequestInterface;

/* readonly */ class UriPlaceholderResolver
{
public function resolve(ServerRequestInterface $request, string $path): ServerRequestInterface|bool
{
$count = 0;
// Replace placeholder to named capture group
$regexp_pattern = \preg_replace('#\{(\w+)\}#', '(?P<$1>[^\/]+)', $path, count: $count);
if ($count === 0) {
return false; // no placeholder
}

$matches = [];
$actual_path = $request->getUri()->getPath();

if (\preg_match('#^' . $regexp_pattern . '$#', $actual_path, $matches)) {
/** @var array<string, string> */
$result = \array_filter($matches, fn(string|int $key): bool => !\is_int($key), \ARRAY_FILTER_USE_KEY);

foreach ($result as $key => $value) {
$request = $request->withAttribute($key, $value);
}
return $request;
}

return false; // @codeCoverageIgnore
}
}
2 changes: 1 addition & 1 deletion src/HttpServer/ResponseEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ private function emitHeader(ResponseInterface $response): void
$informational_response = $status_code >= 100 && $status_code < 200;
if ($informational_response && \function_exists('headers_sent') === false) {
// Skip when SAPI does not support headers_sent
return;
return; // @codeCoverageIgnore
}

/** @var string $name Fixes PSR-7 definition */
Expand Down
1 change: 1 addition & 0 deletions src/HttpServer/RoadRunnerHttpDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* RoadRunner HTTP Dispatcher
* @package Rayleigh\HttpServer
* @link https://docs.roadrunner.dev/docs/php-worker/worker
* @codeCoverageIgnore
*/
class RoadRunnerHttpDispatcher
{
Expand Down
34 changes: 34 additions & 0 deletions src/HttpServer/Route.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

/**
* @author Masaru Yamagishi <akai_inu@live.jp>
* @license Apache-2.0
*/

namespace Rayleigh\HttpServer;

use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
* Route information
* @package Rayleigh\HttpServer
*/
final /* readonly */ class Route
{
/**
* Constructor
* @param string $method
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
*/
public function __construct(
public readonly string $method,

Check failure on line 29 in src/HttpServer/Route.php

View workflow job for this annotation

GitHub Actions / Unit Tests on PHP 8.2 with lowest dependencies

PossiblyUnusedProperty

src/HttpServer/Route.php:29:32: PossiblyUnusedProperty: Cannot find any references to property Rayleigh\HttpServer\Route::$method (see https://psalm.dev/149)

Check failure on line 29 in src/HttpServer/Route.php

View workflow job for this annotation

GitHub Actions / Unit Tests on PHP 8.3 with highest dependencies

PossiblyUnusedProperty

src/HttpServer/Route.php:29:32: PossiblyUnusedProperty: Cannot find any references to property Rayleigh\HttpServer\Route::$method (see https://psalm.dev/149)

Check failure on line 29 in src/HttpServer/Route.php

View workflow job for this annotation

GitHub Actions / Unit Tests on PHP 8.3 with lowest dependencies

PossiblyUnusedProperty

src/HttpServer/Route.php:29:32: PossiblyUnusedProperty: Cannot find any references to property Rayleigh\HttpServer\Route::$method (see https://psalm.dev/149)
public readonly string $path,

Check failure on line 30 in src/HttpServer/Route.php

View workflow job for this annotation

GitHub Actions / Unit Tests on PHP 8.2 with lowest dependencies

PossiblyUnusedProperty

src/HttpServer/Route.php:30:32: PossiblyUnusedProperty: Cannot find any references to property Rayleigh\HttpServer\Route::$path (see https://psalm.dev/149)

Check failure on line 30 in src/HttpServer/Route.php

View workflow job for this annotation

GitHub Actions / Unit Tests on PHP 8.3 with highest dependencies

PossiblyUnusedProperty

src/HttpServer/Route.php:30:32: PossiblyUnusedProperty: Cannot find any references to property Rayleigh\HttpServer\Route::$path (see https://psalm.dev/149)

Check failure on line 30 in src/HttpServer/Route.php

View workflow job for this annotation

GitHub Actions / Unit Tests on PHP 8.3 with lowest dependencies

PossiblyUnusedProperty

src/HttpServer/Route.php:30:32: PossiblyUnusedProperty: Cannot find any references to property Rayleigh\HttpServer\Route::$path (see https://psalm.dev/149)
public readonly RequestHandlerInterface $handler,
public readonly array $middlewares = [],
) {}
}
24 changes: 24 additions & 0 deletions src/HttpServer/RouteNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

/**
* @author Masaru Yamagishi <akai_inu@live.jp>
* @license Apache-2.0
*/

namespace Rayleigh\HttpServer;

use RuntimeException;

/**
* Route not found exception
* @package Rayleigh\HttpServer
*/
class RouteNotFoundException extends RuntimeException
{
public function __construct(string $method, string $path)
{
parent::__construct("Route not found: {$method} {$path}", 404);
}
}
183 changes: 183 additions & 0 deletions src/HttpServer/RoutingHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

declare(strict_types=1);

/**
* @author Masaru Yamagishi <akai_inu@live.jp>
* @license Apache-2.0
*/

namespace Rayleigh\HttpServer;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Rayleigh\HttpMessage\UriPlaceholderResolver;

/**
* PSR-15 Routing handler
* @package Rayleigh\HttpServer
*/
final /* readonly */ class RoutingHandler implements RequestHandlerInterface
{
/** @var array<string, array<string, Route>> $routes */
private array $routes = [];

/**
* Get routes
* @return array<string, array<string, Route>>
*/
public function getRoutes(): array
{
return $this->routes;
}

/**
* Add GET route
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function get(string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
$this->addRoute('GET', $path, $handler, $middlewares);
}

/**
* Add POST route
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function post(string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
$this->addRoute('POST', $path, $handler, $middlewares);
}

/**
* Add PUT route
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function put(string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
$this->addRoute('PUT', $path, $handler, $middlewares);
}

/**
* Add DELETE route
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function delete(string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
$this->addRoute('DELETE', $path, $handler, $middlewares);
}

/**
* Add PATCH route
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function patch(string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
$this->addRoute('PATCH', $path, $handler, $middlewares);
}

/**
* Add HEAD route
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function head(string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
$this->addRoute('HEAD', $path, $handler, $middlewares);
}

/**
* Add OPTIONS route
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function options(string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
$this->addRoute('OPTIONS', $path, $handler, $middlewares);
}

/**
* Add TRACE route
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function trace(string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
$this->addRoute('TRACE', $path, $handler, $middlewares);
}

/**
* Add route
* @param string $method HTTP Method
* @param string $path
* @param RequestHandlerInterface $handler
* @param array<int, MiddlewareInterface> $middlewares
* @return void
*/
public function addRoute(string $method, string $path, RequestHandlerInterface $handler, array $middlewares = []): void
{
if (\array_key_exists($path, $this->routes) === false) {
$this->routes[$path] = [];
}
$this->routes[$path][$method] = new Route($method, $path, $handler, $middlewares);
}

public function handle(ServerRequestInterface $request): ResponseInterface
{
$method = \strtoupper($request->getMethod());
$path = $request->getUri()->getPath();

// Check exact routes
if (\array_key_exists($path, $this->routes) && \array_key_exists($method, $this->routes[$path])) {
$route = $this->routes[$path][$method];
return (new ServerRequestRunner($route->middlewares, $route->handler))->handle($request);
}

// Check placeholder routes
$resolver = new UriPlaceholderResolver();
foreach ($this->routes as $path_pattern => $routes) {
if (\array_key_exists($method, $routes) === false) {
continue;
}
$result = $resolver->resolve($request, $path_pattern);
if ($result instanceof ServerRequestInterface === false) {
continue;
}
$route = $routes[$method];
return (new ServerRequestRunner($route->middlewares, $route->handler))->handle($result);
}

// Check wildcard routes
if (\array_key_exists('*', $this->routes)) {
$route = $this->routes['*'][$method] ?? $this->routes['*']['OPTIONS'] ?? null;
if ($route !== null) {
return (new ServerRequestRunner($route->middlewares, $route->handler))->handle($request);
}
}

throw new RouteNotFoundException($method, $path);
}
}
Loading

0 comments on commit 24d204c

Please sign in to comment.