438 lines
22 KiB
PHP
438 lines
22 KiB
PHP
<?php
|
|
// RouterTest.php
|
|
// Created: 2022-01-20
|
|
// Updated: 2025-03-15
|
|
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpUri};
|
|
use Index\Http\Content\{FormContent,MultipartFormContent,UrlEncodedFormContent};
|
|
use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon,Router};
|
|
use Index\Http\Routing\Filters\{FilterInfo,PrefixFilter};
|
|
use Index\Http\Routing\Processors\{After,Before,Postprocessor,Preprocessor,ProcessorInfo};
|
|
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute,RouteInfo};
|
|
use Index\Http\Streams\Stream;
|
|
|
|
/**
|
|
* This test isn't super representative of the current functionality
|
|
* it mostly just does the same tests that were done against the previous implementation
|
|
*/
|
|
#[CoversClass(Router::class)]
|
|
#[CoversClass(RouteInfo::class)]
|
|
#[CoversClass(PrefixFilter::class)]
|
|
#[CoversClass(ExactRoute::class)]
|
|
#[CoversClass(RouteHandler::class)]
|
|
#[CoversClass(RouteHandlerCommon::class)]
|
|
#[CoversClass(PatternRoute::class)]
|
|
#[CoversClass(FilterInfo::class)]
|
|
final class RouterTest extends TestCase {
|
|
public function testRouter(): void {
|
|
$router1 = new Router;
|
|
|
|
$router1->route(RouteInfo::exact('GET', '/', fn() => 'get'));
|
|
$router1->route(RouteInfo::exact('POST', '/', fn() => 'post'));
|
|
$router1->route(RouteInfo::exact('DELETE', '/', fn() => 'delete'));
|
|
$router1->route(RouteInfo::exact('PATCH', '/', fn() => 'patch'));
|
|
$router1->route(RouteInfo::exact('PUT', '/', fn() => 'put'));
|
|
$router1->route(RouteInfo::exact('CUSTOM', '/', fn() => 'wacky'));
|
|
|
|
$this->assertEquals('get', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
|
|
$this->assertEquals('wacky', (string)$router1->handle(HttpRequest::createRequestWithoutBody('CUSTOM', '/'))->getBody());
|
|
|
|
$router1->filter(FilterInfo::prefix('/', function() { /* this one intentionally does nothing */ }));
|
|
|
|
// registration order should matter
|
|
$router1->filter(FilterInfo::prefix('/deep', fn() => 'deep'));
|
|
|
|
$router1->filter(FilterInfo::pattern('#^/user/([A-Za-z0-9]+)/below#u', fn(string $user) => 'warioware below ' . $user));
|
|
|
|
$router1->route(RouteInfo::exact('GET', '/user/static', fn() => 'the static one'));
|
|
$router1->route(RouteInfo::exact('GET', '/user/static/below', fn() => 'below the static one'));
|
|
$router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)$#uD', fn(string $user) => $user));
|
|
$router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)/below$#uD', fn(string $user) => 'below ' . $user));
|
|
|
|
$this->assertEquals('warioware below static', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/user/static/below'))->getBody());
|
|
|
|
$router2 = new Router;
|
|
$router2->filter(FilterInfo::prefix('/', fn() => 'meow'));
|
|
$router2->route(RouteInfo::exact('GET', '/rules', fn() => 'rules page'));
|
|
$router2->route(RouteInfo::exact('GET', '/contact', fn() => 'contact page'));
|
|
$router2->route(RouteInfo::exact('GET', '/25252', fn() => 'numeric test'));
|
|
|
|
$this->assertEquals('meow', (string)$router2->handle(HttpRequest::createRequestWithoutBody('GET', '/rules'))->getBody());
|
|
}
|
|
|
|
public function testAttribute(): void {
|
|
$router = new Router;
|
|
$handler = new class implements RouteHandler {
|
|
use RouteHandlerCommon;
|
|
|
|
#[ExactRoute('GET', '/')]
|
|
public function getIndex(): string {
|
|
return 'index';
|
|
}
|
|
|
|
#[ExactRoute('POST', '/avatar')]
|
|
public function postAvatar(): string {
|
|
return 'avatar';
|
|
}
|
|
|
|
#[ExactRoute('PUT', '/static')]
|
|
public static function putStatic(): string {
|
|
return 'static';
|
|
}
|
|
|
|
#[ExactRoute('GET', '/meow')]
|
|
#[ExactRoute('POST', '/meow')]
|
|
public function multiple(): string {
|
|
return 'meow';
|
|
}
|
|
|
|
#[PrefixFilter('/filter')]
|
|
public function useFilter(): string {
|
|
return 'this intercepts';
|
|
}
|
|
|
|
#[ExactRoute('GET', '/filter')]
|
|
public function getFilter(): string {
|
|
return 'this is intercepted';
|
|
}
|
|
|
|
#[PatternRoute('GET', '/profile/([A-Za-z0-9]+)')]
|
|
public function getPattern(string $beans): string {
|
|
return sprintf('profile of %s', $beans);
|
|
}
|
|
|
|
#[PatternRoute('GET', '#^/profile-but-raw/([A-Za-z0-9]+)$#uD', raw: true)]
|
|
public function getPatternRaw(string $beans): string {
|
|
return sprintf('still the profile of %s', $beans);
|
|
}
|
|
|
|
public function hasNoAttr(): string {
|
|
return 'not a route';
|
|
}
|
|
};
|
|
|
|
$router->register($handler);
|
|
|
|
$this->assertEquals('index', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
|
|
$this->assertEquals('avatar', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/avatar'))->getBody());
|
|
$this->assertEquals('static', (string)$router->handle(HttpRequest::createRequestWithoutBody('PUT', '/static'))->getBody());
|
|
$this->assertEquals('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/meow'))->getBody());
|
|
$this->assertEquals('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/meow'))->getBody());
|
|
$this->assertEquals('profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile/Cool134'))->getBody());
|
|
$this->assertEquals('still the profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile-but-raw/Cool134'))->getBody());
|
|
}
|
|
|
|
public function testEEPROMSituation(): void {
|
|
$router = new Router;
|
|
$router->route(RouteInfo::pattern('GET', '#^/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?$#uD', fn(string $id) => "Get {$id}"));
|
|
$router->route(RouteInfo::pattern('DELETE', '#^/uploads/([A-Za-z0-9\-_]+)$#uD', fn(string $id) => "Delete {$id}"));
|
|
|
|
// make sure both GET and DELETE are able to execute with a different pattern
|
|
$this->assertEquals('Get AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))->getBody());
|
|
$this->assertEquals('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(HttpRequest::createRequestWithoutBody('DELETE', '/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'))->getBody());
|
|
}
|
|
|
|
public function testFilterInterceptionOnRoot(): void {
|
|
$router = new Router;
|
|
$router->filter(FilterInfo::prefix('/', fn() => 'expected'));
|
|
$router->route(RouteInfo::exact('GET', '/', fn() => 'unexpected'));
|
|
$router->route(RouteInfo::exact('GET', '/test', fn() => 'also unexpected'));
|
|
|
|
$this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
|
|
$this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/test'))->getBody());
|
|
$this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/error'))->getBody());
|
|
}
|
|
|
|
public function testDefaultOptionsImplementation(): void {
|
|
$router = new Router;
|
|
$router->route(RouteInfo::exact('GET', '/test', fn() => 'get'));
|
|
$router->route(RouteInfo::exact('POST', '/test', fn() => 'post'));
|
|
$router->route(RouteInfo::exact('PATCH', '/test', fn() => 'patch'));
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('OPTIONS', '/test'));
|
|
$this->assertEquals(204, $response->getStatusCode());
|
|
$this->assertEquals('No Content', $response->getReasonPhrase());
|
|
$this->assertEquals('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow'));
|
|
}
|
|
|
|
public function testDefaultProcessorsNoRegister(): void {
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessage(sprintf('preprocessor "%s" was not found', 'input:urlencoded:required'));
|
|
|
|
$router = new Router(registerDefaultProcessors: false);
|
|
$router->route(RouteInfo::exact(
|
|
'GET', '/soap',
|
|
#[Before('input:urlencoded:required')]
|
|
function() {
|
|
return 'test';
|
|
},
|
|
));
|
|
$router->handle(HttpRequest::createRequestWithoutBody('GET', '/soap'));
|
|
}
|
|
|
|
public function testDefaultProcessors(): void {
|
|
$router = new Router;
|
|
$router->register(new class implements RouteHandler {
|
|
use RouteHandlerCommon;
|
|
|
|
#[Before('input:urlencoded', required: false)]
|
|
#[Before('input:multipart', required: false)]
|
|
#[ExactRoute('POST', '/optional-form')]
|
|
public function formOptional(?FormContent $content = null): string {
|
|
if($content instanceof MultipartFormContent)
|
|
return 'multipart:' . (string)$content->getParam('test');
|
|
if($content instanceof UrlEncodedFormContent)
|
|
return 'urlencoded:' . (string)$content->getParam('test');
|
|
return 'none';
|
|
}
|
|
|
|
#[Before('input:multipart')]
|
|
#[ExactRoute('POST', '/required-multipart')]
|
|
public function multipartRequired(MultipartFormContent $content): string {
|
|
return (string)$content->getParam('test');
|
|
}
|
|
|
|
#[Before('input:urlencoded')]
|
|
#[ExactRoute('POST', '/required-urlencoded')]
|
|
public function urlencodedRequired(UrlEncodedFormContent $content): string {
|
|
return (string)$content->getParam('test');
|
|
}
|
|
|
|
#[After('output:stream')]
|
|
#[ExactRoute('GET', '/output/stream')]
|
|
public function testOutputStream(): string {
|
|
return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
|
|
}
|
|
|
|
#[After('output:plain', charset: 'utf-8')]
|
|
#[ExactRoute('GET', '/output/plain')]
|
|
public function testOutputPlain(): string {
|
|
return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
|
|
}
|
|
|
|
#[After('output:html', charset: 'us-ascii')]
|
|
#[ExactRoute('GET', '/output/html')]
|
|
public function testOutputHtml(): string {
|
|
return '<?xml idk how xml opens the prefix is enough><beans></beans>';
|
|
}
|
|
|
|
#[After('output:xml')]
|
|
#[ExactRoute('GET', '/output/xml')]
|
|
public function testOutputXml(): string {
|
|
return 'soup';
|
|
}
|
|
|
|
#[After('output:css')]
|
|
#[ExactRoute('GET', '/output/css')]
|
|
public function testOutputCss(): string {
|
|
return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
|
|
}
|
|
|
|
#[After('output:js')]
|
|
#[ExactRoute('GET', '/output/js')]
|
|
public function testOutputJs(): string {
|
|
return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
|
|
}
|
|
|
|
#[After('output:json', flags: 0)]
|
|
#[ExactRoute('GET', '/output/json')]
|
|
/** @return array{wow: string} */
|
|
public function testOutputJson(): array {
|
|
return ['wow' => 'objects?? / epic!!'];
|
|
}
|
|
|
|
#[After('output:bencode')]
|
|
#[ExactRoute('GET', '/output/bencode')]
|
|
/** @return array{benben: int} */
|
|
public function testOutputBencode(): array {
|
|
return ['benben' => 12345];
|
|
}
|
|
});
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/stream'));
|
|
$this->assertEquals('application/octet-stream', $response->getHeaderLine('Content-Type'));
|
|
$this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/plain'));
|
|
$this->assertEquals('text/plain;charset=utf-8', $response->getHeaderLine('Content-Type'));
|
|
$this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/html'));
|
|
$this->assertEquals('text/html;charset=us-ascii', $response->getHeaderLine('Content-Type'));
|
|
$this->assertEquals('<?xml idk how xml opens the prefix is enough><beans></beans>', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/xml'));
|
|
$this->assertEquals('application/xml', $response->getHeaderLine('Content-Type'));
|
|
$this->assertEquals('soup', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/css'));
|
|
$this->assertEquals('text/css', $response->getHeaderLine('Content-Type'));
|
|
$this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/js'));
|
|
$this->assertEquals('application/javascript', $response->getHeaderLine('Content-Type'));
|
|
$this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/json'));
|
|
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
|
|
$this->assertEquals('{"wow":"objects?? \/ epic!!"}', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/bencode'));
|
|
$this->assertEquals('application/x-bittorrent', $response->getHeaderLine('Content-Type'));
|
|
$this->assertEquals('d6:benbeni12345ee', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/optional-form'));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals('none', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', [], Stream::createStream('test=mewow')));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals('none', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=mewow')));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals('urlencoded:mewow', (string)$response->getBody());
|
|
|
|
// an empty string is valid too
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('')));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals('urlencoded:', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [
|
|
'----soap12345',
|
|
'Content-Disposition: form-data; name="test"',
|
|
'',
|
|
'wowof',
|
|
'----soap12345--',
|
|
'',
|
|
]))));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals('multipart:wowof', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [
|
|
'----soap12345--',
|
|
'',
|
|
]))));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals('multipart:', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-urlencoded'));
|
|
$this->assertEquals(400, $response->getStatusCode());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-multipart'));
|
|
$this->assertEquals(400, $response->getStatusCode());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=meow')));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals('meow', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream(implode("\r\n", [
|
|
'----soap56789',
|
|
'Content-Disposition: form-data; name="test"',
|
|
'',
|
|
'woof',
|
|
'----soap56789--',
|
|
'',
|
|
]))));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals('woof', (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream('test=meow')));
|
|
$this->assertEquals(400, $response->getStatusCode());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream(implode("\r\n", [
|
|
'----soap56789',
|
|
'Content-Disposition: form-data; name="test"',
|
|
'',
|
|
'woof',
|
|
'----soap56789--',
|
|
'',
|
|
]))));
|
|
$this->assertEquals(400, $response->getStatusCode());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data']], Stream::createStream(implode("\r\n", [
|
|
'----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
'Content-Disposition: form-data; name="test"',
|
|
'',
|
|
'the',
|
|
'----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--',
|
|
'',
|
|
]))));
|
|
$this->assertEquals(400, $response->getStatusCode());
|
|
}
|
|
|
|
public function testProcessors(): void {
|
|
$router = new Router;
|
|
|
|
$router->register(new class implements RouteHandler {
|
|
use RouteHandlerCommon;
|
|
|
|
#[Preprocessor('base64-decode-body')]
|
|
public function decodePre(HandlerContext $context, HttpRequest $request): void {
|
|
$context->addArgument(base64_decode((string)$request->getBody()));
|
|
}
|
|
|
|
#[Postprocessor('crash')]
|
|
public function crashPost(): void {
|
|
throw new RuntimeException('this shouldnt run!');
|
|
}
|
|
|
|
#[Postprocessor('json-encode')]
|
|
public function encodePost(HandlerContext $context, int $flags): void {
|
|
$context->halt();
|
|
$context->response->setTypeJson();
|
|
$context->response->body = Stream::createStream(
|
|
json_encode($context->result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | $flags)
|
|
);
|
|
}
|
|
|
|
/** @return array{data: string} */
|
|
#[ExactRoute('POST', '/test')]
|
|
#[Before('base64-decode-body')]
|
|
#[After('json-encode', flags: JSON_PRETTY_PRINT)]
|
|
#[After('crash')]
|
|
public function route(string $decoded): array {
|
|
return ['data' => $decoded];
|
|
}
|
|
});
|
|
|
|
$router->preprocessor(ProcessorInfo::pre('intercept', function(HttpResponseBuilder $response) {
|
|
$response->statusCode = 403;
|
|
return 'it can work like a filter too';
|
|
}));
|
|
|
|
$router->route(RouteInfo::exact(
|
|
'PUT', '/alternate',
|
|
#[Before('base64-decode-body')]
|
|
#[After('json-encode', flags: JSON_PRETTY_PRINT)]
|
|
function(HttpRequest $request, string $decoded): array {
|
|
return ['path' => $request->uri->path, 'data' => $decoded];
|
|
}
|
|
));
|
|
|
|
$router->route(RouteInfo::exact(
|
|
'GET', '/filtered',
|
|
#[Before('intercept')]
|
|
#[Before('base64-decode-body')]
|
|
function(): string {
|
|
return 'should not get here';
|
|
}
|
|
));
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('POST', '/test', [], Stream::createStream(base64_encode('mewow'))));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals("{\n \"data\": \"mewow\"\n}", (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithBody('PUT', '/alternate', [], Stream::createStream(base64_encode('soap'))));
|
|
$this->assertEquals(200, $response->getStatusCode());
|
|
$this->assertEquals("{\n \"path\": \"/alternate\",\n \"data\": \"soap\"\n}", (string)$response->getBody());
|
|
|
|
$response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/filtered'));
|
|
$this->assertEquals(403, $response->getStatusCode());
|
|
$this->assertEquals('it can work like a filter too', (string)$response->getBody());
|
|
}
|
|
}
|