<?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'); } }); $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()); $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::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()); } }