Added processors.

This commit is contained in:
flash 2025-03-07 23:39:15 +00:00
parent 69e22beb08
commit 4f092a3259
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
25 changed files with 471 additions and 66 deletions

View file

@ -1 +1 @@
0.2503.72025
0.2503.72337

View file

@ -3,10 +3,11 @@
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\Filters;
use Attribute;
use Closure;
use Index\Http\Routing\HandlerAttribute;
/**
* Provides an attribute for marking methods in a class as a filter.

View file

@ -3,9 +3,10 @@
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\Filters;
use Closure;
use Index\Http\Routing\UriMatchers\{PatternPathUriMatcher,PrefixPathUriMatcher,UriMatcher};
/**
* Information of a filter.

View file

@ -3,7 +3,7 @@
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\Filters;
use Attribute;
use Closure;

View file

@ -3,7 +3,7 @@
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\Filters;
use Attribute;
use Closure;

View file

@ -7,6 +7,7 @@ namespace Index\Http\Routing;
use ReflectionAttribute;
use ReflectionObject;
use RuntimeException;
/**
* Provides base for attributes that mark methods in a class as handlers.
@ -19,20 +20,24 @@ abstract class HandlerAttribute {
* @param RouteHandler $handler Handler instance.
*/
public static function register(Router $router, RouteHandler $handler): void {
$objectInfo = new ReflectionObject($handler);
$methodInfos = $objectInfo->getMethods();
$object = new ReflectionObject($handler);
$methods = $object->getMethods();
foreach($methodInfos as $methodInfo) {
$attrInfos = $methodInfo->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF);
foreach($methods as $method) {
$attrs = $method->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF);
foreach($attrInfos as $attrInfo) {
$handlerInfo = $attrInfo->newInstance();
$closure = $methodInfo->getClosure($methodInfo->isStatic() ? null : $handler);
foreach($attrs as $attr) {
$info = $attr->newInstance();
$closure = $method->getClosure($method->isStatic() ? null : $handler);
if($handlerInfo instanceof RouteAttribute)
$router->route($handlerInfo->createInstance($closure));
elseif($handlerInfo instanceof FilterAttribute)
$router->filter($handlerInfo->createInstance($closure));
if($info instanceof Routes\RouteAttribute)
$router->route($info->createInstance($closure));
elseif($info instanceof Filters\FilterAttribute)
$router->filter($info->createInstance($closure));
elseif($info instanceof Processors\ProcessorAttribute)
$router->processor($info->createInstance($closure));
else
throw new RuntimeException('unsupported HandlerAttribute encountered');
}
}
}

View file

