Skip to content

Commit 60be808

Browse files
gharlanclaude
andauthored
feat: auto-discover API functions via attributes and ClassDiscovery (#6483)
Replace the hardcoded API function registry with automatic class discovery using PHP attributes. Each API function class is now annotated with #[AsApiFunction('name')] and discovered at runtime via the new ClassDiscovery service, which scans Composer's classmap and PSR-4 directories filtered to core, active addons, and project-level code. The cache uses xxh128 hashing for addon-based invalidation, with an additional file-listing hash check in debug mode so new classes are found immediately without manual cache clearing. ApiFunction::register() remains available as an alternative to attributes. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1998422 commit 60be808

30 files changed

Lines changed: 432 additions & 36 deletions

.tools/psalm/baseline.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,6 +1819,12 @@
18191819
<code><![CDATA[gzgets($fpIn, 1024 * 512)]]></code>
18201820
</PossiblyFalseOperand>
18211821
</file>
1822+
<file src="src/ClassDiscovery.php">
1823+
<MixedReturnStatement>
1824+
<code><![CDATA[array_first(ClassLoader::getRegisteredLoaders())
1825+
?? throw new RuntimeException('Composer ClassLoader not found.')]]></code>
1826+
</MixedReturnStatement>
1827+
</file>
18221828
<file src="src/Config.php">
18231829
<ArgumentTypeCoercion>
18241830
<code><![CDATA[$cfg->getValue('value')]]></code>

addons/debug/boot.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919

2020
use function Redaxo\Core\View\escape;
2121

22-
ApiFunction::register('debug', rex_api_debug::class);
23-
2422
if (!rex_debug_clockwork::isRexDebugEnabled() || 'debug' === Request::get(ApiFunction::REQ_CALL_PARAM)) {
2523
return;
2624
}

addons/debug/lib/api_debug.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
<?php
22

33
use Redaxo\Core\ApiFunction\ApiFunction;
4+
use Redaxo\Core\ApiFunction\AsApiFunction;
45
use Redaxo\Core\ApiFunction\Result;
56
use Redaxo\Core\Core;
67
use Redaxo\Core\Http\Response;
78

89
/**
910
* @internal
1011
*/
12+
#[AsApiFunction('debug')]
1113
class rex_api_debug extends ApiFunction
1214
{
1315
public function execute()

src/Addon/ApiFunction/AddonOperation.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Redaxo\Core\Addon\Addon as BaseAddon;
77
use Redaxo\Core\Addon\AddonManager;
88
use Redaxo\Core\ApiFunction\ApiFunction;
9+
use Redaxo\Core\ApiFunction\AsApiFunction;
910
use Redaxo\Core\ApiFunction\Exception\ApiFunctionException;
1011
use Redaxo\Core\ApiFunction\Result;
1112
use Redaxo\Core\Core;
@@ -18,6 +19,7 @@
1819
/**
1920
* @internal
2021
*/
22+
#[AsApiFunction('addon_operation')]
2123
final class AddonOperation extends ApiFunction
2224
{
2325
#[Override]

src/ApiFunction/ApiFunction.php

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,16 @@
33
namespace Redaxo\Core\ApiFunction;
44

55
use BadMethodCallException;
6-
use Redaxo\Core\Addon\ApiFunction\AddonOperation;
76
use Redaxo\Core\ApiFunction\Exception\ApiFunctionException;
87
use Redaxo\Core\Base\FactoryTrait;
9-
use Redaxo\Core\Content\ApiFunction as ContentApiFunction;
8+
use Redaxo\Core\ClassDiscovery;
109
use Redaxo\Core\Core;
1110
use Redaxo\Core\Exception\LogicException;
1211
use Redaxo\Core\Http\Context;
1312
use Redaxo\Core\Http\Exception\HttpException;
1413
use Redaxo\Core\Http\Exception\NotFoundHttpException;
1514
use Redaxo\Core\Http\Request;
1615
use Redaxo\Core\Http\Response;
17-
use Redaxo\Core\MetaInfo\ApiFunction\DefaultFieldsCreate;
18-
use Redaxo\Core\Security\ApiFunction as SecurityApiFunction;
1916
use Redaxo\Core\Security\CsrfToken;
2017
use Redaxo\Core\Security\Login;
2118
use Redaxo\Core\Translation\I18n;
@@ -65,36 +62,11 @@ abstract class ApiFunction
6562
protected $result;
6663

6764
/**
68-
* Explicitly registered api functions.
65+
* Discovered and registered api functions.
6966
*
70-
* @var array<string, class-string<ApiFunction>>
67+
* @var array<string, class-string<self>>|null
7168
*/
72-
private static $functions = [
73-
'addon_operation' => AddonOperation::class,
74-
'article_add' => ContentApiFunction\ArticleAdd::class,
75-
'article_copy' => ContentApiFunction\ArticleCopy::class,
76-
'article_delete' => ContentApiFunction\ArticleDelete::class,
77-
'article_edit' => ContentApiFunction\ArticleEdit::class,
78-
'article_move' => ContentApiFunction\ArticleMove::class,
79-
'article_slice_move' => ContentApiFunction\ArticleSliceMove::class,
80-
'article_slice_status_change' => ContentApiFunction\ArticleSliceStatusChange::class,
81-
'article_status_change' => ContentApiFunction\ArticleStatusChange::class,
82-
'article_to_category' => ContentApiFunction\ArticleToCategory::class,
83-
'article_to_startarticle' => ContentApiFunction\ArticleToStartArticle::class,
84-
'category_add' => ContentApiFunction\CategoryAdd::class,
85-
'category_delete' => ContentApiFunction\CategoryDelete::class,
86-
'category_edit' => ContentApiFunction\CategoryEdit::class,
87-
'category_move' => ContentApiFunction\CategoryMove::class,
88-
'category_status_change' => ContentApiFunction\CategoryStatusChange::class,
89-
'category_to_article' => ContentApiFunction\CategoryToArticle::class,
90-
'content_copy' => ContentApiFunction\ContentCopy::class,
91-
'metainfo_default_fields_create' => DefaultFieldsCreate::class,
92-
'user_has_session' => SecurityApiFunction\UserHasSession::class,
93-
'user_impersonate' => SecurityApiFunction\UserImpersonate::class,
94-
'user_remove_auth_method' => SecurityApiFunction\UserRemoveAuthMethod::class,
95-
'user_remove_session' => SecurityApiFunction\UserRemoveSession::class,
96-
'user_session_status' => SecurityApiFunction\UserSessionStatus::class,
97-
];
69+
private static ?array $functions = null;
9870

9971
/**
10072
* The api function which is bound to the current request.
@@ -127,7 +99,11 @@ public static function factory(): ?self
12799
$api = Request::request(self::REQ_CALL_PARAM, 'string');
128100

129101
if ($api) {
130-
$apiClass = self::$functions[$api];
102+
$apiClass = self::loadFunctions()[$api] ?? null;
103+
104+
if (null === $apiClass) {
105+
throw new NotFoundHttpException('API function "' . $api . '" is not registered.');
106+
}
131107

132108
if (class_exists($apiClass)) {
133109
$apiImpl = new $apiClass();
@@ -146,6 +122,7 @@ public static function factory(): ?self
146122
/** @param class-string<ApiFunction> $class */
147123
public static function register(string $name, string $class): void
148124
{
125+
self::loadFunctions();
149126
self::$functions[$name] = $class;
150127
}
151128

@@ -312,9 +289,24 @@ protected function requiresCsrfProtection()
312289
return false;
313290
}
314291

292+
/** @return array<string, class-string<ApiFunction>> */
293+
private static function loadFunctions(): array
294+
{
295+
if (null !== self::$functions) {
296+
return self::$functions;
297+
}
298+
299+
$functions = [];
300+
foreach (ClassDiscovery::getInstance()->discoverByAttribute(AsApiFunction::class, self::class) as $class => $attribute) {
301+
$functions[$attribute->name] = $class;
302+
}
303+
304+
return self::$functions = $functions;
305+
}
306+
315307
private static function getName(string $class): string
316308
{
317-
$name = array_search($class, self::$functions, true);
309+
$name = array_search($class, self::loadFunctions(), true);
318310
if (false !== $name) {
319311
return $name;
320312
}

src/ApiFunction/AsApiFunction.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Redaxo\Core\ApiFunction;
4+
5+
use Attribute;
6+
7+
#[Attribute(Attribute::TARGET_CLASS)]
8+
final readonly class AsApiFunction
9+
{
10+
public function __construct(
11+
public string $name,
12+
) {}
13+
}

0 commit comments

Comments
 (0)