feat: implement preview configuration
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -13,6 +13,7 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
protected TenantSecurity $security;
|
||||
protected TenantFirewall $firewall;
|
||||
protected TenantStores $stores;
|
||||
protected TenantPreview $preview;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -20,6 +21,7 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
$this->security = new TenantSecurity();
|
||||
$this->firewall = new TenantFirewall();
|
||||
$this->stores = new TenantStores();
|
||||
$this->preview = new TenantPreview();
|
||||
}
|
||||
|
||||
public function authentication(): TenantAuthentication {
|
||||
@@ -38,4 +40,8 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
return $this->stores;
|
||||
}
|
||||
|
||||
public function preview(): TenantPreview {
|
||||
return $this->preview;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
final class TenantPreview extends JsonSerializableObject
|
||||
{
|
||||
protected bool $enabled = true;
|
||||
protected string $store = 'previews';
|
||||
protected TenantPreviewGeneration $generation;
|
||||
protected TenantPreviewVariants $variants;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->generation = new TenantPreviewGeneration();
|
||||
$this->variants = new TenantPreviewVariants();
|
||||
}
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
if (array_key_exists('enabled', $data)) {
|
||||
if (!is_bool($data['enabled'])) {
|
||||
throw new InvalidArgumentException('Preview enabled must be a boolean');
|
||||
}
|
||||
$this->enabled = $data['enabled'];
|
||||
}
|
||||
|
||||
if (array_key_exists('store', $data)) {
|
||||
if (!is_string($data['store']) || preg_match('/^[a-z][a-z0-9-]*$/', $data['store']) !== 1) {
|
||||
throw new InvalidArgumentException('Preview store must be a logical system-store name');
|
||||
}
|
||||
$this->store = $data['store'];
|
||||
}
|
||||
|
||||
if (array_key_exists('generation', $data)) {
|
||||
if (!is_array($data['generation'])) {
|
||||
throw new InvalidArgumentException('Preview generation configuration must be an object');
|
||||
}
|
||||
$this->generation->jsonDeserialize($data['generation']);
|
||||
}
|
||||
|
||||
if (array_key_exists('variants', $data)) {
|
||||
if (!is_array($data['variants'])) {
|
||||
throw new InvalidArgumentException('Preview variants configuration must be an object');
|
||||
}
|
||||
$this->variants->jsonDeserialize($data['variants']);
|
||||
}
|
||||
|
||||
if ($this->enabled && $this->variants->all() === []) {
|
||||
throw new InvalidArgumentException('At least one preview variant is required when previews are enabled');
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function store(): string
|
||||
{
|
||||
return $this->store;
|
||||
}
|
||||
|
||||
public function generation(): TenantPreviewGeneration
|
||||
{
|
||||
return $this->generation;
|
||||
}
|
||||
|
||||
public function variants(): TenantPreviewVariants
|
||||
{
|
||||
return $this->variants;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
final class TenantPreviewGeneration extends JsonSerializableObject
|
||||
{
|
||||
public const MODE_LAZY = 'lazy';
|
||||
public const MODE_EAGER_WITH_LAZY_FALLBACK = 'eager-with-lazy-fallback';
|
||||
|
||||
protected string $mode = self::MODE_EAGER_WITH_LAZY_FALLBACK;
|
||||
protected int $maxSourceSize = 104857600;
|
||||
protected int $timeoutSeconds = 30;
|
||||
protected int $retryDelaySeconds = 3600;
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
if (array_key_exists('mode', $data)) {
|
||||
if (!is_string($data['mode']) || !in_array($data['mode'], [
|
||||
self::MODE_LAZY,
|
||||
self::MODE_EAGER_WITH_LAZY_FALLBACK,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Unsupported preview generation mode');
|
||||
}
|
||||
$this->mode = $data['mode'];
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'maxSourceSize' => 'maximum source size',
|
||||
'timeoutSeconds' => 'timeout',
|
||||
'retryDelaySeconds' => 'retry delay',
|
||||
] as $property => $label) {
|
||||
if (!array_key_exists($property, $data)) {
|
||||
continue;
|
||||
}
|
||||
if (!is_int($data[$property]) || $data[$property] < 1) {
|
||||
throw new InvalidArgumentException("Preview {$label} must be a positive integer");
|
||||
}
|
||||
$this->$property = $data[$property];
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function mode(): string
|
||||
{
|
||||
return $this->mode;
|
||||
}
|
||||
|
||||
public function maxSourceSize(): int
|
||||
{
|
||||
return $this->maxSourceSize;
|
||||
}
|
||||
|
||||
public function timeoutSeconds(): int
|
||||
{
|
||||
return $this->timeoutSeconds;
|
||||
}
|
||||
|
||||
public function retryDelaySeconds(): int
|
||||
{
|
||||
return $this->retryDelaySeconds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Preview\MimeType;
|
||||
|
||||
final readonly class TenantPreviewVariant
|
||||
{
|
||||
public string $format;
|
||||
|
||||
public function __construct(
|
||||
public int $width,
|
||||
public int $height,
|
||||
string $format,
|
||||
public int $quality,
|
||||
) {
|
||||
if ($this->width < 1 || $this->height < 1) {
|
||||
throw new InvalidArgumentException('Preview variant dimensions must be positive');
|
||||
}
|
||||
if ($this->quality < 1 || $this->quality > 100) {
|
||||
throw new InvalidArgumentException('Preview variant quality must be between 1 and 100');
|
||||
}
|
||||
$this->format = MimeType::normalize($format);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data, ?self $defaults = null): self
|
||||
{
|
||||
$width = $data['width'] ?? $defaults?->width;
|
||||
$height = $data['height'] ?? $defaults?->height;
|
||||
$format = $data['format'] ?? $defaults?->format;
|
||||
$quality = $data['quality'] ?? $defaults?->quality;
|
||||
|
||||
if (!is_int($width) || !is_int($height) || !is_string($format) || !is_int($quality)) {
|
||||
throw new InvalidArgumentException('Preview variants require integer dimensions and quality plus a MIME format');
|
||||
}
|
||||
|
||||
return new self($width, $height, $format, $quality);
|
||||
}
|
||||
|
||||
/** @return array{width:int,height:int,format:string,quality:int} */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'format' => $this->format,
|
||||
'quality' => $this->quality,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
final class TenantPreviewVariants extends JsonSerializableObject
|
||||
{
|
||||
/** @var array<string, TenantPreviewVariant> */
|
||||
private array $entries;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->entries = self::defaults();
|
||||
}
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
$defaults = self::defaults();
|
||||
$entries = [];
|
||||
foreach ($data as $name => $variant) {
|
||||
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
|
||||
throw new InvalidArgumentException('Invalid preview variant name');
|
||||
}
|
||||
if (!is_array($variant)) {
|
||||
throw new InvalidArgumentException('Preview variant configuration must be an object');
|
||||
}
|
||||
$entries[$name] = TenantPreviewVariant::fromArray($variant, $defaults[$name] ?? null);
|
||||
}
|
||||
$this->entries = $entries;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function variant(string $name): ?TenantPreviewVariant
|
||||
{
|
||||
return $this->entries[$name] ?? null;
|
||||
}
|
||||
|
||||
/** @return array<string, TenantPreviewVariant> */
|
||||
public function all(): array
|
||||
{
|
||||
return $this->entries;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn(TenantPreviewVariant $variant): array => $variant->toArray(),
|
||||
$this->entries,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, TenantPreviewVariant> */
|
||||
private static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'popover' => new TenantPreviewVariant(640, 440, 'image/webp', 80),
|
||||
'thumbnail' => new TenantPreviewVariant(256, 256, 'image/webp', 75),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Preview;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantPreviewGeneration;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PreviewConfigurationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function providesSafeDefaultsWhenPreviewConfigurationIsAbsent(): void
|
||||
{
|
||||
$preview = (new TenantConfiguration())->preview();
|
||||
|
||||
self::assertTrue($preview->enabled());
|
||||
self::assertSame('previews', $preview->store());
|
||||
self::assertSame(TenantPreviewGeneration::MODE_EAGER_WITH_LAZY_FALLBACK, $preview->generation()->mode());
|
||||
self::assertSame(104857600, $preview->generation()->maxSourceSize());
|
||||
self::assertSame(30, $preview->generation()->timeoutSeconds());
|
||||
self::assertSame(3600, $preview->generation()->retryDelaySeconds());
|
||||
self::assertSame(640, $preview->variants()->variant('popover')?->width);
|
||||
self::assertSame(440, $preview->variants()->variant('popover')?->height);
|
||||
self::assertSame('image/webp', $preview->variants()->variant('popover')?->format);
|
||||
self::assertSame(80, $preview->variants()->variant('popover')?->quality);
|
||||
self::assertSame(256, $preview->variants()->variant('thumbnail')?->width);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function loadsAndSerializesTenantPreviewConfiguration(): void
|
||||
{
|
||||
$data = [
|
||||
'enabled' => true,
|
||||
'store' => 'generated-content',
|
||||
'generation' => [
|
||||
'mode' => TenantPreviewGeneration::MODE_LAZY,
|
||||
'maxSourceSize' => 1024,
|
||||
'timeoutSeconds' => 5,
|
||||
'retryDelaySeconds' => 60,
|
||||
],
|
||||
'variants' => [
|
||||
'popover' => [
|
||||
'width' => 800,
|
||||
'height' => 600,
|
||||
'format' => 'IMAGE/PNG',
|
||||
'quality' => 90,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize(['preview' => $data]);
|
||||
|
||||
self::assertSame('generated-content', $configuration->preview()->store());
|
||||
self::assertSame(TenantPreviewGeneration::MODE_LAZY, $configuration->preview()->generation()->mode());
|
||||
self::assertSame(['popover'], array_keys($configuration->preview()->variants()->all()));
|
||||
self::assertSame('image/png', $configuration->preview()->variants()->variant('popover')?->format);
|
||||
$data['variants']['popover']['format'] = 'image/png';
|
||||
self::assertSame($data, $configuration->jsonSerialize()['preview']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mergesKnownVariantDefaultsForPartialOverrides(): void
|
||||
{
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'preview' => [
|
||||
'variants' => [
|
||||
'popover' => ['width' => 900],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$variant = $configuration->preview()->variants()->variant('popover');
|
||||
self::assertSame(900, $variant?->width);
|
||||
self::assertSame(440, $variant?->height);
|
||||
self::assertSame('image/webp', $variant?->format);
|
||||
self::assertSame(80, $variant?->quality);
|
||||
self::assertNull($configuration->preview()->variants()->variant('thumbnail'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowsNoVariantsWhenPreviewsAreDisabled(): void
|
||||
{
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'preview' => ['enabled' => false, 'variants' => []],
|
||||
]);
|
||||
|
||||
self::assertFalse($configuration->preview()->enabled());
|
||||
self::assertSame([], $configuration->preview()->variants()->all());
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array<string, mixed>}> */
|
||||
public static function invalidConfigurations(): iterable
|
||||
{
|
||||
yield 'invalid store' => [['store' => '../previews']];
|
||||
yield 'unknown generation mode' => [['generation' => ['mode' => 'eager']]];
|
||||
yield 'zero source limit' => [['generation' => ['maxSourceSize' => 0]]];
|
||||
yield 'invalid variant name' => [['variants' => ['../popover' => []]]];
|
||||
yield 'invalid dimensions' => [['variants' => ['popover' => ['width' => 0]]]];
|
||||
yield 'invalid output MIME' => [['variants' => ['popover' => ['format' => 'webp']]]];
|
||||
yield 'invalid quality' => [['variants' => ['popover' => ['quality' => 101]]]];
|
||||
yield 'enabled without variants' => [['enabled' => true, 'variants' => []]];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('invalidConfigurations')]
|
||||
public function rejectsInvalidConfiguration(array $data): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
(new TenantConfiguration())->jsonDeserialize(['preview' => $data]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user