@ -43,6 +43,11 @@ class HandlerContext {
*/
public private(set) bool $stopped = false;
/**
* Value returned by the route handler.
*/
public mixed $result = null;
/**
* @param ServerRequestInterface $request Request that is being handled.
*/

View file

@ -0,0 +1,14 @@
<?php
// After.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
/**
* Requires a preprocessor on a route handler.
*/
#[Attribute(ProcessAttribute::FLAGS)]
class After extends ProcessAttribute {}

View file

@ -0,0 +1,14 @@
<?php
// Before.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
/**
* Requires a postprocessor on a route handler.
*/
#[Attribute(ProcessAttribute::FLAGS)]
class Before extends ProcessAttribute {}

View file

@ -0,0 +1,26 @@
<?php
// Postprocessor.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
use Closure;
/**
* Marks a method as a post-processor.
*/
#[Attribute(ProcessorAttribute::FLAGS)]
class Postprocessor extends ProcessorAttribute {
/**
* @param string $name Name of the post-processor.
*/
public function __construct(
private string $name,
) {}
public function createInstance(Closure $handler): ProcessorInfo {
return ProcessorInfo::post($this->name, $handler);
}
}

View file

@ -0,0 +1,26 @@
<?php
// Preprocessor.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
use Closure;
/**
* Marks a method as a pre-processor.
*/
#[Attribute(ProcessorAttribute::FLAGS)]
class Preprocessor extends ProcessorAttribute {
/**
* @param string $name Name of the pre-processor.
*/
public function __construct(
private string $name,
) {}
public function createInstance(Closure $handler): ProcessorInfo {
return ProcessorInfo::pre($this->name, $handler);
}
}

View file

@ -0,0 +1,77 @@
<?php
// ProcessAttribute.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
use Closure;
use ReflectionAttribute;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use RuntimeException;
/**
* Provides an attribute for requiring processors on route handler.
*/
abstract class ProcessAttribute {
/**
* Flags to set for the Attribute attribute.
*/
public const int FLAGS = Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE;
/**
* Arguments to pass to the handler.
*
* @var array<string|int, mixed>
*/
public private(set) array $args;
/**
* @param string $name Name of the processor to require.
* @param mixed ...$args Arguments to pass to the handler.
*/
public function __construct(
public private(set) string $name,
mixed ...$args
) {
$this->args = $args;
}
/**
* Reads attributes from methods in a RouteHandler instance and registers them to a given Router instance.
*
* @param ReflectionFunctionAbstract|Closure $function Router instance.
* @return object{after: array<string, array<string|int, mixed>>, before: array<string, array<string|int, mixed>>}
*/
public static function read(ReflectionFunctionAbstract|Closure $function): object {
if($function instanceof Closure)
$function = new ReflectionFunction($function);
// i HATE how this is considered more acceptable than a stdClass instance
$process = (object)[
'after' => [],
'before' => [],
];
$attrs = $function->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF);
foreach($attrs as $attr) {
$handler = $attr->newInstance();
if($handler instanceof After) {
if(array_key_exists($handler->name, $process->after))
throw new RuntimeException(sprintf('postprocessor "%s" already required', $handler->name));
$process->after[$handler->name] = $handler->args;
} elseif($handler instanceof Before) {
if(array_key_exists($handler->name, $process->before))
throw new RuntimeException(sprintf('postprocessor "%s" already required', $handler->name));
$process->before[$handler->name] = $handler->args;
} else
throw new RuntimeException('unsupported ProcessAttribute encountered');
}
return $process;
}
}

View file

@ -0,0 +1,28 @@
<?php
// ProcessorAttribute.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
use Closure;
use Index\Http\Routing\HandlerAttribute;
/**
* Provides an attribute for marking methods in a class as a processor.
*/
abstract class ProcessorAttribute extends HandlerAttribute {
/**
* Flags to set for the Attribute attribute.
*/
public const int FLAGS = Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD;
/**
* Creates a ProcessorInfo instance.
*
* @param Closure $handler Handler method.
* @return ProcessorInfo
*/
abstract public function createInstance(Closure $handler): ProcessorInfo;
}

View file

@ -0,0 +1,46 @@
<?php
// ProcessorInfo.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Closure;
/**
* Information of a processor.
*/
class ProcessorInfo {
/**
* @param string $name Name of the processor.
* @param ProcessorTarget $target Kind of processor.
* @param Closure $handler Handler for this filter.
*/
public function __construct(
public private(set) string $name,
public private(set) ProcessorTarget $target,
public private(set) Closure $handler,
) {}
/**
* Creates a pre-processor ProcessorInfo instance.
*
* @param string $name Name of the processor.
* @param Closure $handler Handler for this processor.
* @return ProcessorInfo
*/
public static function pre(string $name, Closure $handler): ProcessorInfo {
return new ProcessorInfo($name, ProcessorTarget::Pre, $handler);
}
/**
* Creates a post-processor ProcessorInfo instance.
*
* @param string $name Name of the processor.
* @param Closure $handler Handler for this processor.
* @return ProcessorInfo
*/
public static function post(string $name, Closure $handler): ProcessorInfo {
return new ProcessorInfo($name, ProcessorTarget::Post, $handler);
}
}

View file

@ -0,0 +1,21 @@
<?php
// ProcessorTarget.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
/**
* Indicates the kind of processor.
*/
enum ProcessorTarget {
/**
* Processor is a preprocessor.
*/
case Pre;
/**
* Processor is a postprocessor.
*/
case Post;
}

View file

