Prototype for an API method
authorAlexander Ebert <ebert@woltlab.com>
Sat, 9 Mar 2024 18:04:42 +0000 (19:04 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Sun, 10 Mar 2024 14:19:39 +0000 (15:19 +0100)
wcfsetup/install/files/lib/action/ApiAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
wcfsetup/install/files/lib/system/endpoint/IController.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/endpoint/PostRequest.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/endpoint/error/RouteParameterError.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/endpoint/event/ControllerCollecting.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/endpoint/exception/RouteParameterMismatch.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/endpoint/moderationqueues/Assign.class.php [new file with mode: 0644]

diff --git a/wcfsetup/install/files/lib/action/ApiAction.class.php b/wcfsetup/install/files/lib/action/ApiAction.class.php
new file mode 100644 (file)
index 0000000..ac51398
--- /dev/null
@@ -0,0 +1,231 @@
+<?php
+
+namespace wcf\action;
+
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use wcf\system\endpoint\error\RouteParameterError;
+use wcf\system\endpoint\event\ControllerCollecting;
+use wcf\system\endpoint\exception\RouteParameterMismatch;
+use wcf\system\endpoint\IController;
+use wcf\system\endpoint\PostRequest;
+use wcf\system\event\EventHandler;
+use wcf\system\request\RouteHandler;
+
+final class ApiAction implements RequestHandlerInterface
+{
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        $result = $this->parsePathInfo(RouteHandler::getPathInfo());
+        if ($result === null) {
+            \wcfDebug(RouteHandler::getPathInfo());
+        }
+
+        [$type, $prefix, $endpoint] = $result;
+
+        $event = new ControllerCollecting($prefix);
+        EventHandler::getInstance()->fire($event);
+
+        $method = null;
+        $matches = [];
+        foreach ($event->getControllers() as $controller) {
+            $result = $this->findRequestedEndpoint($prefix, $endpoint, $controller);
+            if ($result !== null) {
+                [$method, $matches] = $result;
+
+                break;
+            }
+        }
+
+        if ($method === null) {
+            // TODO: debug response
+            return new JsonResponse([
+                'type' => $type,
+                'prefix' => $prefix,
+                'endpoint' => $endpoint,
+            ]);
+        }
+
+        try {
+            return $this->forwardRequest($request, $controller, $method, $matches);
+        } catch (RouteParameterMismatch $e) {
+            // TODO: proper wrapper?
+            return new JsonResponse([
+                'code' => $e->type,
+                'message' => '',
+                'param' => $e->name,
+            ], 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 RouteParameterMismatch(
+                        RouteParameterError::ParameterWithoutType,
+                        $parameter->name
+                    );
+                }
+
+                if (!($type instanceof \ReflectionNamedType)) {
+                    throw new RouteParameterMismatch(
+                        RouteParameterError::ParameterTypeComplex,
+                        $parameter->name
+                    );
+                }
+
+                if ($type->getName() === 'int' || $type->getName() === 'string') {
+                    $value = $matches[$parameter->name] ?? null;
+                    if ($value === null) {
+                        throw new RouteParameterMismatch(
+                            RouteParameterError::ParameterNotInUri,
+                            $parameter->name
+                        );
+                    }
+
+                    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;
+                }
+
+                throw new RouteParameterMismatch(
+                    RouteParameterError::ParameterTypeUnknown,
+                    $parameter->name
+                );
+            },
+            $method->getParameters(),
+        );
+
+        return $controller->{$method->name}(...$parameters);
+    }
+
+    /**
+     * @return array{\ReflectionMethod, array{string: string}}|null
+     */
+    private function findRequestedEndpoint(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(PostRequest::class));
+            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(PostRequest $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
+     */
+    private function parsePathInfo(string $pathInfo): array|null
+    {
+        if (!\str_starts_with($pathInfo, 'api/rpc/')) {
+            return null;
+        }
+
+        $pathInfo = \mb_substr($pathInfo, \strlen('api/rpc/') - 1);
+
+        $segments = \explode('/', $pathInfo);
+        if (\count($segments) < 3) {
+            // The namespace and the primary object are always required.
+            return null;
+        }
+
+        return [
+            'rpc',
+            \sprintf(
+                '/%s/%s',
+                $segments[1],
+                $segments[2],
+            ),
+            $pathInfo
+        ];
+    }
+}
index c34690f0975fe0ad3a33a15c53100091b8830c65..945248053c65bef85a0695642be35166fd317168 100644 (file)
@@ -2,6 +2,7 @@
 
 use wcf\system\acp\dashboard\event\AcpDashboardCollecting;
 use wcf\system\cronjob\CronjobScheduler;
