Skip to content

Commit 1efeadc

Browse files
feature #40600 [Config][DependencyInjection] Add configuration builder for writing PHP config (Nyholm)
This PR was squashed before being merged into the 5.3-dev branch. Discussion ---------- [Config][DependencyInjection] Add configuration builder for writing PHP config | Q | A | ------------- | --- | Branch? | 5.x | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | | License | MIT | Doc PR | symfony/symfony-docs#15181 I've spent most part of today to generate this PR. It is far from complete but it is ready for review. This PR will build classes and store them in the build_dir. The classes will help you write PHP config. It will basically generate an array for you. ### Before ```php // config/packages/security.php <?php use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function (ContainerConfigurator $container) { $array = [ 'firewalls' => [ 'main' => [ 'pattern' => '^/*', 'lazy' => true, 'anonymous' => [], ], ], 'access_control' => [ [ 'path' => '^/user', 'roles' => [ 0 => 'ROLE_USER', ], ], [ 'path' => '^/admin', 'roles' => 'ROLE_ADMIN', ], ], 'role_hierarchy' => [ 'ROLE_ADMIN' => ['ROLE_USER'], 'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH', ], ], ]; $container->extension('security', $array); } ``` ### After ```php // config/packages/security.php <?php use Symfony\Config\SecurityConfig; return static function (SecurityConfig $security) { $security ->roleHierarchy('ROLE_ADMIN', ['ROLE_USER']) ->roleHierarchy('ROLE_SUPER_ADMIN', ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH']) ->accessControl() ->path('^/user') ->role('ROLE_USER'); $security->accessControl(['path' => '^/admin', 'roles' => 'ROLE_ADMIN']); $security->firewall('main') ->pattern('^/*') ->lazy(true) ->anonymous(); }; ``` ### About autogeneration This PR is generating the extension's `ConfigBuilder`s when they are first used. Since the PR is already very large, I prefer to follow up with additional PRs to include a cache warmer and command to rebuild the `ConfigBuilder`s. The generated `ConfigBuilder` uses a "ucfirst() camelCased" extension alias. If the alias is `acme_foo` the root `ConfigBuilder` will be `Symfony\Config\AcmeFooConfig`. The recommended way of using this class is: ```php // config/packages/acme_foo.php use Symfony\Config\AcmeFooConfig; return static function (AcmeFooConfig $foo) { // ... // No need to return } ``` One may also init the class directly, But this will not help you with generation or autoloading ```php // config/packages/acme_foo.php use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function (ContainerConfigurator $container) { $foo = new \Symfony\Config\AcmeFooConfig(); // ... $container->extension('acme_foo', $foo->toArray()); } ``` **I do think we should only talk about the first way** If a third party bundle like this idea and want to provide their own `ConfigBuilder`, they have two options: 1) Create the class `Symfony\Config\TheBundleConfig` themselves and make sure they configure composer to autoload that file and that the class implements `ConfigBuilderInterface`. We will never regenerate a file that already exists. 2) Create any class implementing `ConfigBuilderInterface` and ask their users to use that class in their config in the same way they would use `Symfony\Config\TheBundleConfig`. The first way is obviously the smoothest way of doing things. ### BC There is a great discussion about backwards compatibility for the generated files. We can assure that the class generator don't introduce a BC break with our tests. However, if the bundle changes their configuration definition it may break BC. Things like renaming, changing type or changing a single value to array is obvious BC breaks, however, these can be fixed in the config definition with normalisation. The generator does not support normalisation. It is way way more complicated to reverse engineer that. I think a future update could fix this in one of two ways: 1) Add extra definition rules to help the class generator 2) Allow the bundle to patch part of the generated code I hate BC breaks as much as the next person, but all the BC breaks in the generated classes will be caught when building the container (not at runtime), so I am fine with not having a 100% complete solution for this issue in the initial PR. ### Other limitations If a bundle is using a custom extension alias, then we cannot guess it.. so a user have to use a cache warmer because we cannot generate the `ConfigBuilder` on the fly. ### TODO - [x] Add tests - [x] Update changelog - [x] Write documentation ------------- The generated code can be found in this example app: https://github.com/Nyholm/sf-issue-40600/tree/main/var/cache/dev/Symfony/Config Commits ------- 460b46f730 [Config][DependencyInjection] Add configuration builder for writing PHP config
2 parents a70b648 + a9ba320 commit 1efeadc

File tree

7 files changed

+187
-2
lines changed

7 files changed

+187
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CHANGELOG
1313
* Add `ContainerBuilder::willBeAvailable()` to help with conditional configuration
1414
* Add support an integer return value for default_index_method
1515
* Add `env()` and `EnvConfigurator` in the PHP-DSL
16+
* Add support for `ConfigBuilder` in the `PhpFileLoader`
1617

