Compare commits

...

3 commits

Author SHA1 Message Date
d6819a29fe Added scoping. 2024-08-16 15:14:44 +00:00
25e0441c1c Added attribute tests. 2024-08-16 15:03:44 +00:00
b503c9edb9 Added stuff for attribute based registration. 2024-08-15 18:38:01 +00:00
14 changed files with 483 additions and 112 deletions

18
src/IRpcActionHandler.php Normal file
View file

@ -0,0 +1,18 @@
<?php
// IRpcActionHandler.php
// Created: 2024-08-15
// Updated: 2024-08-16
namespace Aiwass;
/**
* Provides the interface for IRpcServer::register().
*/
interface IRpcActionHandler {
/**
* Registers axctions on a given IRpcServer instance.
*
* @param IRpcServer $server Target RPC server.
*/
public function registerRpcActions(IRpcServer $server): void;
}

68
src/IRpcServer.php Normal file
View file

@ -0,0 +1,68 @@
<?php
// IRpcServer.php
// Created: 2024-08-16
// Updated: 2024-08-16
namespace Aiwass;
use RuntimeException;
use InvalidArgumentException;
/**
* Provides a common interface for RPC servers.
*/
interface IRpcServer {
/**
* Creates a proxy for this RPC server with a specified namespace.
*
* @param string $prefix Prefix to apply to the scoped RPC server.
* @return IRpcServer A scoped RPC server instance.
*/
function scopeTo(string $prefix): IRpcServer;
/**
* Registers a handler class.
*
* @param IRpcActionHandler $handler Handler to register.
*/
function register(IRpcActionHandler $handler): void;
/**
* Registers an action.
*
* @param bool $isProcedure true if the action is a procedure (HTTP POST), false if it is a query (HTTP GET).
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
function registerAction(bool $isProcedure, string $name, $handler): void;
/**
* Registers a query action (HTTP GET).
*
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
function registerQueryAction(string $name, $handler): void;
/**
* Registers a procedure action (HTTP POST).
*
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
function registerProcedureAction(string $name, $handler): void;
/**
* Retrieves information about an action.
*
* @param string $name Name of the action.
* @return ?RpcActionInfo An object containing information about the action, or null if it does not exist.
*/
function getActionInfo(string $name): ?RpcActionInfo;
}

67
src/RpcAction.php Normal file
View file

@ -0,0 +1,67 @@
<?php
// RpcAction.php
// Created: 2024-08-15
// Updated: 2024-08-16
namespace Aiwass;
use Attribute;
use ReflectionAttribute;
use ReflectionObject;
/**
* Provides base for attributes that mark methods in a class as RPC action handlers.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class RpcAction {
/**
* @param bool $isProcedure true if the action is a procedure, false if query.
* @param string $name Action name.
*/
public function __construct(
private bool $isProcedure,
private string $name
) {}
/**
* Returns whether the action is a procedure.
*
* @return bool
*/
public function isProcedure(): bool {
return $this->isProcedure;
}
/**
* Returns the action name.
*
* @return string
*/
public function getName(): string {
return $this->name;
}
/**
* Reads attributes from methods in a IRpcActionHandler instance and registers them to a given IRpcServer instance.
*
* @param IRpcServer $server RPC server instance.
* @param IRpcActionHandler $handler Handler instance.
*/
public static function register(IRpcServer $server, IRpcActionHandler $handler): void {
$objectInfo = new ReflectionObject($handler);
$methodInfos = $objectInfo->getMethods();
foreach($methodInfos as $methodInfo) {
$attrInfos = $methodInfo->getAttributes(RpcAction::class, ReflectionAttribute::IS_INSTANCEOF);
foreach($attrInfos as $attrInfo) {
$handlerInfo = $attrInfo->newInstance();
$server->registerAction(
$handlerInfo->isProcedure(),
$handlerInfo->getName(),
$methodInfo->getClosure($methodInfo->isStatic() ? null : $handler)
);
}
}
}
}

