diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c5912b736..285483512 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,3 @@ - name: build on: diff --git a/src/Boot/src/Bootloader/ConfigurationBootloader.php b/src/Boot/src/Bootloader/ConfigurationBootloader.php index fd245ae07..bcc1e6456 100644 --- a/src/Boot/src/Bootloader/ConfigurationBootloader.php +++ b/src/Boot/src/Bootloader/ConfigurationBootloader.php @@ -6,63 +6,82 @@ use Psr\Container\ContainerInterface; use Spiral\Boot\DirectoriesInterface; +use Spiral\Boot\EnvironmentInterface; use Spiral\Config\ConfigManager; use Spiral\Config\ConfiguratorInterface; -use Spiral\Config\Loader\DirectoryLoader; +use Spiral\Config\Loader\ConfigsMergerInterface; +use Spiral\Config\Loader\DirectoriesRepository; +use Spiral\Config\Loader\DirectoriesRepositoryInterface; use Spiral\Config\Loader\FileLoaderInterface; +use Spiral\Config\Loader\FileLoaderRegistry; use Spiral\Config\Loader\JsonLoader; +use Spiral\Config\Loader\MergeFileStrategyLoader; use Spiral\Config\Loader\PhpLoader; -use Spiral\Core\BinderInterface; +use Spiral\Config\Loader\RecursiveConfigMerger; +use Spiral\Config\Loader\SingleFileStrategyLoader; +use Spiral\Config\LoaderInterface; use Spiral\Core\ConfigsInterface; /** - * Bootloads core services. + * Boot core service responsible for configuration loading and management. */ final class ConfigurationBootloader extends Bootloader { - protected const SINGLETONS = [ - // configuration - ConfigsInterface::class => ConfiguratorInterface::class, - ConfiguratorInterface::class => ConfigManager::class, - ConfigManager::class => [self::class, 'configManager'], - ]; + public function __construct( + private readonly ContainerInterface $container, + ) {} - private readonly ConfiguratorInterface $configurator; + public function defineSingletons(): array + { + return [ + ConfigsInterface::class => ConfiguratorInterface::class, + ConfiguratorInterface::class => ConfigManager::class, + FileLoaderRegistry::class => $this->createFileLoader(...), + ConfigManager::class => $this->createConfigManager(...), + ConfigsMergerInterface::class => RecursiveConfigMerger::class, + LoaderInterface::class => $this->createConfigLoader(...), + DirectoriesRepositoryInterface::class => $this->createDirectories(...), + ]; + } - /** @var FileLoaderInterface[] */ - private array $loaders; + private function createConfigLoader(EnvironmentInterface $env): LoaderInterface + { + return match ($env->get('CONFIG_STRATEGY', 'single')) { + 'merge' => $this->container->get(MergeFileStrategyLoader::class), + default => $this->container->get(SingleFileStrategyLoader::class), + }; + } - public function __construct( - ContainerInterface $container, - private readonly DirectoriesInterface $directories, - private readonly BinderInterface $binder, - ) { - $this->loaders = [ - 'php' => $container->get(PhpLoader::class), - 'json' => $container->get(JsonLoader::class), - ]; + private function createDirectories(DirectoriesInterface $directories): DirectoriesRepositoryInterface + { + return new DirectoriesRepository([ + $directories->get('config'), + ]); + } - $this->configurator = $this->createConfigManager(); + private function createFileLoader(PhpLoader $phpLoader, JsonLoader $jsonLoader): FileLoaderRegistry + { + return new FileLoaderRegistry([ + 'php' => $phpLoader, + 'json' => $jsonLoader, + ]); } - public function addLoader(string $ext, FileLoaderInterface $loader): void + public function setDirectories(array $directories): void { - if (!isset($this->loaders[$ext]) || $loader::class !== $this->loaders[$ext]::class) { - $this->loaders[$ext] = $loader; - $this->binder->bindSingleton(ConfigManager::class, $this->createConfigManager()); - } + $this->container->get(DirectoriesRepositoryInterface::class)->setDirectories($directories); } - private function createConfigManager(): ConfigManager + public function addLoader(string $ext, FileLoaderInterface $loader): void { - return new ConfigManager( - new DirectoryLoader($this->directories->get('config'), $this->loaders), - true, - ); + $this->container->get(FileLoaderRegistry::class)->register($ext, $loader); } - private function configManager(): ConfiguratorInterface + private function createConfigManager(LoaderInterface $loader): ConfigManager { - return $this->configurator; + return new ConfigManager( + loader: $loader, + strict: true, + ); } } diff --git a/src/Boot/tests/ConfigsTest.php b/src/Boot/tests/ConfigsTest.php index 87fae77bf..5129c3863 100644 --- a/src/Boot/tests/ConfigsTest.php +++ b/src/Boot/tests/ConfigsTest.php @@ -4,29 +4,71 @@ namespace Spiral\Tests\Boot; +use Spiral\Boot\DirectoriesInterface; use Spiral\Config\ConfiguratorInterface; +use Spiral\Config\Loader\DirectoriesRepositoryInterface; +use Spiral\Tests\Boot\Fixtures\FooConfig; use Spiral\Tests\Boot\Fixtures\TestConfig; use Spiral\Tests\Boot\Fixtures\TestCore; +use Traversable; class ConfigsTest extends TestCase { public function testDirectories(): void { $core = TestCore::create([ - 'root' => __DIR__, + 'root' => __DIR__, 'config' => __DIR__ . '/config', ])->run(); - /** @var TestConfig $config */ - $config = $core->getContainer()->get(TestConfig::class); + /** @var TestConfig $testConfig */ + $testConfig = $core->getContainer()->get(TestConfig::class); - self::assertSame(['key' => 'value'], $config->toArray()); + /** @var FooConfig $fooConfig */ + $fooConfig = $core->getContainer()->get(FooConfig::class); + + self::assertSame(['key' => 'value1'], $testConfig->toArray()); + self::assertSame(['key' => 'value'], $fooConfig->toArray()); + } + + public function testCustomDirectoriesRepository(): void + { + $core = TestCore::create([ + 'root' => __DIR__, + 'config' => __DIR__ . '/config', + ])->run(); + + $core->getContainer()->bindSingleton( + DirectoriesRepositoryInterface::class, + static function (DirectoriesInterface $dirs): DirectoriesRepositoryInterface { + return new class($dirs->get('config')) implements DirectoriesRepositoryInterface { + public function __construct( + private string $rootDir, + ) {} + + + public function getIterator(): Traversable + { + yield $this->rootDir; + } + }; + }, + ); + + /** @var TestConfig $testConfig */ + $testConfig = $core->getContainer()->get(TestConfig::class); + + /** @var FooConfig $fooConfig */ + $fooConfig = $core->getContainer()->get(FooConfig::class); + + self::assertSame(['key' => 'value'], $testConfig->toArray()); + self::assertSame(['key' => 'value'], $fooConfig->toArray()); } public function testCustomConfigLoader(): void { $core = TestCore::create([ - 'root' => __DIR__, + 'root' => __DIR__, 'config' => __DIR__ . '/config', ])->run(); diff --git a/src/Boot/tests/Fixtures/ConfigBootloader.php b/src/Boot/tests/Fixtures/ConfigBootloader.php index 618a1a8b9..5b1b1fe6b 100644 --- a/src/Boot/tests/Fixtures/ConfigBootloader.php +++ b/src/Boot/tests/Fixtures/ConfigBootloader.php @@ -8,6 +8,7 @@ use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\Bootloader\ConfigurationBootloader; use Spiral\Boot\Bootloader\CoreBootloader; +use Spiral\Boot\DirectoriesInterface; use Spiral\Core\Container; class ConfigBootloader extends Bootloader @@ -41,8 +42,12 @@ public function init(Container $container, AbstractKernel $kernel): void $container->bind('efg', 'foo'); } - public function boot(ConfigurationBootloader $configuration, AbstractKernel $kernel, Container $container): void - { + public function boot( + ConfigurationBootloader $configuration, + DirectoriesInterface $directories, + AbstractKernel $kernel, + Container $container, + ): void { // won't be executed $kernel->booting(static function (AbstractKernel $kernel) use ($container): void { $container->bind('ghi', 'foo'); @@ -53,5 +58,9 @@ public function boot(ConfigurationBootloader $configuration, AbstractKernel $ker }); $configuration->addLoader('yaml', $container->get(TestLoader::class)); + $configuration->setDirectories([ + $directories->get('config') . '/prod', + $directories->get('config'), + ]); } } diff --git a/src/Boot/tests/Fixtures/FooConfig.php b/src/Boot/tests/Fixtures/FooConfig.php new file mode 100644 index 000000000..0eff48413 --- /dev/null +++ b/src/Boot/tests/Fixtures/FooConfig.php @@ -0,0 +1,12 @@ +getContainer(); ContainerScope::runScope($c, static function (): void { - self::assertSame(['key' => 'value'], spiral(TestConfig::class)->toArray()); + self::assertSame(['key' => 'value1'], spiral(TestConfig::class)->toArray()); }); } diff --git a/src/Boot/tests/config/foo.php b/src/Boot/tests/config/foo.php new file mode 100644 index 000000000..5a1ac9a1b --- /dev/null +++ b/src/Boot/tests/config/foo.php @@ -0,0 +1,5 @@ + 'value']; diff --git a/src/Boot/tests/config/prod/test.php b/src/Boot/tests/config/prod/test.php new file mode 100644 index 000000000..79fc70dd9 --- /dev/null +++ b/src/Boot/tests/config/prod/test.php @@ -0,0 +1,5 @@ + 'value1']; diff --git a/src/Config/src/ConfigManager.php b/src/Config/src/ConfigManager.php index d9fc6ae34..3edac0584 100644 --- a/src/Config/src/ConfigManager.php +++ b/src/Config/src/ConfigManager.php @@ -13,6 +13,7 @@ * Load config files, provides container injection and modifies config data on * bootloading. * + * @internal * @implements ConfiguratorInterface */ #[Singleton] diff --git a/src/Config/src/Loader/ConfigsMergerInterface.php b/src/Config/src/Loader/ConfigsMergerInterface.php new file mode 100644 index 000000000..80089e5fe --- /dev/null +++ b/src/Config/src/Loader/ConfigsMergerInterface.php @@ -0,0 +1,14 @@ +setDirectories($directories); + } + + public function setDirectories(array $directories): void + { + $this->directories = \array_map( + static fn(string $dir): string => \rtrim($dir, '/'), + $directories, + ); + } + + public function addDirectory(string $directory): void + { + $this->directories[] = \rtrim($directory, '/'); + } + + public function getIterator(): Traversable + { + return new \ArrayIterator($this->directories); + } +} \ No newline at end of file diff --git a/src/Config/src/Loader/DirectoriesRepositoryInterface.php b/src/Config/src/Loader/DirectoriesRepositoryInterface.php new file mode 100644 index 000000000..7fc604b99 --- /dev/null +++ b/src/Config/src/Loader/DirectoriesRepositoryInterface.php @@ -0,0 +1,10 @@ + + */ +interface DirectoriesRepositoryInterface extends \IteratorAggregate {} diff --git a/src/Config/src/Loader/DirectoryLoader.php b/src/Config/src/Loader/DirectoryLoader.php index ebac64bc1..1ddc6c5be 100644 --- a/src/Config/src/Loader/DirectoryLoader.php +++ b/src/Config/src/Loader/DirectoryLoader.php @@ -7,6 +7,10 @@ use Spiral\Config\Exception\LoaderException; use Spiral\Config\LoaderInterface; +/** + * @internal + * @deprecated Use {@see SingleFileStrategyLoader} instead. Will be removed in 4.0. + */ final class DirectoryLoader implements LoaderInterface { private readonly string $directory; diff --git a/src/Config/src/Loader/FileLoaderRegistry.php b/src/Config/src/Loader/FileLoaderRegistry.php new file mode 100644 index 000000000..c52f2019c --- /dev/null +++ b/src/Config/src/Loader/FileLoaderRegistry.php @@ -0,0 +1,39 @@ + */ + private array $loaders = [], + ) {} + + /** + * @param non-empty-string $ext + */ + public function register(string $ext, FileLoaderInterface $loader): void + { + if (!isset($this->loaders[$ext]) || $this->loaders[$ext]::class !== $loader::class) { + $this->loaders[$ext] = $loader; + } + } + + public function getExtensions(): array + { + return \array_keys($this->loaders); + } + + public function getLoader(string $ext): FileLoaderInterface + { + return $this->loaders[$ext]; + } +} \ No newline at end of file diff --git a/src/Config/src/Loader/JsonLoader.php b/src/Config/src/Loader/JsonLoader.php index 290e86554..0417bd91f 100644 --- a/src/Config/src/Loader/JsonLoader.php +++ b/src/Config/src/Loader/JsonLoader.php @@ -6,6 +6,9 @@ use Spiral\Config\Exception\LoaderException; +/** + * @internal + */ final class JsonLoader implements FileLoaderInterface { public function loadFile(string $section, string $filename): array diff --git a/src/Config/src/Loader/MergeFileStrategyLoader.php b/src/Config/src/Loader/MergeFileStrategyLoader.php new file mode 100644 index 000000000..9feaf94a4 --- /dev/null +++ b/src/Config/src/Loader/MergeFileStrategyLoader.php @@ -0,0 +1,87 @@ +fileLoader->getExtensions() as $extension) { + if ($this->findFiles($section, $extension) !== []) { + return true; + } + } + + return false; + } + + public function load(string $section): array + { + $files = []; + + foreach ($this->fileLoader->getExtensions() as $extension) { + $foundFiles = $this->findFiles($section, $extension); + + if ($foundFiles === []) { + continue; + } + + if (!isset($files[$extension])) { + $files[$extension] = []; + } + + $files[$extension] = [...$files[$extension], ...$foundFiles]; + } + + if ($files === []) { + throw new LoaderException(\sprintf('Unable to load config `%s`: no suitable loader found.', $section)); + } + + $configs = []; + foreach ($files as $ext => $_files) { + foreach ($_files as $file) { + try { + $configs[] = $this->fileLoader->getLoader($ext)->loadFile($section, $file); + } catch (LoaderException $e) { + throw new LoaderException( + "Unable to load config `{$section}`: {$e->getMessage()}", + $e->getCode(), + $e, + ); + } + } + } + + return $this->configsMerger->merge(...$configs); + } + + private function findFiles(string $section, string $extension): array + { + $files = []; + foreach ($this->directories as $directory) { + $filename = \sprintf('%s/%s.%s', $directory, $section, $extension); + if (\file_exists($filename)) { + $files[] = $filename; + } + } + + return $files; + } +} diff --git a/src/Config/src/Loader/PhpLoader.php b/src/Config/src/Loader/PhpLoader.php index 678cff58c..78d616961 100644 --- a/src/Config/src/Loader/PhpLoader.php +++ b/src/Config/src/Loader/PhpLoader.php @@ -9,6 +9,7 @@ use Spiral\Core\ContainerScope; /** + * @internal * Loads PHP files inside container scope. */ final class PhpLoader implements FileLoaderInterface diff --git a/src/Config/src/Loader/RecursiveConfigMerger.php b/src/Config/src/Loader/RecursiveConfigMerger.php new file mode 100644 index 000000000..33df65da4 --- /dev/null +++ b/src/Config/src/Loader/RecursiveConfigMerger.php @@ -0,0 +1,13 @@ +fileLoader->getExtensions() as $extension) { + if ($this->findFile($section, $extension) !== null) { + return true; + } + } + + return false; + } + + public function load(string $section): array + { + foreach ($this->fileLoader->getExtensions() as $extension) { + $filename = $this->findFile($section, $extension); + if ($filename === null) { + continue; + } + + try { + return $this->fileLoader->getLoader($extension)->loadFile($section, $filename); + } catch (LoaderException $e) { + throw new LoaderException("Unable to load config `{$section}`: {$e->getMessage()}", $e->getCode(), $e); + } + } + + throw new LoaderException(\sprintf('Unable to load config `%s`: no suitable loader found.', $section)); + } + + private function findFile(string $section, string $extension): ?string + { + foreach ($this->directories as $directory) { + $filename = \sprintf('%s/%s.%s', $directory, $section, $extension); + if (\file_exists($filename)) { + return $filename; + } + } + + return null; + } +} diff --git a/src/Config/tests/BaseTestCase.php b/src/Config/tests/BaseTestCase.php index 647bf205d..bba5706b8 100644 --- a/src/Config/tests/BaseTestCase.php +++ b/src/Config/tests/BaseTestCase.php @@ -6,17 +6,16 @@ use PHPUnit\Framework\TestCase; use Spiral\Config\ConfigManager; -use Spiral\Config\Loader\DirectoryLoader; +use Spiral\Config\Loader\DirectoriesRepository; +use Spiral\Config\Loader\FileLoaderRegistry; use Spiral\Config\Loader\JsonLoader; use Spiral\Config\Loader\PhpLoader; +use Spiral\Config\Loader\SingleFileStrategyLoader; use Spiral\Core\Container; abstract class BaseTestCase extends TestCase { - /** - * @var Container - */ - protected $container; + protected Container $container; protected function setUp(): void { @@ -30,11 +29,14 @@ protected function getFactory(?string $directory = null, bool $strict = true): C } return new ConfigManager( - new DirectoryLoader($directory, [ - 'php' => $this->container->get(PhpLoader::class), - 'json' => $this->container->get(JsonLoader::class), - ]), - $strict, + loader: new SingleFileStrategyLoader( + directories: new DirectoriesRepository([$directory]), + fileLoader: new FileLoaderRegistry([ + 'php' => $this->container->get(PhpLoader::class), + 'json' => $this->container->get(JsonLoader::class), + ]), + ), + strict: $strict, ); } }