release 3.0.0

This commit is contained in:
Jérémy Munsch 2026-11-04 17:27:09 +01:00
commit 516350ecf6
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
12 changed files with 408 additions and 21 deletions

View file

@ -6,12 +6,26 @@ All notable changes to this project will be documented in this file.
- This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
- Commits respect [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) & use [Gitmoji](https://gitmoji.dev/).
## [1.0.3] - 2026-11-04
### 🚀 Features
- *(StorageServiceConfig)* Support scoped driver with boot-time underlying driver resolution
### 🎨 Styling
- *(tests)* Fix spacing in docblock for allowedExtensions
## [1.0.2] - 2026-11-04
### 🚀 Features
- *(StorageServiceConfig)* Add prefix handling and validation
### ⚙️ Miscellaneous Tasks
- *(cliff.toml)* Add spacing before release headings in changelog
## [1.0.1] - 2026-11-04
### 🚀 Features

View file

@ -2,7 +2,7 @@
"name": "uwil/storage-laravel",
"description": "Handles file storage",
"license": "MIT",
"version": "1.0.2",
"version": "1.0.3",
"readme": "https://lab.s-web.fr/ldc-web/projets/storage-laravel/-/blob/main/README.md",
"support": {
"email": "digital@ldc.fr",

View file

@ -19,6 +19,9 @@ final class StorageServiceConfig
private ?string $prefix = null;
/** @var array<string, string> Laravel disk name => physical driver (scoped disks resolved to their parent) */
private array $realDrivers = [];
/**
* @param string[] $eligibleDisks
* @param object{'disk':string,'driver':string}[] $laravelExistingDisks
@ -140,6 +143,15 @@ final class StorageServiceConfig
$disk->disk,
$disk->driver
), $intersectWithDrivers);
$this->resolveRealDrivers();
}
private function resolveRealDrivers(): void
{
foreach ([$this->default, ...$this->eligibles] as $disk) {
$this->realDrivers[$disk->value] = Utils::resolveDriver($disk->value, $disk->driver);
}
}
public function getPrefix(): ?string
@ -191,4 +203,15 @@ final class StorageServiceConfig
{
return $this->allowedExtensions;
}
public function getRealDriver(DiskName $disk): string
{
return $this->realDrivers[$disk->value]
?? throw new UnexpectedStorageException("Disk {$disk->value} is neither the default nor an eligible disk");
}
public function isS3Driver(DiskName $disk): bool
{
return $this->getRealDriver($disk) === 's3';
}
}

View file

@ -7,6 +7,7 @@ namespace Uwil\Storage\Lib;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use Psr\Log\LoggerInterface;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Contracts\PathGenerator;
use Uwil\Storage\Contracts\StorageGateway;
use Uwil\Storage\Exceptions\StorageException;
@ -21,6 +22,7 @@ use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
readonly class StorageGatewayImpl implements StorageGateway
{
public function __construct(
private StorageServiceConfig $config,
private LoggerInterface $logger,
private DateHashPathGenerator $datePathGenerator,
private UuidPathGenerator $uuidPathGenerator
@ -123,12 +125,12 @@ readonly class StorageGatewayImpl implements StorageGateway
private function pathGeneratorFor(DiskName $disk): PathGenerator
{
return match ($disk->driver) {
'local' => $this->datePathGenerator,
'ftp' => $this->datePathGenerator,
'sftp' => $this->datePathGenerator,
's3' => $this->uuidPathGenerator,
default => throw new UnexpectedStorageException("Unsupported disk driver: {$disk->driver} on disk {$disk->value}"),
$realDriver = $this->config->getRealDriver($disk);
return match ($realDriver) {
'local', 'ftp', 'sftp' => $this->datePathGenerator,
's3' => $this->uuidPathGenerator,
default => throw new UnexpectedStorageException("Unsupported disk driver: {$realDriver} on disk {$disk->value}"),
};
}

View file

@ -4,8 +4,10 @@ declare(strict_types=1);
namespace Uwil\Storage\Lib;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
class Utils
{
@ -14,6 +16,45 @@ class Utils
// Abstract class.
}
public static function resolveDriver(string $disk, ?string $driver = null): string
{
$currentDisk = $disk;
if ($driver === null) {
/** @var mixed $raw */
$raw = Config::get("filesystems.disks.{$currentDisk}.driver");
$driver = is_string($raw) ? $raw : '';
}
$visited = [$currentDisk];
while ($driver === 'scoped') {
/** @var mixed $parentDisk */
$parentDisk = Config::get("filesystems.disks.{$currentDisk}.disk");
if (! is_string($parentDisk) || $parentDisk === '') {
throw new UnexpectedStorageException("Scoped disk '{$currentDisk}' is missing a valid parent 'disk' configuration.");
}
if (in_array($parentDisk, $visited, true)) {
throw new UnexpectedStorageException("Circular reference detected in scoped disk '{$currentDisk}'.");
}
/** @var mixed $parentDriver */
$parentDriver = Config::get("filesystems.disks.{$parentDisk}.driver");
if (! is_string($parentDriver) || $parentDriver === '') {
throw new UnexpectedStorageException("Parent disk '{$parentDisk}' of scoped disk '{$currentDisk}' has no valid driver configured.");
}
$visited[] = $parentDisk;
$driver = $parentDriver;
$currentDisk = $parentDisk;
}
return $driver;
}
public static function diskExists(string | \UnitEnum $disk): bool
{
try {

View file

@ -9,7 +9,7 @@ use Uwil\Storage\Lib\Utils;
final readonly class DiskName implements \Stringable
{
public const array ALLOWED_DRIVERS = ['local', 'ftp', 'sftp', 's3'];
public const array ALLOWED_DRIVERS = ['local', 'ftp', 'sftp', 's3', 'scoped'];
public string $value;

View file

@ -92,6 +92,7 @@ class StorageServiceProvider extends ServiceProvider
$this->app->bind(
StorageGateway::class,
fn (Application $app) => new StorageGatewayImpl(
$app->make(StorageServiceConfig::class),
$app->make(LoggerInterface::class),
$app->make(DateHashPathGenerator::class),
$app->make(UuidPathGenerator::class),

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Config;
use Illuminate\Support\Facades\Storage;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
use Uwil\Storage\Lib\ValueObjects\DiskName;
@ -415,3 +416,97 @@ describe('StorageServiceConfig prefix validation and retrieval', function (): vo
'double slash' => 'media//sub',
]);
});
describe('StorageServiceConfig real driver resolution', function (): void {
it('resolves direct drivers of default and eligible disks', function (): void {
// GIVEN
Storage::fake('s3');
// WHEN
$config = new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local', 's3'],
laravelExistingDisks: [
(object) ['disk' => 'local', 'driver' => 'local'],
(object) ['disk' => 's3', 'driver' => 's3'],
],
allowedExtensions: ['pdf']
);
$localDisk = new DiskName('local', 'local');
$s3Disk = new DiskName('s3', 's3');
// THEN
expect($config->getRealDriver($localDisk))->toBe('local')
->and($config->isS3Driver($localDisk))->toBeFalse()
->and($config->getRealDriver($s3Disk))->toBe('s3')
->and($config->isS3Driver($s3Disk))->toBeTrue();
});
it('resolves the underlying driver of a scoped disk at boot', function (): void {
// GIVEN
config()->set('filesystems.disks.s3_parent', ['driver' => 's3']);
config()->set('filesystems.disks.media', [
'driver' => 'scoped',
'disk' => 's3_parent',
'prefix' => 'media',
]);
Storage::fake('media');
// WHEN
$config = new StorageServiceConfig(
defaultDisk: 'media',
eligibleDisks: ['media'],
laravelExistingDisks: [(object) ['disk' => 'media', 'driver' => 'scoped']],
allowedExtensions: ['pdf']
);
$media = new DiskName('media', 'scoped');
// THEN
expect($config->getRealDriver($media))->toBe('s3')
->and($config->isS3Driver($media))->toBeTrue();
});
it('fails at boot when a scoped disk is misconfigured', function (): void {
// GIVEN
config()->set('filesystems.disks.broken', ['driver' => 'scoped']);
Storage::fake('broken');
// WHEN
$action = fn () => new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local', 'broken'],
laravelExistingDisks: [
(object) ['disk' => 'local', 'driver' => 'local'],
(object) ['disk' => 'broken', 'driver' => 'scoped'],
],
allowedExtensions: ['pdf']
);
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
"Scoped disk 'broken' is missing a valid parent 'disk' configuration."
);
});
it('throws when asking the driver of a disk that is neither default nor eligible', function (): void {
// GIVEN
$config = new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local'],
laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']],
allowedExtensions: ['pdf']
);
Storage::fake('other');
$other = new DiskName('other', 'local');
// WHEN
$action = fn () => $config->getRealDriver($other);
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
'Disk other is neither the default nor an eligible disk'
);
});
});