14
src/RpcActionHandler.php Normal file
View file

@ -0,0 +1,14 @@
<?php
// RpcActionHandler.php
// Created: 2024-08-15
// Updated: 2024-08-15
namespace Aiwass;
/**
* Provides an abstract class version of IRpcActionHandler that already includes the trait as well,
* letting you only have to use one use statement rather than two!
*/
abstract class RpcActionHandler implements IRpcActionHandler {
use RpcActionHandlerTrait;
}

View file

@ -0,0 +1,16 @@
<?php
// RpcActionHandlerTrait.php
// Created: 2024-08-15
// Updated: 2024-08-16
namespace Aiwass;
/**
* Provides an implementation of IRpcActionHandler::registerRpcActions that uses the attributes.
* For more advanced use, everything can be use'd separately and RpcActionAttribute::register called manually.
*/
trait RpcActionHandlerTrait {
public function registerRpcActions(IRpcServer $server): void {
RpcAction::register($server, $this);
}
}

View file

@ -1,12 +1,14 @@
<?php <?php
// RpcActionInfo.php // RpcActionInfo.php
// Created: 2024-08-13 // Created: 2024-08-13
// Updated: 2024-08-13 // Updated: 2024-08-16
namespace Aiwass; namespace Aiwass;
use Closure; use Closure;
use InvalidArgumentException; use InvalidArgumentException;
use ReflectionFunction;
use RuntimeException;
/** /**
* Provides information about an RPC action. * Provides information about an RPC action.
@ -55,4 +57,37 @@ final class RpcActionInfo {
public function getHandler(): Closure { public function getHandler(): Closure {
return Closure::fromCallable($this->handler); return Closure::fromCallable($this->handler);
} }
/**
* Invokes the handler and ensures required args are specified.
*
* @param array<string, mixed> $args Arguments to invoke the handler with.
* @throws RuntimeException If any given data was invalid.
* @return mixed Result of the handler
*/
public function invokeHandler(array $args = []): mixed {
$handlerInfo = new ReflectionFunction($this->getHandler());
if($handlerInfo->isVariadic())
throw new RuntimeException('variadic');
$handlerArgs = [];
$handlerParams = $handlerInfo->getParameters();
foreach($handlerParams as $paramInfo) {
$paramName = $paramInfo->getName();
if(!array_key_exists($paramName, $args)) {
if($paramInfo->isOptional())
continue;
if(!$paramInfo->allowsNull())
throw new RuntimeException('required');
}
$handlerArgs[$paramName] = $args[$paramName] ?? null;
}
if(count($handlerArgs) !== count($args))
throw new RuntimeException('params');
return $handlerInfo->invokeArgs($handlerArgs);
}
} }

20
src/RpcProcedure.php Normal file
View file

@ -0,0 +1,20 @@
<?php
// RpcProcedure.php
// Created: 2024-08-15
// Updated: 2024-08-16
namespace Aiwass;
use Attribute;
/**
* Provides an attribute for marking methods in a class as an RPC Query action.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class RpcProcedure extends RpcAction {
/**
* @param string $name Action name.
*/
public function __construct(string $name) {
parent::__construct(true, $name);
}
}

20
src/RpcQuery.php Normal file
View file

@ -0,0 +1,20 @@
<?php
// RpcQuery.php
// Created: 2024-08-15
// Updated: 2024-08-16
namespace Aiwass;
use Attribute;
/**
* Provides an attribute for marking methods in a class as an RPC Query action.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class RpcQuery extends RpcAction {
/**
* @param string $name Action name.
*/
public function __construct(string $name) {
parent::__construct(false, $name);
}
}

View file

