Utiliser le composant Config de Symfony pour valider les fichiers XML et YAML

J'écris beaucoup de bundles autonomes pour symfony et le plus souvent ces bundles exposent une configuration que je dois déclarer avec le composant Config de symfony. Et à chaque fois, je suis obligé de replonger dans du code déjà fait pour retrouver des syntaxes, car malheureusement ce composant n'est pas totalement documenté sur le site de symfony mais en plus il n'y a pas d'autocompletion avec PHPStorm. Pour un nouveau projet hors symfony, je reutilise ce composant et c'est donc l'occasion de compiler ici les solutions à certains problèmes.

Démarrage

Pour utiliser ce composant c'est assez simple, tout d'abord l'installation:

composer.phar require symfony/config

Ensuite il faut créer une classe qui implémente ConfigurationInterface et instancie 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;
    }
}

Le fichier devra donc commencer par 'rabbit_setup' en tant que noeud de tête. L'écriture simple d'un arbre est assez bien documentée, je vous renvoie donc à la doc officielle.

Syntaxes

Quand on déclare un noeud, il semble y avoir quelques règles de nommage. en l'occurance les '-' sont remplacés par des '_' dans les clés. Si je crée un noeud "message-ttl", il sera transformé en "message_ttl" et j'aurai une erreur. Je n'ai pas cherché le pourquoi mais j'ai trouvé l'option qui permet de résoudre ce problème: 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();
 

Ce qui me permet d'écrir mon YAML ainsi:

definition:
    federation-upstream: thor
    alternate-exchange: unroutable

Un autre point qui m'a fait perdre quelques cheveux, un simple tableau. Comment déclarer cette écriture si simple en YAML:

federation-upstream-set:
    supfed: [{upstream: thor}, {upstream: all}]
 
#ou sa variante

federation-upstream-set:
    supfed: 
        - {upstream: thor}
        - {upstream: odin}

Et voici la solution:

$rootNode
    ->arrayNode('federation-upstream-set')
        ->useAttributeAsKey('name')
        ->prototype('array')
            ->prototype('array')
                ->children()
                    ->scalarNode('upstream')
                    ->end()
                ->end()
            ->end()
        ->end()
    ->end();

Validation

La validation permet de faire énormement de chose.

Admettons que je veuille déclarer un ttl, instinctivement je vais vouloir utiliser le type integerNode. Comme souvent je veux avoir la possibilité de laisser le champs vide ce qui implique pas d'expiration. Je ne peux malheureusement plus dans ce cas utiliser IntegerNode car ce noeud n'accepte pas la valeur Null. Je suis contraint d'utiliser scalarNode mais du coup on peut saisir n'importe quoi. Vient donc la validation à notre secours:

$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();

Ici dans mon noeud "ttl" j'utilise validate() puis always(). Le deuxième implique que je passerai toujours dans le then() qui suit. Dans le then() je passe une fonction qui valide ma donnée et la retourne ou lève une exception si le type est mauvais. Dans les fonctions then*() il faut absolument renvoyer la donnée qui sera utilisée en remplacement de la donnée ecrite dans le fichier YAML.

De la même façon je peux valider mes données en fonction d'autres. Pour cela je dois appeler la validation sur un élément plus haut dans la structure de l'arbre et je recevrai en paramêtre toute l'arborescence sous cet élément.

$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();

Dans cet exemple je vérifie que les connections appelées en bas de l'arbre dans la partie "vhosts" ont été déclarées dans le noeud "connections". Comme ma validation est au niveau le plus haut dans l'arbre, je recupère tout mon arbre en paramêtre de ma fonction dans then().

Test unitaire

Oui cette classe qui fait 3km de long se teste. Je partage ici le test case pour PHPUnit que je me suis écrit. Il est basé sur un jeu de fixtures, il ne vous reste plus qu'à remplacer la classe de configuration par la votre.

<?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;
 
    }
}
 

Le fichier de fixture ressemble à ça, le YAML en haut, le résultat en bas.

#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" => []
]
 

 

Je n'ai pas traiter de la normalisation de l'arbre qui permet de modifier à la volée la structure de ce dernier. Je suis encore un peu perdu sur ce sujet et cela me semble suffisament compliqué pour mériter d'y consacrer un article entier dans le futur.

 

Ajouter un commentaire