From d268381703e95c34ee4da55c1397ec099b866909 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 27 Apr 2026 22:07:13 +0200 Subject: [PATCH 1/2] feat: Add a modern-style RPC/InterApp API --- src/Api.php | 20 +++ src/Api/TaggerAdminProvider.php | 125 ++++++++++++++ src/Api/TaggerProvider.php | 201 ++++++++++++++++++++++ test/unit/Api/TaggerAdminProviderTest.php | 193 +++++++++++++++++++++ test/unit/Api/TaggerProviderTest.php | 145 ++++++++++++++++ 5 files changed, 684 insertions(+) create mode 100644 src/Api.php create mode 100644 src/Api/TaggerAdminProvider.php create mode 100644 src/Api/TaggerProvider.php create mode 100644 test/unit/Api/TaggerAdminProviderTest.php create mode 100644 test/unit/Api/TaggerProviderTest.php diff --git a/src/Api.php b/src/Api.php new file mode 100644 index 0000000..6b21b10 --- /dev/null +++ b/src/Api.php @@ -0,0 +1,20 @@ + TaggerProvider::class, + 'taggerAdmin' => TaggerAdminProvider::class, + ]; + } +} diff --git a/src/Api/TaggerAdminProvider.php b/src/Api/TaggerAdminProvider.php new file mode 100644 index 0000000..0ae9361 --- /dev/null +++ b/src/Api/TaggerAdminProvider.php @@ -0,0 +1,125 @@ + */ + 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 + { + if (!$this->isAdmin($context)) { + return false; + } + + return isset($this->descriptors[$method]); + } + + public function getMethodDescriptor(string $method, ?ApiCallContext $context = null): ?MethodDescriptor + { + if (!$this->isAdmin($context)) { + return null; + } + + return $this->descriptors[$method] ?? null; + } + + /** @return list */ + public function listMethods(?ApiCallContext $context = null): array + { + if (!$this->isAdmin($context)) { + return []; + } + + return array_values($this->descriptors); + } + + public function invoke(string $method, array $params, ?ApiCallContext $context = null): Result + { + if (!$this->isAdmin($context)) { + throw new RuntimeException('Unauthorized: admin permission required'); + } + + if (!isset($this->descriptors[$method])) { + throw new RuntimeException(sprintf('Unknown method "%s"', $method)); + } + + return match ($method) { + 'listTagsByUser' => $this->listTagsByUser($params[0]), + 'listTaggedObjectsByUser' => $this->listTaggedObjectsByUser($params[0]), + default => throw new RuntimeException(sprintf('Unknown method "%s"', $method)), + }; + } + + private function isAdmin(?ApiCallContext $context): bool + { + $perms = $context?->getAttribute('permissions', []); + + return is_array($perms) && in_array('admin', $perms, true); + } + + private function listTagsByUser(mixed $userId): Result + { + return new Result($this->tagger->getTags(['userId' => $userId])); + } + + private function listTaggedObjectsByUser(mixed $userId): Result + { + $tags = $this->tagger->getTags(['userId' => $userId]); + if (empty($tags)) { + return new Result([]); + } + + return new Result( + $this->tagger->getObjects(['tagId' => array_keys($tags), 'userId' => $userId]) + ); + } + + /** @return array */ + private function buildDescriptors(): array + { + return [ + 'listTagsByUser' => new MethodDescriptor( + name: 'listTagsByUser', + description: 'List all tags applied by a specific user', + parameters: [ + ['name' => 'userId', 'type' => 'mixed', 'required' => true, 'description' => 'User identifier'], + ], + returnType: 'array', + permissions: ['admin'], + ), + 'listTaggedObjectsByUser' => new MethodDescriptor( + name: 'listTaggedObjectsByUser', + description: 'List all objects tagged by a specific user', + parameters: [ + ['name' => 'userId', 'type' => 'mixed', 'required' => true, 'description' => 'User identifier'], + ], + returnType: 'array', + permissions: ['admin'], + ), + ]; + } +} diff --git a/src/Api/TaggerProvider.php b/src/Api/TaggerProvider.php new file mode 100644 index 0000000..a1784de --- /dev/null +++ b/src/Api/TaggerProvider.php @@ -0,0 +1,201 @@ + */ + 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; + } + + /** @return list */ + public function listMethods(?ApiCallContext $context = null): array + { + return array_values($this->descriptors); + } + + public function invoke(string $method, array $params, ?ApiCallContext $context = null): Result + { + if (!isset($this->descriptors[$method])) { + throw new RuntimeException(sprintf('Unknown method "%s"', $method)); + } + + return match ($method) { + 'tag' => new Result($this->tagger->tag($params[0], $params[1], $params[2], $params[3] ?? null)), + 'untag' => new Result($this->tagger->untag($params[0], $params[1], $params[2])), + 'removeTagFromObject' => new Result($this->tagger->removeTagFromObject($params[0], $params[1])), + 'getTags' => new Result($this->tagger->getTags($params[0])), + 'getTagsByObjects' => new Result($this->tagger->getTagsByObjects($params[0], $params[1])), + 'getTagCloud' => new Result($this->tagger->getTagCloud($params[0] ?? [])), + 'getRecentTags' => new Result($this->tagger->getRecentTags($params[0] ?? [])), + 'getObjects' => new Result($this->tagger->getObjects($params[0])), + 'getSimilarObjects' => new Result($this->tagger->getSimilarObjects($params[0], $params[1] ?? [])), + 'getRecentObjects' => new Result($this->tagger->getRecentObjects($params[0] ?? [])), + 'ensureTags' => new Result($this->tagger->ensureTags($params[0])), + 'getTagIds' => new Result($this->tagger->getTagIds($params[0])), + 'splitTags' => new Result($this->tagger->splitTags($params[0])), + 'browseTags' => new Result($this->tagger->browseTags($params[0], $params[1], $params[2])), + default => throw new RuntimeException(sprintf('Unknown method "%s"', $method)), + }; + } + + /** @return array */ + private function buildDescriptors(): array + { + return [ + 'tag' => new MethodDescriptor( + name: 'tag', + description: 'Add tags to an object', + parameters: [ + ['name' => 'userId', 'type' => 'mixed', 'required' => true, 'description' => 'User applying the tags'], + ['name' => 'objectId', 'type' => 'mixed', 'required' => true, 'description' => 'Object ID or {object, type} array'], + ['name' => 'tags', 'type' => 'array', 'required' => true, 'description' => 'Tag names or IDs'], + ['name' => 'created', 'type' => 'string', 'required' => false, 'description' => 'Timestamp of tagging operation'], + ], + returnType: 'void', + ), + 'untag' => new MethodDescriptor( + name: 'untag', + description: 'Remove a user\'s tags from an object', + parameters: [ + ['name' => 'userId', 'type' => 'mixed', 'required' => true], + ['name' => 'objectId', 'type' => 'mixed', 'required' => true], + ['name' => 'tags', 'type' => 'array', 'required' => true], + ], + returnType: 'void', + ), + 'removeTagFromObject' => new MethodDescriptor( + name: 'removeTagFromObject', + description: 'Remove all user tags for a specific tag on an object', + parameters: [ + ['name' => 'objectId', 'type' => 'mixed', 'required' => true], + ['name' => 'tags', 'type' => 'array', 'required' => true], + ], + returnType: 'void', + ), + 'getTags' => new MethodDescriptor( + name: 'getTags', + description: 'Search and list tags with optional filters', + parameters: [ + ['name' => 'args', 'type' => 'array', 'required' => true, 'description' => 'Search criteria: q, limit, offset, userId, typeId, objectId'], + ], + returnType: 'array', + ), + 'getTagsByObjects' => new MethodDescriptor( + name: 'getTagsByObjects', + description: 'Get tags for a set of objects', + parameters: [ + ['name' => 'objects', 'type' => 'array', 'required' => true, 'description' => 'Object identifiers'], + ['name' => 'type', 'type' => 'mixed', 'required' => true, 'description' => 'Object type name or ID'], + ], + returnType: 'array', + ), + 'getTagCloud' => new MethodDescriptor( + name: 'getTagCloud', + description: 'Get tag cloud with usage counts', + parameters: [ + ['name' => 'args', 'type' => 'array', 'required' => false, 'description' => 'Filters: typeId, limit, userId'], + ], + returnType: 'array', + ), + 'getRecentTags' => new MethodDescriptor( + name: 'getRecentTags', + description: 'Get most recently used tags', + parameters: [ + ['name' => 'args', 'type' => 'array', 'required' => false, 'description' => 'Filters: limit, offset, typeId, userId'], + ], + returnType: 'array', + ), + 'getObjects' => new MethodDescriptor( + name: 'getObjects', + description: 'Find objects matching tag criteria', + parameters: [ + ['name' => 'args', 'type' => 'array', 'required' => true, 'description' => 'Criteria: tagId, notTagId, typeId, objectId, userId, limit, offset'], + ], + returnType: 'array', + ), + 'getSimilarObjects' => new MethodDescriptor( + name: 'getSimilarObjects', + description: 'Find objects related to a given object via shared tags', + parameters: [ + ['name' => 'objectId', 'type' => 'mixed', 'required' => true], + ['name' => 'args', 'type' => 'array', 'required' => false, 'description' => 'Filters: typeId, limit'], + ], + returnType: 'array', + ), + 'getRecentObjects' => new MethodDescriptor( + name: 'getRecentObjects', + description: 'Get most recently tagged objects', + parameters: [ + ['name' => 'args', 'type' => 'array', 'required' => false, 'description' => 'Filters: typeId, limit'], + ], + returnType: 'array', + ), + 'ensureTags' => new MethodDescriptor( + name: 'ensureTags', + description: 'Ensure tags exist, creating them if needed', + parameters: [ + ['name' => 'tags', 'type' => 'array', 'required' => true, 'description' => 'Tag names or IDs'], + ], + returnType: 'array', + ), + 'getTagIds' => new MethodDescriptor( + name: 'getTagIds', + description: 'Get tag IDs for tag names without creating', + parameters: [ + ['name' => 'tags', 'type' => 'array', 'required' => true], + ], + returnType: 'array', + ), + 'splitTags' => new MethodDescriptor( + name: 'splitTags', + description: 'Parse a CSV-like tag string into individual tags', + parameters: [ + ['name' => 'text', 'type' => 'string', 'required' => true], + ], + returnType: 'array', + ), + 'browseTags' => new MethodDescriptor( + name: 'browseTags', + description: 'Get related tags for given tag IDs', + parameters: [ + ['name' => 'ids', 'type' => 'array', 'required' => true, 'description' => 'Tag IDs to find related tags for'], + ['name' => 'objectType', 'type' => 'mixed', 'required' => true, 'description' => 'Object type filter'], + ['name' => 'user', 'type' => 'mixed', 'required' => true, 'description' => 'User filter'], + ], + returnType: 'array', + ), + ]; + } +} diff --git a/test/unit/Api/TaggerAdminProviderTest.php b/test/unit/Api/TaggerAdminProviderTest.php new file mode 100644 index 0000000..e674ac4 --- /dev/null +++ b/test/unit/Api/TaggerAdminProviderTest.php @@ -0,0 +1,193 @@ +createMock(Content_Tagger::class)); + } + + private function adminContext(): ApiCallContext + { + return new ApiCallContext(['permissions' => ['admin']]); + } + + private function userContext(): ApiCallContext + { + return new ApiCallContext(['permissions' => ['user']]); + } + + // --- Visibility gating: no context --- + + public function testHasMethodReturnsFalseWithoutContext(): void + { + $provider = $this->makeProvider(); + + $this->assertFalse($provider->hasMethod('listTagsByUser')); + $this->assertFalse($provider->hasMethod('listTaggedObjectsByUser')); + } + + public function testHasMethodReturnsFalseWithNonAdminContext(): void + { + $provider = $this->makeProvider(); + + $this->assertFalse($provider->hasMethod('listTagsByUser', $this->userContext())); + } + + public function testHasMethodReturnsTrueWithAdminContext(): void + { + $provider = $this->makeProvider(); + + $this->assertTrue($provider->hasMethod('listTagsByUser', $this->adminContext())); + $this->assertTrue($provider->hasMethod('listTaggedObjectsByUser', $this->adminContext())); + } + + public function testHasMethodReturnsFalseForUnknownEvenWithAdmin(): void + { + $provider = $this->makeProvider(); + + $this->assertFalse($provider->hasMethod('nonexistent', $this->adminContext())); + } + + // --- listMethods visibility --- + + public function testListMethodsEmptyWithoutAdmin(): void + { + $provider = $this->makeProvider(); + + $this->assertSame([], $provider->listMethods()); + $this->assertSame([], $provider->listMethods($this->userContext())); + } + + public function testListMethodsReturnsMethodsWithAdmin(): void + { + $provider = $this->makeProvider(); + $methods = $provider->listMethods($this->adminContext()); + + $this->assertCount(2, $methods); + $names = array_map(fn(MethodDescriptor $d) => $d->name, $methods); + $this->assertContains('listTagsByUser', $names); + $this->assertContains('listTaggedObjectsByUser', $names); + } + + // --- getMethodDescriptor visibility --- + + public function testGetMethodDescriptorNullWithoutAdmin(): void + { + $provider = $this->makeProvider(); + + $this->assertNull($provider->getMethodDescriptor('listTagsByUser')); + $this->assertNull($provider->getMethodDescriptor('listTagsByUser', $this->userContext())); + } + + public function testGetMethodDescriptorReturnsWithAdmin(): void + { + $provider = $this->makeProvider(); + $desc = $provider->getMethodDescriptor('listTagsByUser', $this->adminContext()); + + $this->assertNotNull($desc); + $this->assertSame('listTagsByUser', $desc->name); + } + + // --- invoke gating --- + + public function testInvokeThrowsWithoutAdmin(): void + { + $provider = $this->makeProvider(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unauthorized'); + $provider->invoke('listTagsByUser', ['user1']); + } + + public function testInvokeThrowsWithNonAdminContext(): void + { + $provider = $this->makeProvider(); + + $this->expectException(RuntimeException::class); + $provider->invoke('listTagsByUser', ['user1'], $this->userContext()); + } + + // --- Delegation --- + + public function testInvokeListTagsByUserDelegates(): void + { + $tagger = $this->createMock(Content_Tagger::class); + $tagger->expects($this->once()) + ->method('getTags') + ->with(['userId' => 'user1']) + ->willReturn([1 => 'php', 2 => 'horde']); + + $provider = new TaggerAdminProvider($tagger); + $result = $provider->invoke('listTagsByUser', ['user1'], $this->adminContext()); + + $this->assertSame([1 => 'php', 2 => 'horde'], $result->value); + } + + public function testInvokeListTaggedObjectsByUserDelegates(): void + { + $tagger = $this->createMock(Content_Tagger::class); + $tagger->expects($this->once()) + ->method('getTags') + ->with(['userId' => 'user1']) + ->willReturn([1 => 'php', 2 => 'horde']); + $tagger->expects($this->once()) + ->method('getObjects') + ->with(['tagId' => [1, 2], 'userId' => 'user1']) + ->willReturn([10 => 'obj-a', 20 => 'obj-b']); + + $provider = new TaggerAdminProvider($tagger); + $result = $provider->invoke('listTaggedObjectsByUser', ['user1'], $this->adminContext()); + + $this->assertSame([10 => 'obj-a', 20 => 'obj-b'], $result->value); + } + + public function testInvokeListTaggedObjectsByUserReturnsEmptyWhenNoTags(): void + { + $tagger = $this->createMock(Content_Tagger::class); + $tagger->expects($this->once()) + ->method('getTags') + ->with(['userId' => 'user1']) + ->willReturn([]); + $tagger->expects($this->never()) + ->method('getObjects'); + + $provider = new TaggerAdminProvider($tagger); + $result = $provider->invoke('listTaggedObjectsByUser', ['user1'], $this->adminContext()); + + $this->assertSame([], $result->value); + } + + // --- Descriptors carry admin permission --- + + public function testDescriptorsHaveAdminPermission(): void + { + $provider = $this->makeProvider(); + $methods = $provider->listMethods($this->adminContext()); + + foreach ($methods as $desc) { + $this->assertSame(['admin'], $desc->permissions, "Method {$desc->name} should require admin"); + } + } + + public function testInvokeUnknownMethodThrowsWithAdmin(): void + { + $provider = $this->makeProvider(); + + $this->expectException(RuntimeException::class); + $provider->invoke('nonexistent', [], $this->adminContext()); + } +} diff --git a/test/unit/Api/TaggerProviderTest.php b/test/unit/Api/TaggerProviderTest.php new file mode 100644 index 0000000..5366305 --- /dev/null +++ b/test/unit/Api/TaggerProviderTest.php @@ -0,0 +1,145 @@ +createMock(Content_Tagger::class)); + } + + public function testHasMethodForKnownMethods(): void + { + $provider = $this->makeProvider(); + $known = [ + 'tag', 'untag', 'removeTagFromObject', 'getTags', 'getTagsByObjects', + 'getTagCloud', 'getRecentTags', 'getObjects', 'getSimilarObjects', + 'getRecentObjects', 'ensureTags', 'getTagIds', 'splitTags', 'browseTags', + ]; + + foreach ($known as $method) { + $this->assertTrue($provider->hasMethod($method), "Expected hasMethod('$method') to be true"); + } + } + + public function testHasMethodReturnsFalseForUnknown(): void + { + $provider = $this->makeProvider(); + + $this->assertFalse($provider->hasMethod('nonexistent')); + } + + public function testInvokeTagDelegatesToTagger(): void + { + $tagger = $this->createMock(Content_Tagger::class); + $tagger->expects($this->once()) + ->method('tag') + ->with('user1', ['type' => 'event', 'object' => 'evt1'], ['work']); + + $provider = new TaggerProvider($tagger); + $provider->invoke('tag', ['user1', ['type' => 'event', 'object' => 'evt1'], ['work']]); + } + + public function testInvokeGetTagsDelegatesToTagger(): void + { + $tagger = $this->createMock(Content_Tagger::class); + $tagger->expects($this->once()) + ->method('getTags') + ->with(['q' => 'per', 'limit' => 10]) + ->willReturn([1 => 'personal', 2 => 'performance']); + + $provider = new TaggerProvider($tagger); + $result = $provider->invoke('getTags', [['q' => 'per', 'limit' => 10]]); + + $this->assertSame([1 => 'personal', 2 => 'performance'], $result->value); + } + + public function testInvokeGetTagCloudDelegatesToTagger(): void + { + $tagger = $this->createMock(Content_Tagger::class); + $tagger->expects($this->once()) + ->method('getTagCloud') + ->with(['limit' => 5]) + ->willReturn([['tag_id' => 1, 'tag_name' => 'php', 'count' => 42]]); + + $provider = new TaggerProvider($tagger); + $result = $provider->invoke('getTagCloud', [['limit' => 5]]); + + $this->assertSame([['tag_id' => 1, 'tag_name' => 'php', 'count' => 42]], $result->value); + } + + public function testInvokeSplitTagsDelegatesToTagger(): void + { + $tagger = $this->createMock(Content_Tagger::class); + $tagger->expects($this->once()) + ->method('splitTags') + ->with('php, horde, "web dev"') + ->willReturn(['php', 'horde', 'web dev']); + + $provider = new TaggerProvider($tagger); + $result = $provider->invoke('splitTags', ['php, horde, "web dev"']); + + $this->assertSame(['php', 'horde', 'web dev'], $result->value); + } + + public function testInvokeUnknownMethodThrows(): void + { + $provider = $this->makeProvider(); + + $this->expectException(\RuntimeException::class); + $provider->invoke('nonexistent', []); + } + + public function testListMethodsReturnsAllDescriptors(): void + { + $provider = $this->makeProvider(); + $methods = $provider->listMethods(); + + $this->assertCount(14, $methods); + $names = array_map(fn(MethodDescriptor $d) => $d->name, $methods); + $this->assertContains('tag', $names); + $this->assertContains('getTags', $names); + $this->assertContains('browseTags', $names); + } + + public function testGetMethodDescriptorReturnsMetadata(): void + { + $provider = $this->makeProvider(); + $desc = $provider->getMethodDescriptor('tag'); + + $this->assertNotNull($desc); + $this->assertSame('tag', $desc->name); + $this->assertSame('Add tags to an object', $desc->description); + $this->assertSame('void', $desc->returnType); + $this->assertCount(4, $desc->parameters); + $this->assertSame('userId', $desc->parameters[0]['name']); + } + + public function testGetMethodDescriptorReturnsNullForUnknown(): void + { + $provider = $this->makeProvider(); + + $this->assertNull($provider->getMethodDescriptor('nope')); + } + + public function testContextIsIgnored(): void + { + $provider = $this->makeProvider(); + $context = new ApiCallContext(['permissions' => ['admin']]); + + $this->assertTrue($provider->hasMethod('tag', $context)); + $this->assertTrue($provider->hasMethod('tag', null)); + $this->assertTrue($provider->hasMethod('tag')); + } +} From 7403d1a0101331246fcadecda7b7cfaaf1693845 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 27 Apr 2026 22:07:52 +0200 Subject: [PATCH 2/2] chore(hordeyml): Add direct dependency on horde/rpc --- .horde.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.horde.yml b/.horde.yml index 838e925..70c3f41 100644 --- a/.horde.yml +++ b/.horde.yml @@ -37,6 +37,7 @@ dependencies: horde/db: ^3 horde/injector: ^3 horde/rdo: ^3 + horde/rpc: ^3 horde/util: ^3 ext: gettext: '*'