diff --git a/.horde.yml b/.horde.yml index ad65b190..43aae82c 100644 --- a/.horde.yml +++ b/.horde.yml @@ -84,6 +84,7 @@ dependencies: horde/pack: ^2 horde/perms: ^3 horde/prefs: ^3 + horde/rpc: ^3 horde/secret: ^3 horde/serialize: ^3 horde/sessionhandler: ^3 diff --git a/composer.json b/composer.json index 04d120f3..4e911558 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,15 @@ "errorhandler" ], "time": "2026-04-26", - "repositories": [], + "repositories": [ + { + "type": "path", + "url": "../Rpc", + "options": { + "symlink": true + } + } + ], "require": { "horde/horde-installer-plugin": "dev-FRAMEWORK_6_0 || ^3 || ^2", "php": "^7.4 || ^8", @@ -74,6 +82,7 @@ "horde/pack": "^2 || dev-FRAMEWORK_6_0", "horde/perms": "^3 || dev-FRAMEWORK_6_0", "horde/prefs": "^3 || dev-FRAMEWORK_6_0", + "horde/rpc": "*@dev", "horde/secret": "^3 || dev-FRAMEWORK_6_0", "horde/serialize": "^3 || dev-FRAMEWORK_6_0", "horde/sessionhandler": "^3 || dev-FRAMEWORK_6_0", @@ -122,6 +131,8 @@ "Horde\\Core\\Test\\": "test/" } }, + "minimum-stability": "alpha", + "prefer-stable": true, "config": { "allow-plugins": { "horde/horde-installer-plugin": true diff --git a/doc/INTERAPP_RPC.md b/doc/INTERAPP_RPC.md new file mode 100644 index 00000000..ca8d8135 --- /dev/null +++ b/doc/INTERAPP_RPC.md @@ -0,0 +1,373 @@ +# Inter-App Communication and RPC + +Horde 6 applications expose functionality to other apps and to external clients +through a new API system. The old H5-style API system is still active. Both can run side by side in the same +installation. + +## Legacy APIs (Horde_Registry) + +The original system. An application declares the interfaces it provides in its +`registry.d/` config snippet: + +```php +// doc/registry.d/app-content.php +$this->applications['content']['provides'] = ['tagger']; +``` + +Methods live in a class extending `Horde_Registry_Api` (typically +`lib/Api.php`). Other apps call them through `Horde_Registry`: + +```php +$registry->call('tagger/tag', [$userId, $objectId, $tags]); +``` + +Internally the registry uses **slash-separated** method names +(`tagger/tag`). RPC transports (XML-RPC, SOAP, legacy JSON-RPC) convert +slashes to dots on the wire (`tagger.tag`). + +Legacy APIs depend on `Horde_Registry` for discovery, routing and app +bootstrapping. They have no structured metadata. There are no parameter descriptions beyond phpdoc, +no return types, no permission declarations. + +## Modern APIs (ApiRegistry) + +The modern system is built on dependency injection. +It lives in three packages: + +| Package | Responsibility | +|---------|---------------| +| `horde/Rpc` | Interfaces and value objects (`Horde\Rpc\Dispatch\*`) | +| `horde/Core` | Central registry, factory, DI wiring (`Horde\Core\Api\*`) | +| App packages | Provider implementations (`Horde\{App}\Api\*`) | + +### Core interfaces (horde/Rpc) + +**`ApiProviderInterface`** method discovery (introspection only): + +```php +interface ApiProviderInterface +{ + public function hasMethod(string $method, ?ApiCallContext $context = null): bool; + public function getMethodDescriptor(string $method, ?ApiCallContext $context = null): ?MethodDescriptor; + public function listMethods(?ApiCallContext $context = null): array; +} +``` + +**`MethodInvokerInterface`** method execution: + +```php +interface MethodInvokerInterface +{ + public function invoke(string $method, array $params, ?ApiCallContext $context = null): Result; +} +``` + +Every provider implements both interfaces. Separating them allows +introspection without execution (useful for `rpc.discover`, admin screens, +and MCP tool listings). + +**`MethodDescriptor`** readonly metadata for a single method: + +```php +final readonly class MethodDescriptor +{ + public function __construct( + public string $name, + public string $description = '', + public array $parameters = [], + public ?string $returnType = null, + public ?array $inputSchema = null, + public ?array $outputSchema = null, + public array $permissions = [], + ) {} +} +``` + +**`ApiCallContext`** immutable attribute bag carrying out-of-band context: + +```php +$context = new ApiCallContext(['permissions' => ['admin'], 'userId' => $uid]); +``` + +Transports populate this with protocol-specific information (caller identity, +auth state, out of band data). Providers inspect it to filter method visibility or enforce +authorization. A null context means "vanilla response, no special +privileges." + +**`Result`** simple wrapper for invocation return values: + +```php +$result = new Result($value); +``` + +### The central registry (horde/Core) + +`ApiRegistry` aggregates all per-app providers into one place. It implements +both `ApiProviderInterface` and `MethodInvokerInterface` itself, so it can +be used as the single entry point for any transport. + +Method names at the registry level use **dot notation**: `interface.method`. +The registry splits on the first dot to route to the correct provider: + +``` +tagger.tag -> provider "tagger", method "tag" +taggerAdmin.listTagsByUser -> provider "taggerAdmin", method "listTagsByUser" +``` + +Key methods: + +```php +// Standard dispatch splits "interface.method" and delegates +$registry->invoke('tagger.tag', [$userId, $objectId, $tags], $context); + +// Explicit dispatch when caller knows the app +$registry->invokeExplicit('content', 'tagger', 'tag', $params, $context); + +// Introspection +$registry->listMethods($context); // all methods, prefixed +$registry->hasMethod('tagger.tag'); +$registry->getMethodDescriptor('tagger.tag'); + +// Provider access +$registry->getInterfaces(); // ['tagger', 'taggerAdmin', ...] +$registry->getProviderForInterface('tagger'); +``` + +### DI wiring + +`ApiRegistry` is bound as a factory in `DefaultInjectorBindings`: + +```php +ApiRegistry::class => ApiRegistryFactory::class, +``` + +`ApiRegistryFactory` auto-discovers providers at construction time: + +1. Load the list of installed apps from `RegistryConfigLoader` +2. For each app, check if `Horde\{App}\Api` exists and implements + `ApiInterfaceListProvider` +3. Call `getApiInterfaceList()` to get the `interface → providerClass` map +4. Instantiate each provider via the injector +5. Register providers that implement both `ApiProviderInterface` and + `MethodInvokerInterface` + +Apps that have no modern API or fail to instantiate are silently skipped. +The registry starts empty and fills up as providers are discovered. + +### Declaring interfaces in an app + +Create `src/Api.php` implementing `ApiInterfaceListProvider`: + +```php +// src/Api.php +namespace Horde\Content; + +use Horde\Content\Api\TaggerProvider; +use Horde\Content\Api\TaggerAdminProvider; +use Horde\Core\Api\ApiInterfaceListProvider; + +class Api implements ApiInterfaceListProvider +{ + public function getApiInterfaceList(): array + { + return [ + 'tagger' => TaggerProvider::class, + 'taggerAdmin' => TaggerAdminProvider::class, + ]; + } +} +``` + +The class must follow the naming convention `Horde\{Ucfirst_app}\Api` so +the factory can find it by convention. Interface names must not contain +dots (the dot is reserved for `interface.method` separation). + +### Writing a provider + +A provider wraps an existing service and exposes it through the dispatch +interfaces: + +```php +// src/Api/TaggerProvider.php +namespace Horde\Content\Api; + +use Content_Tagger; +use Horde\Rpc\Dispatch\ApiCallContext; +use Horde\Rpc\Dispatch\ApiProviderInterface; +use Horde\Rpc\Dispatch\MethodDescriptor; +use Horde\Rpc\Dispatch\MethodInvokerInterface; +use Horde\Rpc\Dispatch\Result; + +class TaggerProvider implements ApiProviderInterface, MethodInvokerInterface +{ + private readonly array $descriptors; + + public function __construct( + private readonly Content_Tagger $tagger, + ) { + $this->descriptors = $this->buildDescriptors(); + } + + public function hasMethod(string $method, ?ApiCallContext $context = null): bool + { + return isset($this->descriptors[$method]); + } + + public function getMethodDescriptor(string $method, ?ApiCallContext $context = null): ?MethodDescriptor + { + return $this->descriptors[$method] ?? null; + } + + public function listMethods(?ApiCallContext $context = null): array + { + return array_values($this->descriptors); + } + + public function invoke(string $method, array $params, ?ApiCallContext $context = null): Result + { + return match ($method) { + 'tag' => new Result($this->tagger->tag($params[0], $params[1], $params[2])), + // ... other methods ... + default => throw new \RuntimeException("Unknown method \"$method\""), + }; + } + + private function buildDescriptors(): array + { + return [ + 'tag' => new MethodDescriptor( + name: 'tag', + description: 'Add tags to an object', + parameters: [ + ['name' => 'userId', 'type' => 'mixed', 'required' => true], + ['name' => 'objectId', 'type' => 'mixed', 'required' => true], + ['name' => 'tags', 'type' => 'array', 'required' => true], + ], + returnType: 'void', + ), + // ... other descriptors ... + ]; + } +} +``` + +### Permission-gated providers + +Providers can use `ApiCallContext` to restrict visibility and access. +`TaggerAdminProvider` demonstrates this pattern: + +```php +private function isAdmin(?ApiCallContext $context): bool +{ + $perms = $context?->getAttribute('permissions', []); + return is_array($perms) && in_array('admin', $perms, true); +} + +public function listMethods(?ApiCallContext $context = null): array +{ + if (!$this->isAdmin($context)) { + return []; + } + return array_values($this->descriptors); +} +``` + +Methods are invisible without the required context. The `permissions` array +on `MethodDescriptor` declares what a method requires. Transports and +admin screens can display this information. + +## RPC transports + +### JSON-RPC 2.0 (modern) + +The `horde/Rpc` package provides a complete JSON-RPC 2.0 stack: + +``` +HTTP Request + - JsonRpcHandler (PSR-15 middleware) + - Codec (decode JSON-RPC envelope) + - Dispatcher (route to provider/invoker) + - ApiProviderInterface.hasMethod() + - MethodInvokerInterface.invoke() + - Codec (encode JSON-RPC response) + - HTTP Response +``` + +`JsonRpcHandler` wires the components together: + +```php +$handler = new JsonRpcHandler( + provider: $apiRegistry, // ApiRegistry as the single provider + invoker: $apiRegistry, // ApiRegistry as the single invoker + responseFactory: $responseFactory, + streamFactory: $streamFactory, + eventDispatcher: $eventDispatcher, +); + +// Use as PSR-15 handler or middleware +$middleware = $handler->getMiddleware(); +``` + +The `Dispatcher` checks the provider first, then falls back to built-in +system methods: + +| Method | Response | +|--------|----------| +| `rpc.discover` | Lists all available methods with metadata | +| `rpc.ping` | Returns `"pong"` | + +### Legacy bridge (HordeRegistryApiProvider) + +For installations migrating from `Horde_Rpc_Jsonrpc`, the +`HordeRegistryApiProvider` bridges `Horde_Registry` into the modern +dispatch interfaces: + +```php +$provider = new HordeRegistryApiProvider($registry); +$dispatcher = new Dispatcher($provider, $provider); +``` + +It translates between dot notation (`tagger.tag`) and the slash notation +(`tagger/tag`) that `Horde_Registry` expects. This allows legacy and modern +methods to be served through the same JSON-RPC endpoint. + +### XML-RPC and SOAP (currently not available as modern APIs) + +The legacy `Horde_Rpc_Xmlrpc` and `Horde_Rpc_Soap` transports operate +through `Horde_Registry` directly. They convert between dot notation on the +wire and slash notation internally. These transports do not use the modern +dispatch interfaces. + +## Coexistence + +Both systems run independently in the same installation: + +- Modern APIs are registered via `Horde\{App}\Api` classes and dispatched + through `ApiRegistry` +- Legacy APIs have their methods directly on the Horde_{App}_Api class +- Both aggregators depend on the registry to make the final decision which of the available APIs to actually expose +- An app can provide both: a legacy `Horde_Registry_Api` in `lib/Api.php` + and modern providers in `src/Api/` +- The admin screen at `/admin/apis/` shows both systems side by side + +The modern system does not replace or wrap the legacy system. They are +parallel paths. Migration happens per-interface: an app adds a modern +provider alongside its legacy API, then consumers switch at their own pace. + +## Package boundaries + +``` +horde/Rpc Interfaces, value objects, JSON-RPC transport + No dependency on Horde_Registry or horde/Core + +horde/Core ApiRegistry, ApiRegistryFactory, DI bindings + Depends on horde/Rpc for interfaces + +App packages Provider implementations, Api class + Depend on horde/Rpc for interfaces + Autoloaded by horde/Core factory via convention +``` + +`horde/Rpc` is deliberately independent of `horde/Core`. This means +providers and transports can be tested without bootstrapping the full Horde +environment. diff --git a/src/Api/ApiInterfaceListProvider.php b/src/Api/ApiInterfaceListProvider.php new file mode 100644 index 00000000..6983dff3 --- /dev/null +++ b/src/Api/ApiInterfaceListProvider.php @@ -0,0 +1,21 @@ +> + */ + public function getApiInterfaceList(): array; +} diff --git a/src/Api/ApiRegistry.php b/src/Api/ApiRegistry.php new file mode 100644 index 00000000..79f1a51e --- /dev/null +++ b/src/Api/ApiRegistry.php @@ -0,0 +1,168 @@ + */ + private array $providers = []; + + /** @var array> */ + private array $appProviders = []; + + /** + * @param ApiProviderInterface&MethodInvokerInterface $provider + */ + public function registerProvider( + string $interface, + ApiProviderInterface&MethodInvokerInterface $provider, + ?string $app = null, + ): void { + $this->providers[$interface] = $provider; + if ($app !== null) { + $this->appProviders[$app][$interface] = $provider; + } + } + + public function hasMethod(string $method, ?ApiCallContext $context = null): bool + { + $parts = $this->splitMethod($method); + if ($parts === null) { + return false; + } + [$interface, $localMethod] = $parts; + $provider = $this->providers[$interface] ?? null; + if ($provider === null) { + return false; + } + + return $provider->hasMethod($localMethod, $context); + } + + public function getMethodDescriptor(string $method, ?ApiCallContext $context = null): ?MethodDescriptor + { + $parts = $this->splitMethod($method); + if ($parts === null) { + return null; + } + [$interface, $localMethod] = $parts; + $provider = $this->providers[$interface] ?? null; + if ($provider === null) { + return null; + } + $descriptor = $provider->getMethodDescriptor($localMethod, $context); + if ($descriptor === null) { + return null; + } + + return new MethodDescriptor( + name: $interface . '.' . $descriptor->name, + description: $descriptor->description, + parameters: $descriptor->parameters, + returnType: $descriptor->returnType, + permissions: $descriptor->permissions, + ); + } + + /** + * @return list + */ + public function listMethods(?ApiCallContext $context = null): array + { + $all = []; + foreach ($this->providers as $interface => $provider) { + foreach ($provider->listMethods($context) as $descriptor) { + $all[] = new MethodDescriptor( + name: $interface . '.' . $descriptor->name, + description: $descriptor->description, + parameters: $descriptor->parameters, + returnType: $descriptor->returnType, + permissions: $descriptor->permissions, + ); + } + } + + return $all; + } + + public function invoke(string $method, array $params, ?ApiCallContext $context = null): Result + { + $parts = $this->splitMethod($method); + if ($parts === null) { + throw new InvalidArgumentException( + sprintf('Method string must contain a dot separator: "%s"', $method) + ); + } + [$interface, $localMethod] = $parts; + $provider = $this->providers[$interface] ?? null; + if ($provider === null) { + throw new RuntimeException( + sprintf('No provider registered for interface "%s"', $interface) + ); + } + + return $provider->invoke($localMethod, $params, $context); + } + + public function invokeExplicit( + string $app, + string $interface, + string $method, + array $params, + ?ApiCallContext $context = null, + ): Result { + $provider = $this->appProviders[$app][$interface] ?? null; + if ($provider === null) { + throw new RuntimeException( + sprintf('No provider registered for app "%s" interface "%s"', $app, $interface) + ); + } + + return $provider->invoke($method, $params, $context); + } + + /** + * @return list + */ + public function getInterfaces(): array + { + return array_keys($this->providers); + } + + /** + * @return (ApiProviderInterface&MethodInvokerInterface)|null + */ + public function getProviderForInterface(string $interface): ApiProviderInterface|MethodInvokerInterface|null + { + return $this->providers[$interface] ?? null; + } + + /** + * @return array{string, string}|null + */ + private function splitMethod(string $method): ?array + { + $dotPos = strpos($method, '.'); + if ($dotPos === false) { + return null; + } + + return [substr($method, 0, $dotPos), substr($method, $dotPos + 1)]; + } +} diff --git a/src/Api/ApiRegistryCaller.php b/src/Api/ApiRegistryCaller.php new file mode 100644 index 00000000..611df2fb --- /dev/null +++ b/src/Api/ApiRegistryCaller.php @@ -0,0 +1,27 @@ + $args + */ + public function __call(string $method, array $args): mixed + { + return $this->registry->invoke($this->interface . '.' . $method, $args)->value; + } +} diff --git a/src/Api/ApiRegistryCallerDecorator.php b/src/Api/ApiRegistryCallerDecorator.php new file mode 100644 index 00000000..514b8b9b --- /dev/null +++ b/src/Api/ApiRegistryCallerDecorator.php @@ -0,0 +1,23 @@ +registry, $interface); + } +} diff --git a/src/DefaultInjectorBindings.php b/src/DefaultInjectorBindings.php index 19ba8455..7b419245 100644 --- a/src/DefaultInjectorBindings.php +++ b/src/DefaultInjectorBindings.php @@ -16,10 +16,13 @@ namespace Horde\Core; +use Horde\Core\Api\ApiRegistry; use Horde\Core\Config\ConfigMetadataProvider; use Horde\Core\Config\Driver\DriverRepository; use Horde\Core\Editor\TinymcePageBinder; +use Horde\Core\Factory\ApiRegistryFactory; use Horde\Core\Factory\AuthBaseFactory; +use Horde\Core\Factory\AuthIsGlobalAdminFactory; use Horde\Core\Factory\ConfigMetadataProviderFactory; use Horde\Core\Factory\DbAdapterFactory; use Horde\Core\Factory\DriverRepositoryFactory; @@ -61,7 +64,10 @@ use Horde\Core\Factory\SimpleCacheFactory; use Horde\Core\Factory\TinymceFactory; use Horde\Core\Factory\TinymcePageBinderFactory; +use Horde\Core\Middleware\AuthIsGlobalAdmin; use Horde\Core\Middleware\OAuthConsentMiddleware; +use Horde\Core\Uri\RegistryRouteMapperProvider; +use Horde\Core\Uri\RouteMapperProvider; use Horde\Core\Service\OAuthHttpClientService; use Horde\Core\Service\OAuthProviderConfigRepository; use Horde\Core\Service\OAuthTokenService; @@ -98,6 +104,7 @@ use Horde\OAuth\Server\ServerMetadata; use Horde\OAuth\Server\Token\AccessTokenIssuer; use Horde\OAuth\Server\Token\RefreshTokenIssuer; +use Horde\Routes\Mapper as HordeRoutesMapper; use Horde\Secret\SecretManager; use Horde\SessionHandler\SessionHandler; use Horde\Http\RequestFactory; @@ -127,6 +134,7 @@ public function register(Horde_Injector $injector): void ], 'Horde_Core_Auth_Signup' => 'Horde_Core_Factory_AuthSignup', 'Horde_Auth_Base' => AuthBaseFactory::class, + ApiRegistry::class => ApiRegistryFactory::class, 'Horde_Core_CssCache' => 'Horde_Core_Factory_CssCache', 'Horde_Core_JavascriptCache' => 'Horde_Core_Factory_JavascriptCache', 'Horde_Core_Perms' => 'Horde_Core_Factory_PermsCore', @@ -150,7 +158,7 @@ public function register(Horde_Injector $injector): void 'Horde_Perms_Base' => 'Horde_Core_Factory_Perms', 'Horde_Queue_Storage' => 'Horde_Core_Factory_QueueStorage', 'Horde_Routes_Mapper' => 'Horde_Core_Factory_Mapper', - \Horde\Routes\Mapper::class => 'Horde_Core_Factory_Mapper', + HordeRoutesMapper::class => 'Horde_Core_Factory_Mapper', 'Horde_Routes_Matcher' => 'Horde_Core_Factory_Matcher', 'Horde_Secret' => 'Horde_Core_Factory_Secret', 'Horde_Secret_Cbc' => 'Horde_Core_Factory_Secret_Cbc', @@ -188,6 +196,7 @@ public function register(Horde_Injector $injector): void JwksEndpoint::class => OAuthJwksEndpointFactory::class, IdTokenBuilder::class => OAuthIdTokenBuilderFactory::class, OAuthConsentMiddleware::class => OAuthConsentMiddlewareFactory::class, + AuthIsGlobalAdmin::class => AuthIsGlobalAdminFactory::class, 'Horde_Service_Facebook' => 'Horde_Core_Factory_Facebook', 'Horde_Service_Twitter' => 'Horde_Core_Factory_Twitter', 'Horde_Service_UrlShortener' => 'Horde_Core_Factory_UrlShortener', @@ -231,6 +240,7 @@ public function register(Horde_Injector $injector): void $implementations = [ 'Horde_Controller_ResponseWriter' => 'Horde_Controller_ResponseWriter_Web', RequestFactoryInterface::class => RequestFactory::class, + RouteMapperProvider::class => RegistryRouteMapperProvider::class, ]; foreach ($factories as $key => $val) { diff --git a/src/Factory/ApiRegistryFactory.php b/src/Factory/ApiRegistryFactory.php new file mode 100644 index 00000000..69cf708a --- /dev/null +++ b/src/Factory/ApiRegistryFactory.php @@ -0,0 +1,61 @@ +getInstance(RegistryConfigLoader::class); + $state = $registryLoader->load(); + + foreach ($state->listApplications() as $appName) { + $className = 'Horde\\' . ucfirst($appName) . '\\Api'; + + if (!class_exists($className)) { + continue; + } + + if (!is_subclass_of($className, ApiInterfaceListProvider::class)) { + continue; + } + + try { + $appInstance = $injector->getInstance($className); + $interfaces = $appInstance->getApiInterfaceList(); + } catch (Throwable) { + continue; + } + + foreach ($interfaces as $interface => $providerClass) { + try { + $provider = $injector->getInstance($providerClass); + } catch (Throwable) { + continue; + } + if ($provider instanceof ApiProviderInterface && $provider instanceof MethodInvokerInterface) { + $registry->registerProvider($interface, $provider, $appName); + } + } + } + + return $registry; + } +} diff --git a/src/Factory/AuthIsGlobalAdminFactory.php b/src/Factory/AuthIsGlobalAdminFactory.php new file mode 100644 index 00000000..fe360e77 --- /dev/null +++ b/src/Factory/AuthIsGlobalAdminFactory.php @@ -0,0 +1,33 @@ +getInstance(ConfigLoader::class); + $state = $loader->load('horde'); + + $admins = $state->get('auth.admins', []); + + return new AuthIsGlobalAdmin(is_array($admins) ? $admins : []); + } +} diff --git a/src/Uri/RegistryRouteMapperProvider.php b/src/Uri/RegistryRouteMapperProvider.php new file mode 100644 index 00000000..541317fd --- /dev/null +++ b/src/Uri/RegistryRouteMapperProvider.php @@ -0,0 +1,68 @@ + */ + private array $cache = []; + + public function __construct( + private readonly \Horde\Core\Config\RegistryConfigLoader $registryLoader, + ) {} + + public function getMapper(string $app): ?Mapper + { + if (array_key_exists($app, $this->cache)) { + return $this->cache[$app]; + } + + $state = $this->registryLoader->load(); + $appConfig = $state->getApplication($app); + if ($appConfig === null) { + $this->cache[$app] = null; + return null; + } + + $fileroot = $appConfig['fileroot'] ?? null; + if ($fileroot === null || !is_dir($fileroot)) { + $this->cache[$app] = null; + return null; + } + + $routeFile = $fileroot . '/config/routes.php'; + if (!file_exists($routeFile)) { + $this->cache[$app] = null; + return null; + } + + $mapper = new Mapper(); + $webroot = $appConfig['webroot'] ?? '/' . $app; + $mapper->prefix = $webroot; + + include $routeFile; + + $localRouteFile = $fileroot . '/config/routes.local.php'; + if (file_exists($localRouteFile)) { + include $localRouteFile; + } + + $this->cache[$app] = $mapper; + return $mapper; + } +} diff --git a/test/Unit/Api/ApiRegistryCallerTest.php b/test/Unit/Api/ApiRegistryCallerTest.php new file mode 100644 index 00000000..db4c2aac --- /dev/null +++ b/test/Unit/Api/ApiRegistryCallerTest.php @@ -0,0 +1,40 @@ + fn(string $format) => 'ical:' . $format]); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $provider); + + $caller = new ApiRegistryCaller($registry, 'calendar'); + $result = $caller->export('vcal'); + + $this->assertSame('ical:vcal', $result); + } + + public function testDecoratorReturnsCallerForInterface(): void + { + $provider = new StubProvider(['list' => fn() => ['event1']]); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $provider); + + $decorator = new ApiRegistryCallerDecorator($registry); + $result = $decorator->calendar->list(); + + $this->assertSame(['event1'], $result); + } +} diff --git a/test/Unit/Api/ApiRegistryTest.php b/test/Unit/Api/ApiRegistryTest.php new file mode 100644 index 00000000..c932b097 --- /dev/null +++ b/test/Unit/Api/ApiRegistryTest.php @@ -0,0 +1,198 @@ +makeProvider(['list' => fn() => []]); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $provider); + + $this->assertTrue($registry->hasMethod('calendar.list')); + $this->assertFalse($registry->hasMethod('calendar.nope')); + } + + public function testInvokeDelegate(): void + { + $provider = $this->makeProvider(['add' => fn(float $a, float $b) => $a + $b]); + $registry = new ApiRegistry(); + $registry->registerProvider('math', $provider); + + $result = $registry->invoke('math.add', [3.0, 4.0]); + + $this->assertSame(7.0, $result->value); + } + + public function testInvokeExplicit(): void + { + $provider = $this->makeProvider(['list' => fn() => ['task1']]); + $registry = new ApiRegistry(); + $registry->registerProvider('tasks', $provider, 'nag'); + + $result = $registry->invokeExplicit('nag', 'tasks', 'list', []); + + $this->assertSame(['task1'], $result->value); + } + + public function testListMethodsPrefixesWithInterface(): void + { + $provider = $this->makeProvider([ + 'list' => fn() => [], + 'export' => fn() => '', + ]); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $provider); + + $methods = $registry->listMethods(); + $names = array_map(fn(MethodDescriptor $d) => $d->name, $methods); + + $this->assertCount(2, $methods); + $this->assertContains('calendar.list', $names); + $this->assertContains('calendar.export', $names); + } + + public function testGetMethodDescriptorPrefixed(): void + { + $provider = $this->makeProvider( + ['list' => fn() => []], + ['list' => new MethodDescriptor('list', description: 'List items')], + ); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $provider); + + $desc = $registry->getMethodDescriptor('calendar.list'); + + $this->assertNotNull($desc); + $this->assertSame('calendar.list', $desc->name); + $this->assertSame('List items', $desc->description); + } + + public function testUnknownInterfaceHasMethodReturnsFalse(): void + { + $registry = new ApiRegistry(); + + $this->assertFalse($registry->hasMethod('nope.method')); + } + + public function testInvokeMalformedMethodThrows(): void + { + $registry = new ApiRegistry(); + + $this->expectException(InvalidArgumentException::class); + $registry->invoke('nodot', []); + } + + public function testInvokeUnknownInterfaceThrows(): void + { + $registry = new ApiRegistry(); + + $this->expectException(RuntimeException::class); + $registry->invoke('unknown.method', []); + } + + public function testInvokeExplicitUnknownAppThrows(): void + { + $registry = new ApiRegistry(); + + $this->expectException(RuntimeException::class); + $registry->invokeExplicit('badapp', 'tasks', 'list', []); + } + + public function testContextPassedThrough(): void + { + $provider = $this->makeProvider(['list' => fn() => []]); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $provider); + + $context = new ApiCallContext(['protocol' => 'jsonrpc']); + $registry->invoke('calendar.list', [], $context); + + $this->assertSame($context, $provider->lastContext); + } + + public function testMultipleInterfacesAggregated(): void + { + $calProvider = $this->makeProvider(['list' => fn() => []]); + $taskProvider = $this->makeProvider(['list' => fn() => [], 'add' => fn() => null]); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $calProvider); + $registry->registerProvider('tasks', $taskProvider); + + $methods = $registry->listMethods(); + $names = array_map(fn(MethodDescriptor $d) => $d->name, $methods); + + $this->assertCount(3, $methods); + $this->assertContains('calendar.list', $names); + $this->assertContains('tasks.list', $names); + $this->assertContains('tasks.add', $names); + } + + public function testGetInterfaces(): void + { + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $this->makeProvider(['list' => fn() => []])); + $registry->registerProvider('tasks', $this->makeProvider(['list' => fn() => []])); + + $interfaces = $registry->getInterfaces(); + + $this->assertSame(['calendar', 'tasks'], $interfaces); + } + + public function testGetProviderForInterface(): void + { + $provider = $this->makeProvider(['list' => fn() => []]); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $provider); + + $this->assertSame($provider, $registry->getProviderForInterface('calendar')); + $this->assertNull($registry->getProviderForInterface('unknown')); + } + + public function testHasMethodNoDotReturnsFalse(): void + { + $registry = new ApiRegistry(); + + $this->assertFalse($registry->hasMethod('nodot')); + } + + public function testGetMethodDescriptorNoDotReturnsNull(): void + { + $registry = new ApiRegistry(); + + $this->assertNull($registry->getMethodDescriptor('nodot')); + } + + public function testGetMethodDescriptorUnknownInterfaceReturnsNull(): void + { + $registry = new ApiRegistry(); + + $this->assertNull($registry->getMethodDescriptor('unknown.method')); + } + + public function testGetMethodDescriptorUnknownMethodReturnsNull(): void + { + $provider = $this->makeProvider(['list' => fn() => []]); + $registry = new ApiRegistry(); + $registry->registerProvider('calendar', $provider); + + $this->assertNull($registry->getMethodDescriptor('calendar.nope')); + } +} diff --git a/test/Unit/Api/StubProvider.php b/test/Unit/Api/StubProvider.php new file mode 100644 index 00000000..31147bde --- /dev/null +++ b/test/Unit/Api/StubProvider.php @@ -0,0 +1,68 @@ + */ + private array $methods; + + /** @var array */ + private array $descriptors; + + public ?ApiCallContext $lastContext = null; + + /** + * @param array $methods + * @param array $descriptors + */ + public function __construct(array $methods = [], array $descriptors = []) + { + $this->methods = $methods; + $this->descriptors = $descriptors; + foreach ($methods as $name => $callable) { + if (!isset($this->descriptors[$name])) { + $this->descriptors[$name] = new MethodDescriptor($name); + } + } + } + + public function hasMethod(string $method, ?ApiCallContext $context = null): bool + { + $this->lastContext = $context; + + return isset($this->methods[$method]); + } + + public function getMethodDescriptor(string $method, ?ApiCallContext $context = null): ?MethodDescriptor + { + $this->lastContext = $context; + + return $this->descriptors[$method] ?? null; + } + + /** + * @return list + */ + public function listMethods(?ApiCallContext $context = null): array + { + $this->lastContext = $context; + + return array_values($this->descriptors); + } + + public function invoke(string $method, array $params, ?ApiCallContext $context = null): Result + { + $this->lastContext = $context; + + return new Result(($this->methods[$method])(...$params)); + } +}