From 14b56479b0a4c27dec214e018348e5310212f6a3 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 11 Mar 2024 13:59:44 +0100 Subject: [PATCH] Implement a prototype based on `FastRoute` --- .../files/lib/action/ApiAction.class.php | 244 ++++-------------- .../install/files/lib/http/Helper.class.php | 19 ++ .../lib/system/endpoint/GetRequest.class.php | 2 +- .../lib/system/endpoint/IController.class.php | 9 + .../lib/system/endpoint/PostRequest.class.php | 2 +- .../system/endpoint/RequestMethod.class.php | 14 +- .../lib/system/endpoint/RequestType.class.php | 3 +- .../messages/MentionSuggestions.class.php | 12 +- 8 files changed, 95 insertions(+), 210 deletions(-) diff --git a/wcfsetup/install/files/lib/action/ApiAction.class.php b/wcfsetup/install/files/lib/action/ApiAction.class.php index db18ca6986..0779b6fc70 100644 --- a/wcfsetup/install/files/lib/action/ApiAction.class.php +++ b/wcfsetup/install/files/lib/action/ApiAction.class.php @@ -2,24 +2,26 @@ namespace wcf\action; +use CuyZ\Valinor\Mapper\MappingError; +use FastRoute\Dispatcher\Result\MethodNotAllowed; +use FastRoute\Dispatcher\Result\NotMatched; +use FastRoute\RouteCollector; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Server\RequestHandlerInterface; -use wcf\http\Helper; -use wcf\system\endpoint\error\ControllerError; -use wcf\system\endpoint\error\RouteParameterError; use wcf\system\endpoint\event\ControllerCollecting; -use wcf\system\endpoint\exception\ControllerMalformed; -use wcf\system\endpoint\exception\RouteParameterMismatch; use wcf\system\endpoint\GetRequest; use wcf\system\endpoint\IController; use wcf\system\endpoint\Parameters; use wcf\system\endpoint\PostRequest; +use wcf\system\endpoint\ApiController; use wcf\system\endpoint\RequestType; use wcf\system\event\EventHandler; use wcf\system\request\RouteHandler; +use function FastRoute\simpleDispatcher; + final class ApiAction implements RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface @@ -47,222 +49,66 @@ final class ApiAction implements RequestHandlerInterface [$type, $prefix, $endpoint] = $result; + // TODO: This is currently very inefficient and should be cached in some + // way, maybe even use a combined cache for both? $event = new ControllerCollecting($prefix); EventHandler::getInstance()->fire($event); - $method = null; - $matches = []; - foreach ($event->getControllers() as $controller) { - $result = $this->findRequestedEndpoint($targetAttribute, $prefix, $endpoint, $controller); - if ($result !== null) { - [$method, $matches] = $result; + $dispatcher = simpleDispatcher( + static function (RouteCollector $r) use ($event) { + foreach ($event->getControllers() as $controller) { + $reflectionClass = new \ReflectionClass($controller); + $attribute = current($reflectionClass->getAttributes(RequestType::class, \ReflectionAttribute::IS_INSTANCEOF)); + \assert($attribute !== false); + + $apiController = $attribute->newInstance(); + + $r->addRoute($apiController->method->toString(), $apiController->uri, $controller); + } + }, + [ + // TODO: debug only + 'cacheDisabled' => true, + ] + ); + + $result = $dispatcher->dispatch($request->getMethod(), $endpoint); - break; - } + if ($result instanceof NotMatched) { + // TODO: debug response + return new JsonResponse([ + 'type' => 'invalid_request_error', + 'type' => $type, + 'prefix' => $prefix, + 'endpoint' => $endpoint, + ], 404); } - if ($method === null) { + if ($result instanceof MethodNotAllowed) { // TODO: debug response return new JsonResponse([ 'type' => 'invalid_request_error', 'type' => $type, 'prefix' => $prefix, 'endpoint' => $endpoint, - ]); + ], 405); } - try { - return $this->forwardRequest($request, $controller, $method, $matches); - } catch (ControllerMalformed $e) { - \wcf\functions\exception\logThrowable($e); + /** @var IController */ + $controller = $result->handler; - // TODO: proper wrapper? - return new JsonResponse([ - 'type' => 'api_error', - 'code' => $e->type, - 'message' => \ENABLE_DEBUG_MODE ? $e->getMessage() : '', - 'param' => '', - ], 400); - } catch (RouteParameterMismatch $e) { - // TODO: proper wrapper? + try { + return $controller($request, $result->variables); + } catch (MappingError $e) { return new JsonResponse([ 'type' => 'invalid_request_error', - 'code' => $e->type, + 'code' => 'mapping_error', 'message' => $e->getMessage(), - 'param' => $e->name, + 'param' => '', ], 400); } } - private function forwardRequest( - ServerRequestInterface $request, - IController $controller, - \ReflectionMethod $method, - array $matches - ): ResponseInterface { - $parameters = \array_map( - static function (\ReflectionParameter $parameter) use ($matches, $request) { - $type = $parameter->getType(); - if ($type === null) { - throw new ControllerMalformed( - ControllerError::ParameterWithoutType, - $parameter, - ); - } - - if (!($type instanceof \ReflectionNamedType)) { - throw new ControllerMalformed( - ControllerError::ParameterTypeComplex, - $parameter, - ); - } - - if ($type->getName() === 'int' || $type->getName() === 'string') { - $value = $matches[$parameter->name] ?? null; - if ($value === null) { - throw new ControllerMalformed( - ControllerError::ParameterNotInUri, - $parameter, - ); - } - - if ($type->getName() === 'int') { - $value = (int)$value; - if ($value <= 0) { - throw new RouteParameterMismatch( - RouteParameterError::ExpectedPositiveInteger, - $parameter->name - ); - } - - return $value; - } - - if ($type->getName() === 'string') { - $value = \trim($value); - if ($value === '') { - throw new RouteParameterMismatch( - RouteParameterError::ExpectedNonEmptyString, - $parameter->name - ); - } - - return $value; - } - - throw new \LogicException('Unreachable'); - } else if ($type->getName() === ServerRequestInterface::class) { - return $request; - } - - // Support the mapping of parameters based on the request type. - $mappingAttribute = current($parameter->getAttributes(Parameters::class)); - if ($mappingAttribute !== false) { - if ($type->getName() === 'array') { - $classStringOrShape = $mappingAttribute->newInstance()->arrayShape; - } else { - $classStringOrShape = $type->getName(); - } - - if ($request->getMethod() === 'GET' || $request->getMethod() === 'DELETE') { - return Helper::mapQueryParameters( - $request->getQueryParams(), - $classStringOrShape, - ); - } else { - return Helper::mapRequestBody( - $request->getParsedBody(), - $classStringOrShape, - ); - } - } - - throw new ControllerMalformed( - ControllerError::ParameterTypeUnknown, - $parameter, - ); - }, - $method->getParameters(), - ); - - return $controller->{$method->name}(...$parameters); - } - - /** - * @template T of RequestType - * @param class-string $targetAttribute - * @return array{\ReflectionMethod, array{string: string}}|null - */ - private function findRequestedEndpoint( - string $targetAttribute, - string $prefix, - string $endpoint, - IController $controller - ): array|null { - $reflectionClass = new \ReflectionClass($controller); - $publicMethods = $reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC); - - foreach ($publicMethods as $method) { - $reflectionAttribute = \current($method->getAttributes($targetAttribute)); - if ($reflectionAttribute === false) { - continue; - } - - $attribute = $reflectionAttribute->newInstance(); - if (!\str_starts_with($attribute->uri, $prefix)) { - continue; - } - - $matches = $this->getMatchesFromUri($attribute, $endpoint); - if ($matches === null) { - continue; - } - - return [ - $method, - $matches, - ]; - } - - return null; - } - - private function getMatchesFromUri(RequestType $request, string $endpoint): array|null - { - $segments = \explode('/', $request->uri); - - $keys = []; - foreach ($segments as &$segment) { - if ($segment === '') { - continue; - } - - if (!\str_starts_with($segment, ':')) { - continue; - } - - $key = \substr($segment, 1); - $keys[] = $key; - - $segment = \sprintf( - '(?<%s>[^/]++)', - $key, - ); - } - unset($segment); - - $pattern = '~^' . \implode('/', $segments) . '$~'; - if (\preg_match($pattern, $endpoint, $matches)) { - return \array_filter( - $matches, - static fn (string $key) => \in_array($key, $keys), - \ARRAY_FILTER_USE_KEY, - ); - } - - return null; - } - /** * @return array{string, string, string}|null */ diff --git a/wcfsetup/install/files/lib/http/Helper.class.php b/wcfsetup/install/files/lib/http/Helper.class.php index 1df3e472b1..d254b411b9 100644 --- a/wcfsetup/install/files/lib/http/Helper.class.php +++ b/wcfsetup/install/files/lib/http/Helper.class.php @@ -8,6 +8,7 @@ use CuyZ\Valinor\MapperBuilder; use Negotiation\Accept; use Negotiation\Negotiator; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; use wcf\util\StringUtil; @@ -140,6 +141,24 @@ final class Helper ); } + /** + * Validates the query string parameters for `GET` and `DELETE` requests + * based on the signature of the provided class name. For `POST` request + * the parsed body is validated instead. + * + * @template T + * @param class-string $className + * @return T + * @throws MappingError + */ + public static function mapApiParameters(ServerRequestInterface $request, string $className): object + { + return match ($request->getMethod()) { + 'GET', 'DELETE' => self::mapQueryParameters($request->getQueryParams(), $className), + 'POST' => self::mapRequestBody($request->getParsedBody(), $className) + }; + } + /** * Forbid creation of Helper objects. */ diff --git a/wcfsetup/install/files/lib/system/endpoint/GetRequest.class.php b/wcfsetup/install/files/lib/system/endpoint/GetRequest.class.php index d222c220fb..69e63e9824 100644 --- a/wcfsetup/install/files/lib/system/endpoint/GetRequest.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/GetRequest.class.php @@ -2,7 +2,7 @@ namespace wcf\system\endpoint; -#[\Attribute(\Attribute::TARGET_METHOD)] +#[\Attribute(\Attribute::TARGET_CLASS)] final class GetRequest extends RequestType { public function __construct(string $uri) diff --git a/wcfsetup/install/files/lib/system/endpoint/IController.class.php b/wcfsetup/install/files/lib/system/endpoint/IController.class.php index 691eb117c5..2d34af7a5b 100644 --- a/wcfsetup/install/files/lib/system/endpoint/IController.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/IController.class.php @@ -2,6 +2,15 @@ namespace wcf\system\endpoint; +use CuyZ\Valinor\Mapper\MappingError; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + interface IController { + /** + * @param array $variables + * @throws MappingError + */ + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface; } diff --git a/wcfsetup/install/files/lib/system/endpoint/PostRequest.class.php b/wcfsetup/install/files/lib/system/endpoint/PostRequest.class.php index 0d5ea6e4a4..dc38206914 100644 --- a/wcfsetup/install/files/lib/system/endpoint/PostRequest.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/PostRequest.class.php @@ -2,7 +2,7 @@ namespace wcf\system\endpoint; -#[\Attribute(\Attribute::TARGET_METHOD)] +#[\Attribute(\Attribute::TARGET_CLASS)] final class PostRequest extends RequestType { public function __construct(string $uri) diff --git a/wcfsetup/install/files/lib/system/endpoint/RequestMethod.class.php b/wcfsetup/install/files/lib/system/endpoint/RequestMethod.class.php index 939211e6b5..333931bb3c 100644 --- a/wcfsetup/install/files/lib/system/endpoint/RequestMethod.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/RequestMethod.class.php @@ -2,8 +2,16 @@ namespace wcf\system\endpoint; -enum RequestMethod: string +enum RequestMethod { - case GET = 'GET'; - case POST = 'POST'; + case GET; + case POST; + + public function toString(): string + { + return match ($this) { + self::GET => 'GET', + self::POST => 'POST', + }; + } } diff --git a/wcfsetup/install/files/lib/system/endpoint/RequestType.class.php b/wcfsetup/install/files/lib/system/endpoint/RequestType.class.php index 9003c21208..64e3224358 100644 --- a/wcfsetup/install/files/lib/system/endpoint/RequestType.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/RequestType.class.php @@ -2,7 +2,8 @@ namespace wcf\system\endpoint; -abstract class RequestType +#[\Attribute(\Attribute::TARGET_CLASS)] +class RequestType { public function __construct( public readonly RequestMethod $method, diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/MentionSuggestions.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/MentionSuggestions.class.php index 1ed792b5f9..293e2c55d2 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/MentionSuggestions.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/MentionSuggestions.class.php @@ -4,19 +4,21 @@ namespace wcf\system\endpoint\controller\core\messages; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use wcf\data\user\group\UserGroup; use wcf\data\user\UserProfileList; +use wcf\http\Helper; use wcf\system\endpoint\GetRequest; use wcf\system\endpoint\IController; -use wcf\system\endpoint\Parameters; use wcf\system\WCF; +#[GetRequest('/core/messages/mentionsuggestions')] final class MentionSuggestions implements IController { - #[GetRequest('/core/messages/mentionsuggestions')] - public function mentionSuggestions( - #[Parameters] MentionSuggestionsParameters $parameters - ): ResponseInterface { + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $parameters = Helper::mapApiParameters($request, MentionSuggestionsParameters::class); + $query = \mb_strtolower($parameters->query); $matches = []; -- 2.20.1