@ -1,15 +1,12 @@
<?php <?php
// RpcRouteHandler.php // RpcRouteHandler.php
// Created: 2024-08-13 // Created: 2024-08-13
// Updated: 2024-08-13 // Updated: 2024-08-16
namespace Aiwass; namespace Aiwass;
use Exception; use Exception;
use ReflectionFunction; use RuntimeException;
use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionUnionType;
use Index\Http\{HttpResponseBuilder,HttpRequest}; use Index\Http\{HttpResponseBuilder,HttpRequest};
use Index\Http\Content\FormContent; use Index\Http\Content\FormContent;
use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler}; use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler};
@ -19,10 +16,11 @@ use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler};
*/ */
class RpcRouteHandler extends RouteHandler { class RpcRouteHandler extends RouteHandler {
/** /**
* @param RpcServer $server An RPC server instance. * @param IRpcServer $server An RPC server instance.
*/ */
public function __construct( public function __construct(
private RpcServer $server private IVerificationProvider $verification,
private IRpcServer $server
) {} ) {}
/** /**
@ -31,7 +29,7 @@ class RpcRouteHandler extends RouteHandler {
* @param HttpResponseBuilder $response Response that it being built. * @param HttpResponseBuilder $response Response that it being built.
* @param HttpRequest $request Request that is being handled. * @param HttpRequest $request Request that is being handled.
* @param string $action Name of the action specified in the URL. * @param string $action Name of the action specified in the URL.
* @return int|array{error: string, message?: string}|mixed Response to the request. * @return int|string Response to the request.
*/ */
#[HttpGet('/_aiwass/([A-Za-z0-9\-_\.:]+)')] #[HttpGet('/_aiwass/([A-Za-z0-9\-_\.:]+)')]
#[HttpPost('/_aiwass/([A-Za-z0-9\-_\.:]+)')] #[HttpPost('/_aiwass/([A-Za-z0-9\-_\.:]+)')]
@ -61,49 +59,29 @@ class RpcRouteHandler extends RouteHandler {
$userToken = (string)$request->getHeaderLine('X-Aiwass-Verify'); $userToken = (string)$request->getHeaderLine('X-Aiwass-Verify');
if($userToken === '') { if($userToken === '') {
$response->setStatusCode(403); $response->setStatusCode(403);
return AiwassMsgPack::encode(['error' => 'aiwass:verify', 'message' => 'X-Aiwass-Verify header is missing.']); return AiwassMsgPack::encode(['error' => 'aiwass:verify']);
} }
$actInfo = $this->server->getActionInfo($action); $actInfo = $this->server->getActionInfo($action);
if($actInfo === null) { if($actInfo === null) {
$response->setStatusCode(404); $response->setStatusCode(404);
return AiwassMsgPack::encode(['error' => 'aiwass:unknown', 'message' => 'No action with that name exists.']); return AiwassMsgPack::encode(['error' => 'aiwass:unknown']);
} }
if($actInfo->isProcedure() !== $expectProcedure) { if($actInfo->isProcedure() !== $expectProcedure) {
$response->setStatusCode(405); $response->setStatusCode(405);
$response->setHeader('Allow', $actInfo->isProcedure() ? 'POST' : 'GET'); $response->setHeader('Allow', $actInfo->isProcedure() ? 'POST' : 'GET');
return AiwassMsgPack::encode(['error' => 'aiwass:method', 'message' => 'This action cannot be called using this request method.']); return AiwassMsgPack::encode(['error' => 'aiwass:method']);
} }
if(!$this->server->getVerificationProvider()->verify($userToken, $expectProcedure, $action, $paramString)) if(!$this->verification->verify($userToken, $expectProcedure, $action, $paramString))
return AiwassMsgPack::encode(['error' => 'aiwass:verify', 'message' => 'Request token verification failed.']); return AiwassMsgPack::encode(['error' => 'aiwass:verify']);
$handlerInfo = new ReflectionFunction($actInfo->getHandler()); try {
if($handlerInfo->isVariadic()) return AiwassMsgPack::encode($actInfo->invokeHandler($params));
return AiwassMsgPack::encode(['error' => 'aiwass:variadic', 'message' => 'Handler was declared as a variadic method and is thus impossible to be called.']); } catch(RuntimeException $ex) {
$handlerArgs = [];
$handlerParams = $handlerInfo->getParameters();
foreach($handlerParams as $paramInfo) {
$paramName = $paramInfo->getName();
if(!array_key_exists($paramName, $params)) {
if($paramInfo->isOptional())
continue;
if(!$paramInfo->allowsNull())
return AiwassMsgPack::encode(['error' => 'aiwass:param:required', 'message' => 'A required parameter is missing.', 'param' => $paramName]);
}
$handlerArgs[$paramName] = $params[$paramName] ?? null;
}
if(count($handlerArgs) !== count($params)) {
$response->setStatusCode(400); $response->setStatusCode(400);
return AiwassMsgPack::encode(['error' => 'aiwass:params', 'message' => 'Unsupported arguments were specified.']); return AiwassMsgPack::encode(['error' => sprintf('aiwass:%s', $ex->getMessage())]);
} }
return AiwassMsgPack::encode($handlerInfo->invokeArgs($handlerArgs));
} }
} }