+use wcf\system\endpoint\event\ControllerCollecting;
 use wcf\system\event\EventHandler;
 use wcf\system\event\listener\PackageUpdateListChangedLicenseListener;
 use wcf\system\event\listener\PhraseChangedPreloadListener;
@@ -83,6 +84,10 @@ return static function (): void {
         $event->register(new \wcf\system\acp\dashboard\box\CreditsAcpDashboardBox());
     });
 
+    $eventHandler->register(ControllerCollecting::class, static function (ControllerCollecting $event) {
+        $event->register(new \wcf\system\endpoint\moderationqueues\Assign);
+    });
+
     try {
         $licenseApi = new LicenseApi();
         $licenseData = $licenseApi->readFromFile();
diff --git a/wcfsetup/install/files/lib/system/endpoint/IController.class.php b/wcfsetup/install/files/lib/system/endpoint/IController.class.php
new file mode 100644 (file)
index 0000000..691eb11
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace wcf\system\endpoint;
+
+interface IController
+{
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/PostRequest.class.php b/wcfsetup/install/files/lib/system/endpoint/PostRequest.class.php
new file mode 100644 (file)
index 0000000..7dc196f
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace wcf\system\endpoint;
+
+#[\Attribute(\Attribute::TARGET_METHOD)]
+final class PostRequest
+{
+    public function __construct(
+        public readonly string $uri,
+    ) {
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/error/RouteParameterError.class.php b/wcfsetup/install/files/lib/system/endpoint/error/RouteParameterError.class.php
new file mode 100644 (file)
index 0000000..96b38ce
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+namespace wcf\system\endpoint\error;
+
+enum RouteParameterError: string
+{
+    case ExpectedPositiveInteger = 'expected_positive_integer';
+    case ExpectedNonEmptyString = 'expected_non_empty_string';
+    case ParameterTypeComplex = 'parameter_type_complex';
+    case ParameterTypeUnknown = 'parameter_type_unknown';
+    case ParameterWithoutType = 'parameter_without_type';
+    case ParameterNotInUri = 'parameter_not_in_uri';
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/event/ControllerCollecting.class.php b/wcfsetup/install/files/lib/system/endpoint/event/ControllerCollecting.class.php
new file mode 100644 (file)
index 0000000..73a2b53
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace wcf\system\endpoint\event;
+
+use wcf\system\endpoint\IController;
+use wcf\system\event\IEvent;
+
+final class ControllerCollecting implements IEvent
+{
+    /**
+     * @var IController[]
+     */
+    private array $controllers = [];
+
+    public function register(IController $controller): void
+    {
+        $this->controllers[] = $controller;
+    }
+
+    /**
+     * @return IController[]
+     */
+    public function getControllers(): array
+    {
+        return $this->controllers;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/exception/RouteParameterMismatch.class.php b/wcfsetup/install/files/lib/system/endpoint/exception/RouteParameterMismatch.class.php
new file mode 100644 (file)
index 0000000..9c39a50
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+
+namespace wcf\system\endpoint\exception;
+
+use wcf\system\endpoint\error\RouteParameterError;
+
+final class RouteParameterMismatch extends \Exception
+{
+    public function __construct(
+        public readonly RouteParameterError $type,
+        public readonly string $name,
+    ) {
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/moderationqueues/Assign.class.php b/wcfsetup/install/files/lib/system/endpoint/moderationqueues/Assign.class.php
new file mode 100644 (file)
index 0000000..6f2e644
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\endpoint\moderationqueues;
+
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use wcf\system\endpoint\IController;
+use wcf\system\endpoint\PostRequest;
+
+final class Assign implements IController
+{
+    #[PostRequest('/core/moderationqueues/:id/assign')]
+    public function assign(int $id, ServerRequestInterface $request): ResponseInterface
+    {
+        return new JsonResponse([
+            'id' => $id,
+            'request' => $request,
+        ]);
+    }
+}