@ -5,12 +5,16 @@
namespace Index\Http\Routing;
use Stringable;
use InvalidArgumentException;
use JsonSerializable;
use RuntimeException;
use Stringable;
use Index\Bencode\{Bencode,BencodeSerializable};
use Index\Http\{HttpRequest,HttpStream};
use Index\Http\Routing\ErrorHandling\{ErrorHandler,HtmlErrorHandler,PlainErrorHandler};
use Index\Http\Routing\Filters\FilterInfo;
use Index\Http\Routing\Processors\{ProcessorInfo,ProcessorTarget};
use Index\Http\Routing\Routes\RouteInfo;
use Psr\Http\Message\{ResponseInterface,ServerRequestInterface};
use Psr\Http\Server\RequestHandlerInterface;
@ -34,39 +38,19 @@ class Router implements RequestHandlerInterface {
/** @var RouteInfo[] */
private array $routes = [];
private string $charSetValue;
/** @var array<string, ProcessorInfo> */
private array $preprocs = [];
/** @var array<string, ProcessorInfo> */
private array $postprocs = [];
/**
* @param string $charSet Default character set to specify when none is present.
* @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.
*/
public function __construct(
string $charSet = '',
ErrorHandler|string $errorHandler = 'html'
) {
$this->charSetValue = $charSet;
public function __construct(ErrorHandler|string $errorHandler = 'html') {
$this->setErrorHandler($errorHandler);
}
/**
* Retrieves the normalised name of the preferred character set.
*
* @return string Normalised character set name.
*/
public string $charSet {
get {
if($this->charSetValue === '') {
$charSet = mb_preferred_mime_name(mb_internal_encoding());
if($charSet === false)
$charSet = 'UTF-8';
return strtolower($charSet);
}
return $this->charSetValue;
}
}
/**
* Error handler instance.
*/
@ -120,6 +104,54 @@ class Router implements RequestHandlerInterface {
$this->routes[] = $route;
}
/**
* Registers a processor.
*
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not supported.
* @throws RuntimeException if the processor has already been registered.
*/
public function processor(ProcessorInfo $processor): void {
if($processor->target === ProcessorTarget::Pre)
$procs = &$this->preprocs;
elseif($processor->target === ProcessorTarget::Post)
$procs = &$this->postprocs;
else
throw new InvalidArgumentException('$processor target is not supported');
if(array_key_exists($processor->name, $procs))
throw new RuntimeException('$processor has already been registered');
$procs[$processor->name] = $processor;
}
/**
* Registers a pre-processor.
*
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not a preprocessor.
* @throws RuntimeException if the processor has already been registered.
*/
public function preprocessor(ProcessorInfo $processor): void {
if($processor->target !== ProcessorTarget::Pre)
throw new InvalidArgumentException('$processor is not a preprocessor');
$this->processor($processor);
}
/**
* Registers a post-processor.
*
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not a postprocessor.
*/
public function postprocessor(ProcessorInfo $processor): void {
if($processor->target !== ProcessorTarget::Post)
throw new InvalidArgumentException('$processor is not a postprocessor');
$this->processor($processor);
}
/**
* Registers a route handler class instance.
*
@ -176,11 +208,11 @@ class Router implements RequestHandlerInterface {
if(!$context->response->hasContentType()) {
if(mb_stripos($result, '<!doctype html') === 0)
$context->response->setTypeHtml($this->charSet);
$context->response->setTypeHtml();
elseif(mb_stripos($result, '<?xml') === 0)
$context->response->setTypeXml($this->charSet);
$context->response->setTypeXml();
else
$context->response->setTypePlain($this->charSet);
$context->response->setTypePlain();
}
$context->response->body = HttpStream::createStream($result);
@ -215,7 +247,7 @@ class Router implements RequestHandlerInterface {
foreach($this->routes as $routeInfo) {
$match = $routeInfo->matcher->match($context->request->uri);
if($match !== false)
$methods[$routeInfo->method] = [
$methods[$routeInfo->method] = (object)[
'info' => $routeInfo,
'matches' => is_array($match) ? $match : null,
];
@ -256,12 +288,32 @@ class Router implements RequestHandlerInterface {
}
$route = $methods[$context->request->method];
$result = $context->call($route['info']->handler, $route['matches']);
$route->info->readProcessors();
// wait for the pipeline stuff
//if($result !== null || $context->stopped)
foreach($route->info->preprocs as $name => $args) {
if(!array_key_exists($name, $this->preprocs))
throw new RuntimeException(sprintf('preprocessor "%s" was not found', $name));
$this->defaultResultHandler($context, $result);
$result = $context->call($this->preprocs[$name]->handler, $args);
if($result !== null || $context->stopped) {
$this->defaultResultHandler($context, $result);
return $context->response->toResponse();
}
}
$context->result = $context->call($route->info->handler, $route->matches);
if(!$context->stopped)
foreach($route->info->postprocs as $name => $args) {
if(!array_key_exists($name, $this->postprocs))
throw new RuntimeException(sprintf('postprocessor "%s" was not found', $name));
$result = $context->call($this->postprocs[$name]->handler, $args);
if($result !== null || $context->stopped) // @phpstan-ignore booleanOr.rightAlwaysFalse
break;
}
$this->defaultResultHandler($context, $context->result);
return $context->response->toResponse();
}

View file

@ -3,7 +3,7 @@
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\Routes;
use Attribute;
use Closure;

View file

@ -3,7 +3,7 @@
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\Routes;
use Attribute;
use Closure;

View file

@ -3,10 +3,11 @@
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\Routes;
use Attribute;
use Closure;
use Index\Http\Routing\HandlerAttribute;
/**
* Provides an attribute for marking methods in a class as a route.

View file

@ -3,10 +3,12 @@
// Created: 2025-03-02
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\Routes;
use Closure;
use InvalidArgumentException;
use Index\Http\Routing\Processors\ProcessAttribute;
use Index\Http\Routing\UriMatchers\{ExactPathUriMatcher,PatternPathUriMatcher,UriMatcher};
/**
* Information of a route.
@ -31,6 +33,8 @@ class RouteInfo {
*/
public private(set) array $postprocs = [];
private bool $processorsRead = false;
/**
* @param string $method HTTP method this route serves.
* @param UriMatcher $matcher URI matcher to use.
@ -78,6 +82,21 @@ class RouteInfo {
$this->postprocs[$name] = $args;
}
/**
* Reads processor attributes and merges them with the already registered instances.
*/
public function readProcessors(): void {
if($this->processorsRead)
return;
$this->processorsRead = true;
$info = ProcessAttribute::read($this->handler);
foreach($info->after as $name => $args)
$this->after($name, $args);
foreach($info->before as $name => $args)
$this->before($name, $args);
}
/**
* Creates RouteInfo instance using ExactPathUriMatcher.
*

View file

@ -3,7 +3,7 @@
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\UriMatchers;
use Psr\Http\Message\UriInterface;

View file

@ -3,7 +3,7 @@
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\UriMatchers;
use Psr\Http\Message\UriInterface;

View file

@ -3,7 +3,7 @@
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\UriMatchers;
use Psr\Http\Message\UriInterface;

View file

@ -3,7 +3,7 @@
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
namespace Index\Http\Routing\UriMatchers;
use Psr\Http\Message\UriInterface;

View file

@ -7,13 +7,11 @@ declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpUri,NullStream};
use Index\Http\Routing\{
ExactRoute,
FilterInfo,
PatternRoute,PrefixFilter,
RouteInfo,RouteHandler,RouteHandlerCommon,Router
};
use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpStream,HttpUri,NullStream};
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};
/**
* This test isn't super representative of the current functionality
@ -158,4 +156,75 @@ final class RouterTest extends TestCase {
$this->assertEquals('No Content', $response->getReasonPhrase());
$this->assertEquals('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow'));
}
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 = HttpStream::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(new HttpRequest('1.1', [], HttpStream::createStream(base64_encode('mewow')), [], 'POST', HttpUri::createUri('/test'), [], []));
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals("{\n \"data\": \"mewow\"\n}", (string)$response->getBody());
$response = $router->handle(new HttpRequest('1.1', [], HttpStream::createStream(base64_encode('soap')), [], 'PUT', HttpUri::createUri('/alternate'), [], []));
$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'), [], []));
$this->assertEquals(403, $response->getStatusCode());
$this->assertEquals('it can work like a filter too', (string)$response->getBody());
}
}