1718
5.2.0
1819
-----

Loader/PhpFileLoader.php

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111

1212
namespace Symfony\Component\DependencyInjection\Loader;
1313

14+
use Symfony\Component\Config\Builder\ConfigBuilderGenerator;
15+
use Symfony\Component\Config\Builder\ConfigBuilderGeneratorInterface;
16+
use Symfony\Component\Config\Builder\ConfigBuilderInterface;
17+
use Symfony\Component\Config\FileLocatorInterface;
18+
use Symfony\Component\DependencyInjection\Container;
19+
use Symfony\Component\DependencyInjection\ContainerBuilder;
20+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
21+
use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface;
22+
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
1423
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
1524

1625
/**
@@ -24,6 +33,13 @@
2433
class PhpFileLoader extends FileLoader
2534
{
2635
protected $autoRegisterAliasesForSinglyImplementedInterfaces = false;
36+
private $generator;
37+
38+
public function __construct(ContainerBuilder $container, FileLocatorInterface $locator, string $env = null, ?ConfigBuilderGeneratorInterface $generator = null)
39+
{
40+
parent::__construct($container, $locator, $env);
41+
$this->generator = $generator;
42+
}
2743

2844
/**
2945
* {@inheritdoc}
@@ -47,7 +63,7 @@ public function load($resource, string $type = null)
4763
$callback = $load($path);
4864

4965
if (\is_object($callback) && \is_callable($callback)) {
50-
$callback(new ContainerConfigurator($this->container, $this, $this->instanceof, $path, $resource, $this->env), $this->container, $this);
66+
$this->executeCallback($callback, new ContainerConfigurator($this->container, $this, $this->instanceof, $path, $resource, $this->env), $path);
5167
}
5268
} finally {
5369
$this->instanceof = [];
@@ -70,6 +86,97 @@ public function supports($resource, string $type = null)
7086

7187
return 'php' === $type;
7288
}
89+
90+
/**
91+
* Resolve the parameters to the $callback and execute it.
92+
*/
93+
private function executeCallback(callable $callback, ContainerConfigurator $containerConfigurator, string $path)
94+
{
95+
if (!$callback instanceof \Closure) {
96+
$callback = \Closure::fromCallable($callback);
97+
}
98+
99+
$arguments = [];
100+
$configBuilders = [];
101+
$parameters = (new \ReflectionFunction($callback))->getParameters();
102+
foreach ($parameters as $parameter) {
103+
$reflectionType = $parameter->getType();
104+
if (!$reflectionType instanceof \ReflectionNamedType) {
105+
throw new \InvalidArgumentException(sprintf('Could not resolve argument "%s" for "%s".', $parameter->getName(), $path));
106+
}
107+
$type = $reflectionType->getName();
108+
109+
switch ($type) {
110+
case ContainerConfigurator::class:
111+
$arguments[] = $containerConfigurator;
112+
break;
113+
case ContainerBuilder::class:
114+
$arguments[] = $this->container;
115+
break;
116+
case FileLoader::class:
117+
case self::class:
118+
$arguments[] = $this;
119+
break;
120+
default:
121+
try {
122+
$configBuilder = $this->configBuilder($type);
123+
} catch (InvalidArgumentException | \LogicException $e) {
124+
throw new \InvalidArgumentException(sprintf('Could not resolve argument "%s" for "%s".', $type.' '.$parameter->getName(), $path), 0, $e);
125+
}
126+
$configBuilders[] = $configBuilder;
127+
$arguments[] = $configBuilder;
128+
}
129+
}
130+
131+
$callback(...$arguments);
132+
133+
/** @var ConfigBuilderInterface $configBuilder */
134+
foreach ($configBuilders as $configBuilder) {
135+
$containerConfigurator->extension($configBuilder->getExtensionAlias(), $configBuilder->toArray());
136+
}
137+
}
138+
139+
/**
140+
* @param string $namespace FQCN string for a class implementing ConfigBuilderInterface
141+
*/
142+
private function configBuilder(string $namespace): ConfigBuilderInterface
143+
{
144+
if (!class_exists(ConfigBuilderGenerator::class)) {
145+
throw new \LogicException('You cannot use the config builder as the Config component is not installed. Try running "composer require symfony/config".');
146+
}
147+
148+
if (null === $this->generator) {
149+
throw new \LogicException('You cannot use the ConfigBuilders without providing a class implementing ConfigBuilderGeneratorInterface.');
150+
}
151+
152+
// If class exists and implements ConfigBuilderInterface
153+
if (class_exists($namespace) && is_subclass_of($namespace, ConfigBuilderInterface::class)) {
154+
return new $namespace();
155+
}
156+
157+
// If it does not start with Symfony\Config\ we dont know how to handle this
158+
if ('Symfony\\Config\\' !== substr($namespace, 0, 15)) {
159+
throw new InvalidargumentException(sprintf('Could not find or generate class "%s".', $namespace));
160+
}
161+
162+
// Try to get the extension alias
163+
$alias = Container::underscore(substr($namespace, 15, -6));
164+
165+
if (!$this->container->hasExtension($alias)) {
166+
$extensions = array_filter(array_map(function (ExtensionInterface $ext) { return $ext->getAlias(); }, $this->container->getExtensions()));
167+
throw new InvalidArgumentException(sprintf('There is no extension able to load the configuration for "%s". Looked for namespace "%s", found "%s".', $namespace, $namespace, $extensions ? implode('", "', $extensions) : 'none'));
168+
}
169+
170+
$extension = $this->container->getExtension($alias);
171+
if (!$extension instanceof ConfigurationExtensionInterface) {
172+
throw new \LogicException(sprintf('You cannot use the config builder for "%s" because the extension does not implement "%s".', $namespace, ConfigurationExtensionInterface::class));
173+
}
174+
175+
$configuration = $extension->getConfiguration([], $this->container);
176+
$loader = $this->generator->build($configuration);
177+
178+
return $loader();
179+
}
73180
}
74181

