Implemented default processors for form handling.

This commit is contained in:
flash 2025-03-15 02:10:16 +00:00
parent cc351a2f05
commit ee48bb5bd6
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
12 changed files with 486 additions and 219 deletions

View file

@ -1 +1 @@
0.2503.142359
0.2503.150208

View file

@ -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()) {

View file

@ -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 {}

View file

@ -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.
*

View file

@ -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.
*

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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.
*

View file

@ -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);
}
/**

View file

@ -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();
}
}
}

View file

@ -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'));

View file

@ -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());
}