diff --git a/VERSION b/VERSION index 99f6ee8..4fe10f9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2503.142359 +0.2503.150208 diff --git a/src/Dependencies.php b/src/Dependencies.php index 7be9c1e..a935bb1 100644 --- a/src/Dependencies.php +++ b/src/Dependencies.php @@ -1,7 +1,7 @@ <?php // Dependencies.php // Created: 2025-01-18 -// Updated: 2025-03-07 +// Updated: 2025-03-15 namespace Index; @@ -50,7 +50,7 @@ class Dependencies { try { $typeInfo = $paramInfo->getType(); $allowsNull = $typeInfo === null || $typeInfo->allowsNull(); - $value = null; + $value = $paramInfo->isDefaultValueAvailable() ? $paramInfo->getDefaultValue() : null; // maybe support intersection and union someday if($typeInfo instanceof ReflectionNamedType && !$typeInfo->isBuiltin()) { diff --git a/src/Http/Content/FormContent.php b/src/Http/Content/FormContent.php index 32fb08c..e3fbe3c 100644 --- a/src/Http/Content/FormContent.php +++ b/src/Http/Content/FormContent.php @@ -1,66 +1,16 @@ <?php // FormContent.php // Created: 2025-03-12 -// Updated: 2025-03-14 +// Updated: 2025-03-15 namespace Index\Http\Content; use Iterator; +use Index\Http\HttpParameters; /** * Provides a common interface for application/x-www-form-urlencoded and multipart/form-data. * - * @template TValue of string|\Stringable - * @extends Iterator<string, ?list<TValue>> + * @extends Iterator<string, ?list<string|\Stringable>> */ -interface FormContent extends Content, Iterator { - /** - * Checks if a form field is present. - * - * @param string $name Name of the form field. - * @return bool - */ - public function hasParam(string $name): bool; - - /** - * Retrieves an HTTP request form field, or null if it is not present. - * - * @param string $name Name of the request form field. - * @param int $filter A PHP filter extension filter constant. - * @param mixed[]|int $options Options for the PHP filter. - * @param mixed $default Default value to fall back on. - * @return mixed Value of the form field, null if not present. - */ - public function getParam( - string $name, - int $filter = FILTER_DEFAULT, - array|int $options = 0, - mixed $default = null, - ): mixed; - - /** - * Retrieves an HTTP request form field, or null if it is not present. - * - * @param string $name Name of the request form field. - * @param int $index Index of the parameter value. - * @param int $filter A PHP filter extension filter constant. - * @param mixed[]|int $options Options for the PHP filter. - * @param mixed $default Default value to fall back on. - * @return mixed Value of the form field, null if not present. - */ - public function getParamAt( - string $name, - int $index, - int $filter = FILTER_DEFAULT, - array|int $options = 0, - mixed $default = null, - ): mixed; - - /** - * Retrieves how many parameters of the same name appear in the HTTP request query field. - * - * @param string $name Name of the request query field. - * @return int<0, max> Amount of values. - */ - public function getParamCount(string $name): int; -} +interface FormContent extends Content, HttpParameters, Iterator {} diff --git a/src/Http/Content/MultipartFormContent.php b/src/Http/Content/MultipartFormContent.php index 8888373..fd26956 100644 --- a/src/Http/Content/MultipartFormContent.php +++ b/src/Http/Content/MultipartFormContent.php @@ -1,21 +1,25 @@ <?php // MultipartFormContent.php // Created: 2025-03-12 -// Updated: 2025-03-14 +// Updated: 2025-03-15 namespace Index\Http\Content; +use Iterator; use RuntimeException; +use Index\Http\HttpParametersCommon; use Index\Http\Content\Multipart\{FileMultipartFormData,MultipartFormData,ValueMultipartFormData}; use Index\Http\Streams\{ScopedStream,Stream,StreamBuffer}; use Psr\Http\Message\StreamInterface; /** * Implements multipart/form-data. - * - * @implements FormContent<MultipartFormData> + + * @implements Iterator<string, ?list<MultipartFormData>> */ -class MultipartFormContent implements FormContent { +class MultipartFormContent implements FormContent, Iterator { + use HttpParametersCommon; + /** @var string[] */ private array $keys; @@ -59,19 +63,6 @@ class MultipartFormContent implements FormContent { ++$this->position; } - public function hasParam(string $name): bool { - return isset($this->params[$name]); - } - - public function getParam( - string $name, - int $filter = FILTER_DEFAULT, - array|int $options = 0, - mixed $default = null, - ): mixed { - return $this->getParamAt($name, 0, $filter, $options, $default); - } - /** * Retrieves value of the form field, including additional data. * @@ -82,20 +73,6 @@ class MultipartFormContent implements FormContent { return $this->getParamDataAt($name, 0); } - public function getParamAt( - string $name, - int $index, - int $filter = FILTER_DEFAULT, - array|int $options = 0, - mixed $default = null, - ): mixed { - if(!isset($this->params[$name]) || !isset($this->params[$name][$index])) - return $default; - - $value = filter_var($this->params[$name][$index], $filter, $options); - return is_scalar($value) ? $value : $default; - } - /** * Retrieves value of the form field, including additional data. * @@ -108,10 +85,6 @@ class MultipartFormContent implements FormContent { ? $this->params[$name][$index] : null; } - public function getParamCount(string $name): int { - return isset($this->params[$name]) ? count($this->params[$name]) : 0; - } - /** * Parses multipart form data in a stream. * diff --git a/src/Http/Content/UrlEncodedFormContent.php b/src/Http/Content/UrlEncodedFormContent.php index 0bf2525..87aa398 100644 --- a/src/Http/Content/UrlEncodedFormContent.php +++ b/src/Http/Content/UrlEncodedFormContent.php @@ -1,19 +1,19 @@ <?php // UrlEncodedFormContent.php // Created: 2025-03-12 -// Updated: 2025-03-14 +// Updated: 2025-03-15 namespace Index\Http\Content; -use Index\Http\HttpUri; +use Index\Http\{HttpParametersCommon,HttpUri}; use Psr\Http\Message\StreamInterface; /** * Implements application/x-www-form-urlencoded. - * - * @implements FormContent<string> */ class UrlEncodedFormContent implements FormContent { + use HttpParametersCommon; + /** @var string[] */ private array $keys; @@ -55,37 +55,6 @@ class UrlEncodedFormContent implements FormContent { ++$this->position; } - public function hasParam(string $name): bool { - return isset($this->params[$name]); - } - - public function getParam( - string $name, - int $filter = FILTER_DEFAULT, - array|int $options = 0, - mixed $default = null, - ): mixed { - return $this->getParamAt($name, 0, $filter, $options, $default); - } - - public function getParamAt( - string $name, - int $index, - int $filter = FILTER_DEFAULT, - array|int $options = 0, - mixed $default = null, - ): mixed { - if(!isset($this->params[$name]) || !isset($this->params[$name][$index])) - return $default; - - $value = filter_var($this->params[$name][$index], $filter, $options); - return is_scalar($value) ? $value : $default; - } - - public function getParamCount(string $name): int { - return isset($this->params[$name]) ? count($this->params[$name]) : 0; - } - /** * Parses URL encoded form params in a stream. * diff --git a/src/Http/HttpParameters.php b/src/Http/HttpParameters.php new file mode 100644 index 0000000..76ed156 --- /dev/null +++ b/src/Http/HttpParameters.php @@ -0,0 +1,87 @@ +<?php +// HttpParameters.php +// Created: 2025-03-15 +// Updated: 2025-03-15 + +namespace Index\Http; + +/** + * Common definition for HTTP parameters. + */ +interface HttpParameters { + /** + * Checks if a parameter is present. + * + * @param string $name Name of the parameter. + * @return bool + */ + public function hasParam(string $name): bool; + + /** + * Retrieves how many parameters of the same name appear in the HTTP request query field. + * + * @param string $name Name of the request query field. + * @return int<0, max> Amount of values. + */ + public function getParamCount(string $name): int; + + /** + * Retrieves a parameter, or a default value if it is not present. + * + * @param string $name Name of the parameter. + * @param ?string $default Default value to fall back on. + * @return ?string Value of the parameter, $default if not present. + */ + public function getParam( + string $name, + ?string $default = null, + ): ?string; + + /** + * Retrieves a parameter, or a default value if it is not present. + * + * @param string $name Name of the parameter. + * @param int $index Index of the parameter value. + * @param ?string $default Default value to fall back on. + * @return ?string Value of the parameter, null if not present. + */ + public function getParamAt( + string $name, + int $index, + ?string $default = null, + ): ?string; + + /** + * Retrieves a filtered parameter, or a default value if it is not present. + * + * @param string $name Name of the parameter. + * @param int $filter A PHP filter extension filter constant. + * @param mixed[]|int $options Options for the PHP filter. + * @param mixed $default Default value to fall back on. + * @return mixed Value of the parameter, default value if not present. + */ + public function getFilteredParam( + string $name, + int $filter = FILTER_DEFAULT, + array|int $options = 0, + mixed $default = null, + ): mixed; + + /** + * Retrieves a filtered parameter, or a default value if it is not present. + * + * @param string $name Name of the parameter. + * @param int $index Index of the parameter value. + * @param int $filter A PHP filter extension filter constant. + * @param mixed[]|int $options Options for the PHP filter. + * @param mixed $default Default value to fall back on. + * @return mixed Value of the parameter, default value if not present. + */ + public function getFilteredParamAt( + string $name, + int $index, + int $filter = FILTER_DEFAULT, + array|int $options = 0, + mixed $default = null, + ): mixed; +} diff --git a/src/Http/HttpParametersCommon.php b/src/Http/HttpParametersCommon.php new file mode 100644 index 0000000..ef23a7f --- /dev/null +++ b/src/Http/HttpParametersCommon.php @@ -0,0 +1,102 @@ +<?php +// HttpParametersCommon.php +// Created: 2025-03-15 +// Updated: 2025-03-15 + +namespace Index\Http; + +/** + * Common implementation for HTTP parameters. + */ +trait HttpParametersCommon { + /** + * Checks if a parameter is present. + * + * @param string $name Name of the parameter. + * @return bool + */ + public function hasParam(string $name): bool { + return isset($this->params[$name]); + } + + /** + * Retrieves how many parameters of the same name appear in the HTTP request query field. + * + * @param string $name Name of the request query field. + * @return int<0, max> Amount of values. + */ + public function getParamCount(string $name): int { + return isset($this->params[$name]) ? count($this->params[$name]) : 0; + } + + /** + * Retrieves a parameter, or a default value if it is not present. + * + * @param string $name Name of the parameter. + * @param ?string $default Default value to fall back on. + * @return ?string Value of the parameter, $default if not present. + */ + public function getParam( + string $name, + ?string $default = null, + ): ?string { + return $this->getParamAt($name, 0, $default); + } + + /** + * Retrieves a parameter, or a default value if it is not present. + * + * @param string $name Name of the parameter. + * @param int $index Index of the parameter value. + * @param ?string $default Default value to fall back on. + * @return ?string Value of the parameter, null if not present. + */ + public function getParamAt( + string $name, + int $index, + ?string $default = null, + ): ?string { + return isset($this->params[$name]) && isset($this->params[$name][$index]) + ? $this->params[$name][$index] : $default; + } + + /** + * Retrieves a filtered parameter, or a default value if it is not present. + * + * @param string $name Name of the parameter. + * @param int $filter A PHP filter extension filter constant. + * @param mixed[]|int $options Options for the PHP filter. + * @param mixed $default Default value to fall back on. + * @return mixed Value of the parameter, default value if not present. + */ + public function getFilteredParam( + string $name, + int $filter = FILTER_DEFAULT, + array|int $options = 0, + mixed $default = null, + ): mixed { + return $this->getFilteredParamAt($name, 0, $filter, $options, $default); + } + + /** + * Retrieves a filtered parameter, or a default value if it is not present. + * + * @param string $name Name of the parameter. + * @param int $index Index of the parameter value. + * @param int $filter A PHP filter extension filter constant. + * @param mixed[]|int $options Options for the PHP filter. + * @param mixed $default Default value to fall back on. + * @return mixed Value of the parameter, default value if not present. + */ + public function getFilteredParamAt( + string $name, + int $index, + int $filter = FILTER_DEFAULT, + array|int $options = 0, + mixed $default = null, + ): mixed { + return isset($this->params[$name]) && isset($this->params[$name][$index]) + ? filter_var($this->params[$name][$index], $filter, $options) + : $default; + } +} diff --git a/src/Http/HttpRequest.php b/src/Http/HttpRequest.php index 9c1390a..33210db 100644 --- a/src/Http/HttpRequest.php +++ b/src/Http/HttpRequest.php @@ -1,7 +1,7 @@ <?php // HttpRequest.php // Created: 2022-02-08 -// Updated: 2025-03-12 +// Updated: 2025-03-15 namespace Index\Http; @@ -9,14 +9,16 @@ use RuntimeException; use InvalidArgumentException; use Index\MediaType; use Index\Http\Content\{MultipartFormContent,UrlEncodedFormContent}; -use Index\Http\Streams\Stream; +use Index\Http\Streams\{NullStream,Stream}; use Index\Json\JsonHttpContent; use Psr\Http\Message\{ServerRequestInterface,StreamInterface,UploadedFileInterface,UriInterface}; /** * Represents a HTTP request message. */ -class HttpRequest extends HttpMessage implements ServerRequestInterface { +class HttpRequest extends HttpMessage implements ServerRequestInterface, HttpParameters { + use HttpParametersCommon; + /** Name of attribute containing the country code value. */ public const string ATTR_COUNTRY_CODE = 'countryCode'; @@ -277,68 +279,6 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface { : http_build_query($this->params, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986); } - /** - * Checks if a query field is present. - * - * @param string $name Name of the query field. - * @return bool true if the field is present, false if not. - */ - public function hasParam(string $name): bool { - return isset($this->params[$name]); - } - - /** - * Retrieves an HTTP request query field, or null if it is not present. - * - * @param string $name Name of the request query field. - * @param int $filter A PHP filter extension filter constant. - * @param mixed[]|int $options Options for the PHP filter. - * @param mixed $default Default value to fall back on. - * @return mixed Value of the query field, null if not present. - */ - public function getParam( - string $name, - int $filter = FILTER_DEFAULT, - array|int $options = 0, - mixed $default = null, - ): mixed { - return $this->getParamAt($name, 0, $filter, $options, $default); - } - - /** - * Retrieves an HTTP request query field, or null if it is not present. - * - * @param string $name Name of the request query field. - * @param int $index Index of the parameter value. - * @param int $filter A PHP filter extension filter constant. - * @param mixed[]|int $options Options for the PHP filter. - * @param mixed $default Default value to fall back on. - * @return mixed Value of the query field, null if not present. - */ - public function getParamAt( - string $name, - int $index, - int $filter = FILTER_DEFAULT, - array|int $options = 0, - mixed $default = null, - ): mixed { - if(!isset($this->params[$name]) || !isset($this->params[$name][$index])) - return $default; - - $value = filter_var($this->params[$name][$index], $filter, $options); - return is_scalar($value) ? $value : $default; - } - - /** - * Retrieves how many parameters of the same name appear in the HTTP request query field. - * - * @param string $name Name of the request query field. - * @return int<0, max> Amount of values. - */ - public function getParamCount(string $name): int { - return isset($this->params[$name]) ? count($this->params[$name]) : 0; - } - /** * @return array<string, UploadedFileInterface[]> An array tree of UploadedFileInterface instances; an empty array MUST be returned if no data is present. */ @@ -390,6 +330,13 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface { return $this->with(parsedBody: $data); } + /** + * Casts a PSR-7 ServerRequestInterface to an Index HttpRequest. + * If $request was already HttpRequest, it will be returned verbatim. + * + * @param ServerRequestInterface $request + * @return HttpRequest + */ public static function castRequest(ServerRequestInterface $request): HttpRequest { if($request instanceof HttpRequest) return $request; @@ -438,6 +385,58 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface { ); } + /** + * Creates a HttpRequest with some given parameters, exists for testing. + * + * @param string $method Request method. + * @param string $path Request target. + * @param array<string, list<string>> $headers Request headers. + * @return HttpRequest + */ + public static function createRequestWithoutBody( + string $method, + string $path, + array $headers = [], + ): HttpRequest { + return new HttpRequest( + '1.1', + $headers, + NullStream::instance(), + [], + $method, + HttpUri::createUri($path), + [], + [] + ); + } + + /** + * Creates a HttpRequest with some given parameters, exists for testing. + * + * @param string $method Request method. + * @param string $path Request target. + * @param array<string, list<string>> $headers Request headers. + * @param StreamInterface $body Request body. + * @return HttpRequest + */ + public static function createRequestWithBody( + string $method, + string $path, + array $headers, + StreamInterface $body, + ): HttpRequest { + return new HttpRequest( + '1.1', + $headers, + $body, + [], + $method, + HttpUri::createUri($path), + [], + [] + ); + } + /** * Parses a Cookie header string. * diff --git a/src/Http/Routing/Router.php b/src/Http/Routing/Router.php index ad1ef60..3dae8a4 100644 --- a/src/Http/Routing/Router.php +++ b/src/Http/Routing/Router.php @@ -1,7 +1,7 @@ <?php // Router.php // Created: 2024-03-28 -// Updated: 2025-03-12 +// Updated: 2025-03-15 namespace Index\Http\Routing; @@ -47,9 +47,15 @@ class Router implements RequestHandlerInterface { /** * @param ErrorHandler|'html'|'plain' $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation. + * @param bool $registerDefaultProcessors Whether the default processors should be registered. */ - public function __construct(ErrorHandler|string $errorHandler = 'html') { + public function __construct( + ErrorHandler|string $errorHandler = 'html', + bool $registerDefaultProcessors = true + ) { $this->setErrorHandler($errorHandler); + if($registerDefaultProcessors) + $this->register(new RouterProcessors); } /** diff --git a/src/Http/Routing/RouterProcessors.php b/src/Http/Routing/RouterProcessors.php new file mode 100644 index 0000000..faadb6f --- /dev/null +++ b/src/Http/Routing/RouterProcessors.php @@ -0,0 +1,68 @@ +<?php +// RouterProcessors.php +// Created: 2025-03-15 +// Updated: 2025-03-15 + +namespace Index\Http\Routing; + +use RuntimeException; +use Index\MediaType; +use Index\Http\Content\{MultipartFormContent,UrlEncodedFormContent}; +use Index\Http\Routing\Processors\{Postprocessor,Preprocessor}; +use Psr\Http\Message\RequestInterface; + +/** + * Implements some standard route handlers, like parsing form data and outputting json. + */ +class RouterProcessors implements RouteHandler { + use RouteHandlerCommon; + + private function handlePreInputUrlencoded(HandlerContext $context, RequestInterface $request): bool { + $contentType = MediaType::parse($request->getHeaderLine('Content-Type')); + if(!$contentType->equals('application/x-www-form-urlencoded')) + return false; + + $context->deps->register(UrlEncodedFormContent::parseStream($request->getBody())); + + return true; + } + + #[Preprocessor('input:urlencoded')] + public function preInputUrlencoded(HandlerContext $context, RequestInterface $request, bool $required = true): void { + if(!$this->handlePreInputUrlencoded($context, $request) && $required) { + $context->response->statusCode = 400; + $context->response->reasonPhrase = ''; + $context->halt(); + } + } + + private function handlePreInputMultipart(HandlerContext $context, RequestInterface $request): bool { + $contentType = MediaType::parse($request->getHeaderLine('Content-Type')); + if(!$contentType->equals('multipart/form-data')) + return false; + + $boundary = $contentType->boundary; + if(empty($boundary)) + return false; + + $context->deps->register(MultipartFormContent::parseStream($request->getBody(), $boundary)); + + return true; + } + + #[Preprocessor('input:multipart')] + public function preInputMultipart(HandlerContext $context, RequestInterface $request, bool $required = true): void { + try { + $result = $this->handlePreInputMultipart($context, $request); + } catch(RuntimeException $ex) { + $result = false; + $required = true; + } + + if(!$result && $required) { + $context->response->statusCode = 400; + $context->response->reasonPhrase = ''; + $context->halt(); + } + } +} diff --git a/tests/HttpFormContentTest.php b/tests/HttpFormContentTest.php index 870b004..48dda4e 100644 --- a/tests/HttpFormContentTest.php +++ b/tests/HttpFormContentTest.php @@ -1,7 +1,7 @@ <?php // HttpFormContentTest.php // Created: 2025-03-12 -// Updated: 2025-03-14 +// Updated: 2025-03-15 declare(strict_types=1); @@ -63,7 +63,7 @@ final class HttpFormContentTest extends TestCase { $this->assertFalse($form->hasParam('never_has_this')); $this->assertEquals(0, $form->getParamCount('never_has_this_either')); $this->assertNull($form->getParam('this_is_not_there_either')); - $this->assertEquals(0401, $form->getParamAt('abusing_octal_notation_for_teto_reference', 3510, default: 0401)); + $this->assertEquals(0401, $form->getFilteredParamAt('abusing_octal_notation_for_teto_reference', 3510, default: 0401)); foreach($expected as $key => $values) { $this->assertTrue($form->hasParam($key)); @@ -90,7 +90,7 @@ final class HttpFormContentTest extends TestCase { $this->assertEquals(0, $form->getParamCount('never_has_this_either')); $this->assertNull($form->getParam('this_is_not_there_either')); $this->assertNull($form->getParamData('this_is_not_there_either')); - $this->assertEquals(0401, $form->getParamAt('abusing_octal_notation_for_teto_reference', 3510, default: 0401)); + $this->assertEquals(0401, $form->getFilteredParamAt('abusing_octal_notation_for_teto_reference', 3510, default: 0401)); $this->assertEquals(null, $form->getParamDataAt('abusing_octal_notation_for_teto_reference', 3510)); $this->assertTrue($form->hasParam('meow')); diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 1bffa55..660d1d2 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -1,18 +1,19 @@ <?php // RouterTest.php // Created: 2022-01-20 -// Updated: 2025-03-12 +// 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\{NullStream,Stream}; +use Index\Http\Streams\Stream; /** * This test isn't super representative of the current functionality @@ -37,8 +38,8 @@ final class RouterTest extends TestCase { $router1->route(RouteInfo::exact('PUT', '/', fn() => 'put')); $router1->route(RouteInfo::exact('CUSTOM', '/', fn() => 'wacky')); - $this->assertEquals('get', (string)$router1->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []))->getBody()); - $this->assertEquals('wacky', (string)$router1->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'CUSTOM', HttpUri::createUri('/'), [], []))->getBody()); + $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 */ })); @@ -52,7 +53,7 @@ final class RouterTest extends TestCase { $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(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/user/static/below'), [], []))->getBody()); + $this->assertEquals('warioware below static', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/user/static/below'))->getBody()); $router2 = new Router; $router2->filter(FilterInfo::prefix('/', fn() => 'meow')); @@ -60,7 +61,7 @@ final class RouterTest extends TestCase { $router2->route(RouteInfo::exact('GET', '/contact', fn() => 'contact page')); $router2->route(RouteInfo::exact('GET', '/25252', fn() => 'numeric test')); - $this->assertEquals('meow', (string)$router2->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/rules'), [], []))->getBody()); + $this->assertEquals('meow', (string)$router2->handle(HttpRequest::createRequestWithoutBody('GET', '/rules'))->getBody()); } public function testAttribute(): void { @@ -116,13 +117,13 @@ final class RouterTest extends TestCase { $router->register($handler); - $this->assertEquals('index', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []))->getBody()); - $this->assertEquals('avatar', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'POST', HttpUri::createUri('/avatar'), [], []))->getBody()); - $this->assertEquals('static', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'PUT', HttpUri::createUri('/static'), [], []))->getBody()); - $this->assertEquals('meow', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/meow'), [], []))->getBody()); - $this->assertEquals('meow', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'POST', HttpUri::createUri('/meow'), [], []))->getBody()); - $this->assertEquals('profile of Cool134', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/profile/Cool134'), [], []))->getBody()); - $this->assertEquals('still the profile of Cool134', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/profile-but-raw/Cool134'), [], []))->getBody()); + $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 { @@ -131,8 +132,8 @@ final class RouterTest extends TestCase { $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(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), [], []))->getBody()); - $this->assertEquals('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'DELETE', HttpUri::createUri('/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'), [], []))->getBody()); + $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 { @@ -141,9 +142,9 @@ final class RouterTest extends TestCase { $router->route(RouteInfo::exact('GET', '/', fn() => 'unexpected')); $router->route(RouteInfo::exact('GET', '/test', fn() => 'also unexpected')); - $this->assertEquals('expected', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []))->getBody()); - $this->assertEquals('expected', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/test'), [], []))->getBody()); - $this->assertEquals('expected', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/error'), [], []))->getBody()); + $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 { @@ -152,12 +153,124 @@ final class RouterTest extends TestCase { $router->route(RouteInfo::exact('POST', '/test', fn() => 'post')); $router->route(RouteInfo::exact('PATCH', '/test', fn() => 'patch')); - $response = $router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'OPTIONS', HttpUri::createUri('/test'), [], [])); + $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; @@ -216,15 +329,15 @@ final class RouterTest extends TestCase { } )); - $response = $router->handle(new HttpRequest('1.1', [], Stream::createStream(base64_encode('mewow')), [], 'POST', HttpUri::createUri('/test'), [], [])); + $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(new HttpRequest('1.1', [], Stream::createStream(base64_encode('soap')), [], 'PUT', HttpUri::createUri('/alternate'), [], [])); + $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(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/filtered'), [], [])); + $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/filtered')); $this->assertEquals(403, $response->getStatusCode()); $this->assertEquals('it can work like a filter too', (string)$response->getBody()); }