Use Symfony Config Component to Validate XML and YAML Files
I write a lot of standalone bundles for symfony and often these bundles expose a configuration that I need to declare with the Symfony Config component. And each time, I am forced to dive back into code already made to find syntaxes, because unfortunately this component is not fully documented on the symfony website but in addition there is no autocompletion with PhpStorm. For a new project outside symfony, I use this component again and it is therefore an opportunity to compile the solutions to some problems here.
Start
To use this component it is quite simple, first of all the installation:
composer require symfony/config
Then you have to create a class that implements ConfigurationInterface and instantiates TreeBuilder.
<?php namespace Metfan\RabbitSetup\Configuration; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; class ConfigExpertConfiguration implements ConfigurationInterface { public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root('rabbit_setup'); return $rootNode; } }
The file should therefore start with 'rabbit_setup' as the head node. The simple writing of a tree is quite well documented, so I refer you to the official doc.
Syntaxes
When declaring a node, there seem to be some naming rules; in this case the '-' are replaced by '_' in the keys. If I create a “message-ttl” node, it will be transformed into “message_ttl” and I will get an error. I didn't look for the why but I found the option that solves this problem: normalizeKeys (false)
$rootNode ->arrayNode('definition') ->isRequired() ->normalizeKeys(false) # don't transforme - into _ ->children() ->integerNode('message-ttl')->end() ->integerNode('expires')->end() ->integerNode('max-length')->end() ->integerNode('max-length-bytes')->end() ->scalarNode('alternate-exchange')->end() ->scalarNode('dead-letter-exchange')->end() ->scalarNode('dead-letter-routing-key')->end() ->scalarNode('federation-upstream-set')->end() ->scalarNode('federation-upstream')->end() ->end() ->end();
Which allows me to write my YAML as follows:
definition: federation-upstream: thor alternate-exchange: unroutable
Another point that caused me to lose some hair, an array. How to declare this script so simple in YAML:
federation-upstream-set: supfed: [{upstream: thor}, {upstream: all}] #or it's variant federation-upstream-set: supfed: - {upstream: thor} - {upstream: odin}
And here's the solution:
$rootNode ->arrayNode('federation-upstream-set') ->useAttributeAsKey('name') ->prototype('array') ->prototype('array') ->children() ->scalarNode('upstream') ->end() ->end() ->end() ->end() ->end();
Validation
Validation allows you to do a lot of things.
Let's say I want to declare a ttl, instinctively I'm going to want to use the IntegerNode type. As often I want to have the possibility to leave the field empty which implies no expiration. Unfortunately I can no longer use IntegerNode in this case because this node does not accept the null value. I am forced to use ScalarNode but so you can enter anything. So comes validation to our rescue:
$node ->scalarNode('ttl') ->validate() ->always() ->then(function($value){ if (!$value) { return null; } elseif (is_int($value)){ return $value; } throw new InvalidTypeException(sprintf( 'Invalid value for path "ttl". Expected integer or null, but got %s.', gettype($value))); }) ->end() ->info('# in ms, leave blank mean forever') ->defaultValue(null) ->end();
Here in my “ttl” node I use validate() then always(). The second implies that I will always switch to the then() that follows. In the then() I pass a function that validates my data and returns it or throws an exception if the type is bad. In the then*() functions it is absolutely necessary to return the data that will be used to replace the data written in the YAML file.
In the same way I can validate my data according to others. To do this I have to call the validation on an element higher in the structure of the tree and I will receive as a parameter the entire tree under this element.
$rootNode ->validate() ->always() ->then(function($v){ foreach ($v['vhosts'] as $name => $config) { if (!isset($v['connections'][$config['connection']])) { throw new InvalidTypeException(sprintf( 'Connection name "%s" for vhost %s have to be declared in "connections" section.', $config['connection'], $name )); } } return $v; }) ->end() ->children() ->arrayNode('connections') #definition of connections ->requiresAtLeastOneElement() ->useAttributeAsKey('name') #use name as identifier ->prototype('array') ->children() ->scalarNode('user')->defaultValue('guest')->end() ->scalarNode('password')->defaultValue('guest')->end() ->scalarNode('host')->defaultValue('127.0.0.1')->end() ->scalarNode('port')->defaultValue(15672)->end() ->end() ->end() ->end() ->arrayNode('vhosts') # definition of vhosts ->requiresAtLeastOneElement() ->useAttributeAsKey('name') ->prototype('array') ->children() ->scalarNode('connection')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->end() ->end();
In this example I verify that the connections called at the bottom of the tree in the “vhosts” part have been declared in the “connections” node. As my validation is at the highest level in the tree, I am retrieving my entire tree as a parameter of my function in then().
Unit test
Yes this class that is 3km long is being tested. Here I am sharing the case test for PHPUnit that I wrote to myself. It is based on a set of fixtures, all you have to do is replace the configuration class with your own.
<?php namespace Metfan\RabbitSetup\Tests\Configuration; use Metfan\RabbitFeed\Configuration\ConfigExpertConfiguration; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\Finder\Finder; use Symfony\Component\Yaml\Yaml; class ConfigExpertConfigurationTest extends \PHPUnit_Framework_TestCase { public function provider() { $finder = new Finder(); $finder->files()->in(__DIR__.'/fixtures'); $fixtures = []; foreach ($finder as $file) { $fixtures[] = [$file]; } return $fixtures; } /** * @dataProvider provider * @param \SplFileInfo $file */ public function test(\SplFileInfo $file) { $data = $this->parseFixture($file); if (null !== $data['exception']) { $this->setExpectedException($data['exception']); } $yaml = Yaml::parse($data['yaml']); $processor = new Processor(); $this->assertEquals($data['expect'], $processor->processConfiguration(new ConfigExpertConfiguration(), $yaml)); } /** * Read content of fixture file. split content by "--EXPECT--" * First part is Yaml to be used with configuration. * Second part can be: * - fully qualified name of exception throw during configuration processing * - array resulting of processing configuration * * @param \SplFileInfo $file * @return array */ private function parseFixture(\SplFileInfo $file) { $contents = $file->getContents(); $data = ['yaml' => null, 'expect' => null, 'exception' => null]; $values = explode('--EXPECT--', $contents); $data['yaml'] = $values[0]; $result = str_replace([' ', "\n"], '', $values[1]); if (false === stripos($result, 'exception')) { eval("\$data['expect'] = $result ;"); } else { $data['exception'] = $result; } return $data; } }
The fixture file looks like this, the YAML at the top, the result at the bottom.
#test 1 connection rabbit_setup: connections: default: user: 'pouette' password: "secretMe!" host: 127.0.0.1 port: 15672 --EXPECT-- [ "connections" => [ "default" => [ "user" => "pouette", "password" => "secretMe!", "host" => "127.0.0.1", "port" => 15672, ] ], "vhosts" => [] ]
I did not deal with the normalization of the tree that allows you to modify the structure of the latter on the fly. I am still a little lost on this subject and it seems complicated enough to me to deserve to devote an entire article to it in the future.
Add a comment