75182
/**

Tests/Fixtures/AcmeConfigBuilder.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
4+
5+
use Symfony\Component\Config\Builder\ConfigBuilderInterface;
6+
7+
class AcmeConfigBuilder implements ConfigBuilderInterface
8+
{
9+
private $color;
10+
11+
public function color($value)
12+
{
13+
$this->color = $value;
14+
}
15+
16+
public function toArray(): array
17+
{
18+
return [
19+
'color' => $this->color
20+
];
21+
}
22+
23+
public function getExtensionAlias(): string
24+
{
25+
return 'acme';
26+
}
27+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
parameters:
2+
acme.configs: [{ color: blue }]
3+
4+
services:
5+
service_container:
6+
class: Symfony\Component\DependencyInjection\ContainerInterface
7+
public: true
8+
synthetic: true
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
use Symfony\Component\DependencyInjection\Tests\Fixtures\AcmeConfigBuilder;
4+
5+
return static function (AcmeConfigBuilder $config) {
6+
$config->color('blue');
7+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
use Symfony\Component\DependencyInjection\ContainerBuilder;
4+
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
5+
6+
class AcmeExtension implements ExtensionInterface
7+
{
8+
public function load(array $configs, ContainerBuilder $configuration)
9+
{
10+
$configuration->setParameter('acme.configs', $configs);
11+
12+
return $configuration;
13+
}
14+
15+
public function getXsdValidationBasePath()
16+
{
17+
return false;
18+
}
19+
20+
public function getNamespace(): string
21+
{
22+
return 'http://www.example.com/schema/acme';
23+
}
24+
25+
public function getAlias(): string
26+
{
27+
return 'acme';
28+
}
29+
}

Tests/Loader/PhpFileLoaderTest.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111

1212
namespace Symfony\Component\DependencyInjection\Tests\Loader;
1313

14+
require_once __DIR__.'/../Fixtures/includes/AcmeExtension.php';
15+
1416
use PHPUnit\Framework\TestCase;
1517
use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
18+
use Symfony\Component\Config\Builder\ConfigBuilderGenerator;
1619
use Symfony\Component\Config\FileLocator;
1720
use Symfony\Component\DependencyInjection\ContainerBuilder;
1821
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
@@ -60,7 +63,9 @@ public function testConfigServices()
6063
public function testConfig($file)
6164
{
6265
$fixtures = realpath(__DIR__.'/../Fixtures');
63-
$loader = new PhpFileLoader($container = new ContainerBuilder(), new FileLocator());
66+
$container = new ContainerBuilder();
67+
$container->registerExtension(new \AcmeExtension());
68+
$loader = new PhpFileLoader($container, new FileLocator(), 'prod', new ConfigBuilderGenerator(sys_get_temp_dir()));
6469
$loader->load($fixtures.'/config/'.$file.'.php');
6570

6671
$container->compile();
@@ -82,6 +87,7 @@ public function provideConfig()
8287
yield ['anonymous'];
8388
yield ['lazy_fqcn'];
8489
yield ['remove'];
90+
yield ['config_builder'];
8591
}
8692

8793
public function testAutoConfigureAndChildDefinition()

0 commit comments

Comments
 (0)