View file

@ -1,7 +1,7 @@
<?php <?php
// RpcServer.php // RpcServer.php
// Created: 2024-08-13 // Created: 2024-08-13
// Updated: 2024-08-13 // Updated: 2024-08-16
namespace Aiwass; namespace Aiwass;
@ -11,44 +11,16 @@ use RuntimeException;
/** /**
* Implements an RPC server. * Implements an RPC server.
*/ */
class RpcServer { class RpcServer implements IRpcServer {
use RpcServerTrait;
/** @var array<string, RpcActionInfo> */ /** @var array<string, RpcActionInfo> */
private array $actions = []; private array $actions = [];
/** public function scopeTo(string $prefix): IRpcServer {
* @param IVerificationProvider $verify A verification provider. return new RpcServerScoped($this, $prefix);
*/
public function __construct(
private IVerificationProvider $verify
) {}
/**
* Retrieves this verification provider for this server.
*
* @return IVerificationProvider Verification provider implementation.
*/
public function getVerificationProvider(): IVerificationProvider {
return $this->verify;
} }
/**
* Creates an RPC route handler.
*
* @return RpcRouteHandler RPC route handler.
*/
public function createRouteHandler(): RpcRouteHandler {
return new RpcRouteHandler($this);
}
/**
* Registers an action.
*
* @param bool $isProcedure true if the action is a procedure (HTTP POST), false if it is a query (HTTP GET).
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
public function registerAction(bool $isProcedure, string $name, $handler): void { public function registerAction(bool $isProcedure, string $name, $handler): void {
if(array_key_exists($name, $this->actions)) if(array_key_exists($name, $this->actions))
throw new RuntimeException('an action with that name has already been registered'); throw new RuntimeException('an action with that name has already been registered');
@ -56,47 +28,7 @@ class RpcServer {
$this->actions[$name] = new RpcActionInfo($isProcedure, $name, $handler); $this->actions[$name] = new RpcActionInfo($isProcedure, $name, $handler);
} }
/**
* Registers a query action (HTTP GET).
*
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
public function registerQueryAction(string $name, $handler): void {
$this->registerAction(false, $name, $handler);
}
/**
* Registers a procedure action (HTTP POST).
*
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
public function registerProcedureAction(string $name, $handler): void {
$this->registerAction(true, $name, $handler);
}
/**
* Retrieves information about an action.
*
* @param string $name Name of the action.
* @return ?RpcActionInfo An object containing information about the action, or null if it does not exist.
*/
public function getActionInfo(string $name): ?RpcActionInfo { public function getActionInfo(string $name): ?RpcActionInfo {
return $this->actions[$name] ?? null; return $this->actions[$name] ?? null;
} }
/**
* Creates an RPC server with a Hmac verification provider.
*
* @param callable(): string $getSecretKey A method that returns the secret key to use.
* @return RpcServer The RPC server.
*/
public static function createHmac($getSecretKey): RpcServer {
return new RpcServer(new HmacVerificationProvider($getSecretKey));
}
} }

