From a1413db12f40935ad898fe83dc0718c94cee1b3c Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 28 Aug 2026 20:39:00 -0400 Subject: [PATCH] feat(core): add tenant system store contracts and configuration Signed-off-by: Sebastian Krupinski --- .../lib/Models/Tenant/TenantConfiguration.php | 6 ++ core/lib/Models/Tenant/TenantStores.php | 57 ++++++++++++++++ shared/lib/SystemStore/StoreReference.php | 53 +++++++++++++++ .../SystemStoreManagerInterface.php | 38 +++++++++++ .../SystemStore/SystemStoreContractsTest.php | 65 ++++++++++++++++--- 5 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 core/lib/Models/Tenant/TenantStores.php create mode 100644 shared/lib/SystemStore/StoreReference.php create mode 100644 shared/lib/SystemStore/SystemStoreManagerInterface.php diff --git a/core/lib/Models/Tenant/TenantConfiguration.php b/core/lib/Models/Tenant/TenantConfiguration.php index 649e40e..b3f76a4 100644 --- a/core/lib/Models/Tenant/TenantConfiguration.php +++ b/core/lib/Models/Tenant/TenantConfiguration.php @@ -12,12 +12,14 @@ class TenantConfiguration extends JsonSerializableObject protected TenantAuthentication $authentication; protected TenantSecurity $security; protected TenantFirewall $firewall; + protected TenantStores $stores; public function __construct() { $this->authentication = new TenantAuthentication(); $this->security = new TenantSecurity(); $this->firewall = new TenantFirewall(); + $this->stores = new TenantStores(); } public function authentication(): TenantAuthentication { @@ -32,4 +34,8 @@ class TenantConfiguration extends JsonSerializableObject return $this->firewall; } + public function stores(): TenantStores { + return $this->stores; + } + } diff --git a/core/lib/Models/Tenant/TenantStores.php b/core/lib/Models/Tenant/TenantStores.php new file mode 100644 index 0000000..9d94faa --- /dev/null +++ b/core/lib/Models/Tenant/TenantStores.php @@ -0,0 +1,57 @@ + */ + private array $entries = []; + + public function jsonDeserialize(array|string $data): static + { + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->entries = []; + foreach ($data as $name => $store) { + if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) { + throw new InvalidArgumentException('Invalid logical system-store name'); + } + if (!is_array($store)) { + throw new InvalidArgumentException('Invalid tenant store configuration entry'); + } + $this->entries[$name] = StoreReference::fromArray($store); + } + + return $this; + } + + public function jsonSerialize(): array + { + return array_map( + static fn(StoreReference $store): array => $store->toArray(), + $this->entries, + ); + } + + public function store(string $name): ?StoreReference + { + return $this->entries[$name] ?? null; + } + + /** @return array */ + public function all(): array + { + return $this->entries; + } +} diff --git a/shared/lib/SystemStore/StoreReference.php b/shared/lib/SystemStore/StoreReference.php new file mode 100644 index 0000000..4dd9faf --- /dev/null +++ b/shared/lib/SystemStore/StoreReference.php @@ -0,0 +1,53 @@ +provider === '') { + throw new InvalidArgumentException('System-store provider cannot be empty'); + } + + if ($this->service === '') { + throw new InvalidArgumentException('System-store service cannot be empty'); + } + + if ($this->namespace === '') { + throw new InvalidArgumentException('System-store namespace cannot be empty'); + } + } + + public static function fromArray(array $data): self + { + $provider = $data['provider'] ?? null; + $service = $data['service'] ?? null; + $namespace = $data['namespace'] ?? null; + + if (!is_string($provider) || (!is_string($service) && !is_int($service)) || !is_string($namespace)) { + throw new InvalidArgumentException('Invalid system-store reference'); + } + + return new self($provider, $service, $namespace); + } + + public function toArray(): array + { + return [ + 'provider' => $this->provider, + 'service' => $this->service, + 'namespace' => $this->namespace, + ]; + } +} diff --git a/shared/lib/SystemStore/SystemStoreManagerInterface.php b/shared/lib/SystemStore/SystemStoreManagerInterface.php new file mode 100644 index 0000000..fbcb799 --- /dev/null +++ b/shared/lib/SystemStore/SystemStoreManagerInterface.php @@ -0,0 +1,38 @@ + + */ + public function list(string $tenantId, string $store, string $prefix = ''): iterable; +} diff --git a/tests/php/Unit/SystemStore/SystemStoreContractsTest.php b/tests/php/Unit/SystemStore/SystemStoreContractsTest.php index 16655a1..6443587 100644 --- a/tests/php/Unit/SystemStore/SystemStoreContractsTest.php +++ b/tests/php/Unit/SystemStore/SystemStoreContractsTest.php @@ -6,12 +6,15 @@ namespace KTXT\Unit\SystemStore; use DateTimeImmutable; use InvalidArgumentException; +use KTXC\Models\Tenant\TenantConfiguration; use KTXF\Resource\Provider\ProviderInterface; use KTXF\Resource\Provider\ResourceProviderBaseInterface; use KTXF\Resource\Provider\ResourceServiceBaseInterface; use KTXF\SystemStore\BlobInfo; use KTXF\SystemStore\Provider\ProviderBaseInterface; use KTXF\SystemStore\Service\SystemStoreServiceInterface; +use KTXF\SystemStore\StoreReference; +use KTXF\SystemStore\SystemStoreManagerInterface; use KTXF\SystemStore\WriteCondition; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -41,6 +44,17 @@ final class SystemStoreContractsTest extends TestCase self::assertCount(1, $method->getParameters()); } + #[Test] + public function managerContractExposesTenantScopedStoreOperations(): void + { + $manager = new ReflectionClass(SystemStoreManagerInterface::class); + + self::assertSame( + ['stat', 'read', 'write', 'delete', 'list'], + array_map(static fn($method): string => $method->getName(), $manager->getMethods()), + ); + } + #[Test] public function createsSupportedWriteConditions(): void { @@ -60,7 +74,7 @@ final class SystemStoreContractsTest extends TestCase } #[Test] - public function serializesMetadata(): void + public function exposesBlobInformation(): void { $metadata = new BlobInfo( key: 'previews/example.webp', @@ -71,13 +85,46 @@ final class SystemStoreContractsTest extends TestCase attributes: ['variant' => 'popover'], ); - self::assertSame([ - 'key' => 'previews/example.webp', - 'mime' => 'image/webp', - 'size' => 123, - 'etag' => 'revision-1', - 'modifiedAt' => '2026-08-28T12:00:00+00:00', - 'attributes' => ['variant' => 'popover'], - ], $metadata->jsonSerialize()); + self::assertSame('previews/example.webp', $metadata->key); + self::assertSame('image/webp', $metadata->mimeType); + self::assertSame(123, $metadata->size); + self::assertSame('revision-1', $metadata->etag); + self::assertSame(['variant' => 'popover'], $metadata->attributes); + self::assertNotInstanceOf(\JsonSerializable::class, $metadata); + } + + #[Test] + public function parsesLogicalStoresFromTenantConfiguration(): void + { + $data = [ + 'stores' => [ + 'previews' => [ + 'provider' => 's3', + 'service' => 'preview-storage', + 'namespace' => 'previews', + ], + ], + ]; + + $configuration = (new TenantConfiguration())->jsonDeserialize($data); + + self::assertInstanceOf(StoreReference::class, $configuration->stores()->store('previews')); + self::assertSame('s3', $configuration->stores()->store('previews')?->provider); + self::assertSame($data['stores'], $configuration->jsonSerialize()['stores']); + } + + #[Test] + public function rejectsInvalidLogicalStoreNames(): void + { + $this->expectException(InvalidArgumentException::class); + (new TenantConfiguration())->jsonDeserialize([ + 'stores' => [ + '../previews' => [ + 'provider' => 's3', + 'service' => 'preview-storage', + 'namespace' => 'previews', + ], + ], + ]); } }