View file

@ -14,7 +14,7 @@ use Uwil\Storage\Lib\ValueObjects\SanitizedFile;
covers(FileSanitizerImpl::class);
/**
* @param array<string> $allowedExtensions
* @param array<string> $allowedExtensions
*/
function createConfig(array $allowedExtensions = ['jpg', 'png', 'pdf', 'txt']): StorageServiceConfig
{

View file

@ -4,8 +4,11 @@ declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Lib;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
use Uwil\Storage\Lib\PathGenerators\DateHashPathGenerator;
use Uwil\Storage\Lib\PathGenerators\UuidPathGenerator;
@ -14,14 +17,36 @@ use Uwil\Storage\Lib\ValueObjects\DiskName;
use Uwil\Storage\Lib\ValueObjects\FileStream;
use Uwil\Storage\Lib\ValueObjects\SanitizedFile;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Illuminate\Contracts\Filesystem\Filesystem;
use Psr\Log\LoggerInterface;
covers(StorageGatewayImpl::class);
function createGateway(): StorageGatewayImpl
/**
* @param array<string, string> $extraDisks eligible disks to declare in addition to `local` (name => driver)
*/
function createGatewayConfig(array $extraDisks = []): StorageServiceConfig
{
$existing = [(object) ['disk' => 'local', 'driver' => 'local']];
foreach ($extraDisks as $name => $driver) {
Storage::fake($name);
$existing[] = (object) ['disk' => $name, 'driver' => $driver];
}
return new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local', ...array_keys($extraDisks)],
laravelExistingDisks: $existing,
allowedExtensions: ['txt']
);
}
/**
* @param array<string, string> $extraDisks
*/
function createGateway(array $extraDisks = []): StorageGatewayImpl
{
return new StorageGatewayImpl(
config: createGatewayConfig($extraDisks),
logger: new NullLogger(),
datePathGenerator: new DateHashPathGenerator(),
uuidPathGenerator: new UuidPathGenerator()
@ -52,7 +77,7 @@ describe('StorageGatewayImpl storeFile', function (): void {
it('stores a file onto an s3 disk using UuidPathGenerator', function (): void {
// GIVEN
Storage::fake('s3');
$gateway = createGateway();
$gateway = createGateway(['s3' => 's3']);
$disk = new DiskName('s3', 's3');
$sanitizedFile = new SanitizedFile(
originalName: 'picture.png',
@ -74,9 +99,9 @@ describe('StorageGatewayImpl storeFile', function (): void {
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andReturn(false);
$gateway = createGateway();
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
$gateway = createGateway();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'test.txt',
@ -96,9 +121,9 @@ describe('StorageGatewayImpl storeFile', function (): void {
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andThrow(new \RuntimeException('Network drop'));
$gateway = createGateway();
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
$gateway = createGateway();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'test.txt',
@ -119,9 +144,9 @@ describe('StorageGatewayImpl storeFile', function (): void {
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andThrow($directException);
$gateway = createGateway();
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
$gateway = createGateway();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'test.txt',
@ -143,9 +168,9 @@ describe('StorageGatewayImpl storeFile', function (): void {
config()->set("filesystems.disks.{$driver}", ['driver' => $driver]);
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andReturn(true);
$gateway = createGateway([$driver => $driver]);
Storage::shouldReceive('disk')->with($driver)->andReturn($mockStorage);
$gateway = createGateway();
$disk = new DiskName($driver, $driver);
$sanitizedFile = new SanitizedFile(
originalName: 'test.txt',
@ -163,6 +188,83 @@ describe('StorageGatewayImpl storeFile', function (): void {
'ftp driver' => 'ftp',
'sftp driver' => 'sftp',
]);
it('stores a file onto a scoped disk resolving underlying s3 driver', function (): void {
// GIVEN
config()->set('filesystems.disks.s3', ['driver' => 's3']);
config()->set('filesystems.disks.scoped_s3', [
'driver' => 'scoped',
'disk' => 's3',
'prefix' => 'media',
]);
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andReturn(true);
$gateway = createGateway(['scoped_s3' => 'scoped']);
Storage::shouldReceive('disk')->with('scoped_s3')->andReturn($mockStorage);
$disk = new DiskName('scoped_s3', 'scoped');
$sanitizedFile = new SanitizedFile(
originalName: 'photo.jpg',
sanitizedName: 'photo.jpg',
contents: FileStream::fromString('image bytes')
);
// WHEN
$path = $gateway->storeFile($disk, $sanitizedFile);
// THEN
expect($path)->toBeInstanceOf(StorageFilePath::class)
->and($path->value)->toMatch('#^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$#');
});
it('stores a file onto a scoped disk resolving underlying local driver', function (): void {
// GIVEN
config()->set('filesystems.disks.local_parent', ['driver' => 'local']);
config()->set('filesystems.disks.scoped_local', [
'driver' => 'scoped',
'disk' => 'local_parent',
'prefix' => 'uploads',
]);
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andReturn(true);
$gateway = createGateway(['scoped_local' => 'scoped']);
Storage::shouldReceive('disk')->with('scoped_local')->andReturn($mockStorage);
$disk = new DiskName('scoped_local', 'scoped');
$sanitizedFile = new SanitizedFile(
originalName: 'doc.pdf',
sanitizedName: 'doc.pdf',
contents: FileStream::fromString('pdf data')
);
// WHEN
$path = $gateway->storeFile($disk, $sanitizedFile);
// THEN
expect($path)->toBeInstanceOf(StorageFilePath::class)
->and($path->value)->toStartWith(gmdate('Y') . '/');
});
it('rejects a disk that is neither default nor eligible', function (): void {
// GIVEN
$gateway = createGateway();
Storage::fake('unknown');
$disk = new DiskName('unknown', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'doc.pdf',
sanitizedName: 'doc.pdf',
contents: FileStream::fromString('data')
);
// WHEN
$action = fn () => $gateway->storeFile($disk, $sanitizedFile);
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
'Disk unknown is neither the default nor an eligible disk'
);
});
});
describe('StorageGatewayImpl getFile', function (): void {
@ -320,6 +422,7 @@ describe('StorageGatewayImpl logging', function (): void {
$logger->shouldReceive('info')->with(\Mockery::pattern('/^File deletion result for disk:.*: success$/'))->once();
$gateway = new StorageGatewayImpl(
config: createGatewayConfig(),
logger: $logger,
datePathGenerator: new DateHashPathGenerator(),
uuidPathGenerator: new UuidPathGenerator()
@ -346,6 +449,7 @@ describe('StorageGatewayImpl logging', function (): void {
$logger->shouldReceive('debug')->with('File not found on disk: local, path: not_found.txt')->once();
$gateway = new StorageGatewayImpl(
config: createGatewayConfig(),
logger: $logger,
datePathGenerator: new DateHashPathGenerator(),
uuidPathGenerator: new UuidPathGenerator()

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Lib;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
use Uwil\Storage\Lib\Utils;
covers(Utils::class);
@ -99,3 +100,108 @@ describe('Utils isExtensionAllowed', function (): void {
expect($fallback)->toBeTrue();
});
});
describe('Utils resolveDriver', function (): void {
it('returns direct driver for non-scoped disk', function (): void {
// GIVEN
config()->set('filesystems.disks.my_s3', ['driver' => 's3']);
// WHEN
$driver = Utils::resolveDriver('my_s3');
// THEN
expect($driver)->toBe('s3');
});
it('resolves parent driver for scoped disk from string', function (): void {
// GIVEN
config()->set('filesystems.disks.parent_local', ['driver' => 'local']);
config()->set('filesystems.disks.scoped_disk', [
'driver' => 'scoped',
'disk' => 'parent_local',
]);
// WHEN
$driver = Utils::resolveDriver('scoped_disk');
// THEN
expect($driver)->toBe('local');
});
it('resolves parent driver for scoped disk when driver is given explicitly', function (): void {
// GIVEN
config()->set('filesystems.disks.parent_s3', ['driver' => 's3']);
config()->set('filesystems.disks.scoped_s3', [
'driver' => 'scoped',
'disk' => 'parent_s3',
]);
// WHEN
$driver = Utils::resolveDriver('scoped_s3', 'scoped');
// THEN
expect($driver)->toBe('s3');
});
it('returns explicit non-scoped driver without reading config', function (): void {
// WHEN
$driver = Utils::resolveDriver('not_configured', 'sftp');
// THEN
expect($driver)->toBe('sftp');
});
it('throws exception when parent disk has no driver configured', function (): void {
// GIVEN
config()->set('filesystems.disks.scoped_orphan', [
'driver' => 'scoped',
'disk' => 'missing_parent',
]);
// WHEN
$action = fn () => Utils::resolveDriver('scoped_orphan');
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
"Parent disk 'missing_parent' of scoped disk 'scoped_orphan' has no valid driver configured."
);
});
it('throws exception when scoped disk has missing parent disk', function (): void {
// GIVEN
config()->set('filesystems.disks.scoped_invalid', [
'driver' => 'scoped',
]);
// WHEN
$action = fn () => Utils::resolveDriver('scoped_invalid');
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
"Scoped disk 'scoped_invalid' is missing a valid parent 'disk' configuration."
);
});
it('throws exception when scoped disk has circular reference', function (): void {
// GIVEN
config()->set('filesystems.disks.scoped_x', [
'driver' => 'scoped',
'disk' => 'scoped_y',
]);
config()->set('filesystems.disks.scoped_y', [
'driver' => 'scoped',
'disk' => 'scoped_x',
]);
// WHEN
$action = fn () => Utils::resolveDriver('scoped_x');
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
"Circular reference detected in scoped disk 'scoped_y'."
);
});
});

View file

@ -22,10 +22,11 @@ describe('DiskName instantiation', function (): void {
->and($vo->value)->toBe('local')
->and($vo->driver)->toBe($driver);
})->with([
'local driver' => 'local',
'ftp driver' => 'ftp',
'sftp driver' => 'sftp',
's3 driver' => 's3',
'local driver' => 'local',
'ftp driver' => 'ftp',
'sftp driver' => 'sftp',
's3 driver' => 's3',
'scoped driver' => 'scoped',
]);
it('can instantiate with a BackedEnum value', function (): void {