30
src/RpcServerScoped.php Normal file
View file

@ -0,0 +1,30 @@
<?php
// RpcServerScoped.php
// Created: 2024-08-16
// Updated: 2024-08-16
namespace Aiwass;
/**
* Provides a scoped RPC server implementation.
*/
class RpcServerScoped implements IRpcServer {
use RpcServerTrait;
public function __construct(
private IRpcServer $base,
private string $prefix
) {}
public function scopeTo(string $prefix): IRpcServer {
return new RpcServerScoped($this->base, $this->prefix . $prefix);
}
public function registerAction(bool $isProcedure, string $name, $handler): void {
$this->base->registerAction($isProcedure, $this->prefix . $name, $handler);
}
public function getActionInfo(string $name): ?RpcActionInfo {
return $this->base->getActionInfo($this->prefix . $name);
}
}

23
src/RpcServerTrait.php Normal file
View file

@ -0,0 +1,23 @@
<?php
// RpcServerTrait.php
// Created: 2024-08-16
// Updated: 2024-08-16
namespace Aiwass;
/**
* Provides implementations for IRpcServer methods that are likely going to be identical across implementations.
*/
trait RpcServerTrait {
public function register(IRpcActionHandler $handler): void {
$handler->registerRpcActions($this);
}
public function registerQueryAction(string $name, $handler): void {
$this->registerAction(false, $name, $handler);
}
public function registerProcedureAction(string $name, $handler): void {
$this->registerAction(true, $name, $handler);
}
}

108
tests/AttributesTest.php Normal file
View file

