Implement a prototype based on `FastRoute`
authorAlexander Ebert <ebert@woltlab.com>
Mon, 11 Mar 2024 12:59:44 +0000 (13:59 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Mon, 11 Mar 2024 12:59:44 +0000 (13:59 +0100)
wcfsetup/install/files/lib/action/ApiAction.class.php
wcfsetup/install/files/lib/http/Helper.class.php
wcfsetup/install/files/lib/system/endpoint/GetRequest.class.php
wcfsetup/install/files/lib/system/endpoint/IController.class.php
wcfsetup/install/files/lib/system/endpoint/PostRequest.class.php
wcfsetup/install/files/lib/system/endpoint/RequestMethod.class.php
wcfsetup/install/files/lib/system/endpoint/RequestType.class.php
wcfsetup/install/files/lib/system/endpoint/controller/core/messages/MentionSuggestions.class.php

index db18ca69865c7c93300266ae1c7fa22a14b3f6cf..0779b6fc707d9c0360c20e38ee7d629c2e277493 100644 (file)
@@ -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<T> $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
      */
index 1df3e472b181069a2d27b6b63e24dc1af77b2b71..d254b411b90dd43a8137ce8ea29e99220d7c9fdf 100644 (file)
@@ -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<T> $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.
      */
index d222c220fbd662d1474c5a86bde09330112579bd..69e63e9824413122c66e1ade4dc87db8d1272a2d 100644 (file)
@@ -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)
index 691eb117c55e4f967ad639cfed3883a960fce592..2d34af7a5bb097b42d2d96a8b97e6b9e3357ce40 100644 (file)
@@ -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<string, string> $variables
+     * @throws MappingError
+     */
+    public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface;
 }
index 0d5ea6e4a46d982ebf98defcc5e587864a3d8597..dc3820691400bb1fa9cc66ca9412396c83e7f291 100644 (file)
@@ -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)
index 939211e6b53eadf4a8af52208b53f47548c65804..333931bb3c6c74afd8b84a213edc4231238bde4b 100644 (file)
@@ -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',
+        };
+    }
 }
index 9003c21208a0e4d39c731cbc5df634faeb012cf2..64e32243581c5b58b9a2b4d37473a9b90d770ad5 100644 (file)
@@ -2,7 +2,8 @@
 
 namespace wcf\system\endpoint;
 
-abstract class RequestType
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class RequestType
 {
     public function __construct(
         public readonly RequestMethod $method,
index 1ed792b5f992268535e77a11e8fb9190de7a94f9..293e2c55d2da7b03278763b9b3fd3a9cf56d5e2a 100644 (file)
@@ -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 = [];