@ -0,0 +1,108 @@
<?php
// AttributesTest.php
// Created: 2024-08-16
// Updated: 2024-08-16
declare(strict_types=1);
use RuntimeException;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\{CoversClass,UsesClass};
use Aiwass\{RpcAction,RpcActionHandler,RpcProcedure,RpcQuery,RpcServer};
#[CoversClass(RpcAction::class)]
#[CoversClass(RpcActionHandler::class)]
#[CoversClass(RpcProcedure::class)]
#[CoversClass(RpcQuery::class)]
#[UsesClass(RpcServer::class)]
final class AttributesTest extends TestCase {
public function testAttributes(): void {
$handler = new class($this) extends RpcActionHandler {
private static $that;
public function __construct(private $self) {
self::$that = $self;
}
#[RpcAction(true, 'aiwass:test:proc1')]
public function procedureRegisteredUsingActionAttribute() {
return 'procedure registered using action attribute';
}
#[RpcAction(false, 'aiwass:test:query1')]
public function queryRegisteredUsingActionAttribute(string $beans) {
$this->self->assertEquals('it is beans', $beans);
return 'query registered using action attribute';
}
#[RpcQuery('aiwass:test:query2')]
public static function staticQueryRegisteredUsingQueryAttribute(string $required, string $optional = 'the') {
self::$that->assertEquals('internet', $required);
self::$that->assertEquals('the', $optional);
return 'static query registered using query attribute';
}
#[RpcProcedure('aiwass:test:proc2')]
public function dynamicProcedureRegisteredUsingProcedureAttribute(string $optional = 'meow') {
$this->self->assertEquals('meow', $optional);
return 'dynamic procedure registered using procedure attribute';
}
#[RpcQuery('aiwass:test:query3')]
#[RpcProcedure('aiwass:test:proc3')]
public function multiple() {
return 'a dynamic method registered as both a query and a procedure';
}
public function hasNoAttr() {
return 'not an action handler';
}
};
$server = new RpcServer;
$server->register($handler);
$this->assertNull($server->getActionInfo('aiwass:none'));
$proc1 = $server->getActionInfo('aiwass:test:proc1');
$this->assertNotNull($proc1);
$this->assertTrue($proc1->isProcedure());
$this->assertEquals('aiwass:test:proc1', $proc1->getName());
$this->assertEquals('procedure registered using action attribute', $proc1->invokeHandler());
$proc2 = $server->getActionInfo('aiwass:test:proc2');
$this->assertNotNull($proc2);
$this->assertTrue($proc2->isProcedure());
$this->assertEquals('aiwass:test:proc2', $proc2->getName());
$this->assertEquals('dynamic procedure registered using procedure attribute', $proc2->invokeHandler());
$query1 = $server->getActionInfo('aiwass:test:query1');
$this->assertNotNull($query1);
$this->assertFalse($query1->isProcedure());
$this->assertEquals('aiwass:test:query1', $query1->getName());
$this->assertEquals('query registered using action attribute', $query1->invokeHandler(['beans' => 'it is beans']));
$query2 = $server->getActionInfo('aiwass:test:query2');
$this->assertNotNull($query2);
$this->assertFalse($query2->isProcedure());
$this->assertEquals('aiwass:test:query2', $query2->getName());
$this->assertEquals('static query registered using query attribute', $query2->invokeHandler(['required' => 'internet']));
$query3 = $server->getActionInfo('aiwass:test:query3');
$proc3 = $server->getActionInfo('aiwass:test:proc3');
$this->assertNotNull($query3);
$this->assertNotNull($proc3);
$this->assertFalse($query3->isProcedure());
$this->asserttrue($proc3->isProcedure());
$this->assertEquals('aiwass:test:query3', $query3->getName());
$this->assertEquals('aiwass:test:proc3', $proc3->getName());
$this->assertEquals($query3->getHandler(), $proc3->getHandler());
$this->assertEquals('a dynamic method registered as both a query and a procedure', $query3->invokeHandler());
$this->assertEquals('a dynamic method registered as both a query and a procedure', $proc3->invokeHandler());
$this->assertNull($server->getActionInfo('doesnotexist'));
$this->expectException(RuntimeException::class);
$query1->invokeHandler(['notbeans' => 'it is not beans']);
}
}

View file

@ -0,0 +1,42 @@
<?php
// ScopedServerTest.php
// Created: 2024-08-16
// Updated: 2024-08-16
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\{CoversClass,UsesClass};
use Aiwass\{RpcServer,RpcServerScoped};
#[CoversClass(RpcServerScoped::class)]
#[UsesClass(RpcServer::class)]
final class ScopedServerTest extends TestCase {
public function testServerScoping(): void {
$base = new RpcServer;
$base->registerQueryAction('test', fn() => 'test');
$scopedToBeans = $base->scopeTo('beans:');
$scopedToBeans->registerQueryAction('test', fn() => 'test in beans');
$scopedToGarf = $base->scopeTo('garf:');
$scopedToGarfield = $scopedToGarf->scopeTo('ield:');
$scopedToGarfield->registerQueryAction('test', fn() => 'test in garfield');
$baseTest = $base->getActionInfo('test');
$this->assertNotNull($baseTest);
$this->assertEquals('test', $baseTest->getName());
$baseBeansTest = $base->getActionInfo('beans:test');
$scopedBeansTest = $scopedToBeans->getActionInfo('test');
$this->assertNotNull($baseBeansTest);
$this->assertSame($baseBeansTest, $scopedBeansTest);
$this->assertEquals('beans:test', $scopedBeansTest->getName());
$baseGarfieldTest = $base->getActionInfo('garf:ield:test');
$scopedGarfieldTest = $scopedToGarfield->getActionInfo('test');
$this->assertNotNull($baseGarfieldTest);
$this->assertSame($baseGarfieldTest, $scopedGarfieldTest);
$this->assertEquals('garf:ield:test', $scopedGarfieldTest->getName());
}
}