biurad/flange

View on GitHub
src/Extensions/Symfony/ValidatorExtension.php

Summary

Maintainability
D
2 days
Test Coverage
<?php declare(strict_types=1);

/*
 * This file is part of Biurad opensource projects.
 *
 * @copyright 2019 Biurad Group (https://biurad.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Flange\Extensions\Symfony;

use Flange\KernelInterface;
use Rade\DI\Container;
use Rade\DI\ContainerBuilder;
use Rade\DI\Definition;
use Rade\DI\Definitions\Parameter;
use Rade\DI\Definitions\Reference;
use Rade\DI\Definitions\Statement;
use Rade\DI\Definitions\TaggedLocator;
use Rade\DI\Extensions\AliasedInterface;
use Rade\DI\Extensions\BootExtensionInterface;
use Rade\DI\Extensions\ExtensionInterface;
use Rade\DI\Services\ServiceLocator;
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Resource\FileExistenceResource;
use Symfony\Component\Form\Form;
use Symfony\Component\Validator\Command\DebugCommand;
use Symfony\Component\Validator\Constraints\EmailValidator;
use Symfony\Component\Validator\Constraints\NotCompromisedPasswordValidator;
use Symfony\Component\Validator\ContainerConstraintValidatorFactory;
use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface;
use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\ValidatorBuilder;

/**
 * Symfony component validator extension.
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
class ValidatorExtension implements AliasedInterface, BootExtensionInterface, ConfigurationInterface, ExtensionInterface
{
    use Traits\FilesMappingTrait;

    /**
     * {@inheritdoc}
     */
    public function getAlias(): string
    {
        return 'validator';
    }

    /**
     * {@inheritdoc}
     */
    public function getConfigTreeBuilder(): TreeBuilder
    {
        $treeBuilder = new TreeBuilder(__CLASS__);

        $treeBuilder->getRootNode()
            ->info('validation configuration')
            ->canBeEnabled()
            ->children()
                ->scalarNode('cache')->end()
                ->scalarNode('config_dir')->defaultNull()->end()
                ->booleanNode('enable_annotations')->defaultFalse()->end()
                ->arrayNode('static_method')
                    ->defaultValue(['loadValidatorMetadata'])
                    ->prototype('scalar')->end()
                    ->treatFalseLike([])
                    ->validate()->castToArray()->end()
                ->end()
                ->scalarNode('translation_domain')->defaultValue('validators')->end()
                ->enumNode('email_validation_mode')->values(['html5', 'loose', 'strict'])->defaultValue('loose')->end()
                ->arrayNode('mapping')
                    ->addDefaultsIfNotSet()
                    ->fixXmlConfig('path')
                    ->children()
                        ->arrayNode('paths')
                            ->prototype('scalar')->end()
                        ->end()
                    ->end()
                ->end()
                ->arrayNode('not_compromised_password')
                    ->canBeDisabled()
                    ->children()
                        ->booleanNode('enabled')
                            ->defaultTrue()
                            ->info('When disabled, compromised passwords will be accepted as valid.')
                        ->end()
                        ->scalarNode('endpoint')
                            ->defaultNull()
                            ->info('API endpoint for the NotCompromisedPassword Validator.')
                        ->end()
                    ->end()
                ->end()
                ->arrayNode('auto_mapping')
                    ->info('A collection of namespaces for which auto-mapping will be enabled by default, or null to opt-in with the EnableAutoMapping constraint.')
                    ->example([
                        'App\\Entity\\' => [],
                        'App\\WithSpecificLoaders\\' => ['validator.property_info_loader'],
                    ])
                    ->useAttributeAsKey('namespace')
                    ->normalizeKeys(false)
                    ->beforeNormalization()
                        ->ifArray()
                        ->then(function (array $values): array {
                            foreach ($values as $k => $v) {
                                if (isset($v['service'])) {
                                    continue;
                                }

                                if (isset($v['namespace'])) {
                                    $values[$k]['services'] = [];

                                    continue;
                                }

                                if (!\is_array($v)) {
                                    $values[$v]['services'] = [];
                                    unset($values[$k]);

                                    continue;
                                }

                                $tmp = $v;
                                unset($values[$k]);
                                $values[$k]['services'] = $tmp;
                            }

                            return $values;
                        })
                    ->end()
                    ->arrayPrototype()
                        ->fixXmlConfig('service')
                        ->children()
                            ->arrayNode('services')
                                ->prototype('scalar')->end()
                            ->end()
                        ->end()
                    ->end()
                ->end()
            ->end()
        ;

        return $treeBuilder;
    }

    /**
     * {@inheritdoc}
     */
    public function register(Container $container, array $configs = []): void
    {
        if (!$configs['enabled']) {
            return;
        }

        if (!\class_exists(Validation::class)) {
            throw new \LogicException('Validation support cannot be enabled as the Validator component is not installed. Try running "composer require symfony/validator".');
        }

        $validatorBuilder = $container->set('validator.builder', new Definition(ValidatorBuilder::class))->typed(ValidatorBuilder::class)
            ->bind('setConstraintValidatorFactory', [new Reference('validator.validator_factory')])
            ->bind('setTranslationDomain', [new Parameter('validator.translation_domain')]);

        if ($container->hasExtension(TranslationExtension::class)) {
            $validatorBuilder->bind('setTranslator', [new Reference('translator')]);
        }

        if (!empty($container->getExtensionConfig(CacheExtension::class, $container->hasExtension(FrameworkExtension::class) ? 'symfony' : null))) {
            $container->set('validator.mapping.cache.adapter', new Definition(PhpArrayAdapter::class.'::create', ['%project.cache_dir%/validation.php', new Reference('cache.system')]))->public(false);

            if (!$container->parameters['debug']) {
                $validatorBuilder->bind('setMappingCache', [new Reference('validator.mapping.cache.adapter')]);
            }
        }

        $container->set('validator', new Definition([new Reference('validator.builder'), 'getValidator']))->typed(ValidatorInterface::class, MetadataFactoryInterface::class);
        $container->set('validator.validator_factory', new Definition(ContainerConstraintValidatorFactory::class));
        $container->set('validator.email', new Definition(EmailValidator::class, [$configs['email_validation_mode']]))->public(false)->tag('validator.constraint_validator', ['alias' => EmailValidator::class]);

        if (\class_exists(\Symfony\Component\HttpClient\HttpClient::class)) {
            $container->set('validator.not_compromised_password', new Definition(NotCompromisedPasswordValidator::class))
                ->public(false)
                ->arg(2, $configs['not_compromised_password']['enabled'])
                ->arg(3, $configs['not_compromised_password']['endpoint'])
                ->tag('validator.constraint_validator', ['alias' => NotCompromisedPasswordValidator::class]);
        }

        $container->alias('validator.mapping.class_metadata_factory', 'validator');
        $container->parameters['validator.translation_domain'] = $configs['translation_domain'] ?? 'validators';
        $container->parameters['validator.auto_mapping'] = $configs['auto_mapping'];
        $container->parameters['validator.mapping.paths'] = $configs['mapping']['paths'];

        if (\array_key_exists('enable_annotations', $configs) && $configs['enable_annotations']) {
            $validatorBuilder->bind('enableAnnotationMapping', [true]);

            if ($container->has('annotation.doctrine')) {
                $validatorBuilder->bind('setDoctrineAnnotationReader', [new Reference('annotation.doctrine')]);
            }
        }

        if (\array_key_exists('static_method', $configs) && $configs['static_method']) {
            foreach ($configs['static_method'] as $methodName) {
                $validatorBuilder->bind('addMethodMapping', [$methodName]);
            }
        }

        if ($container->hasExtension(PropertyInfoExtension::class)) {
            $container->set('validator.property_info_loader', new Definition(PropertyInfoLoader::class))->public(false)->tag('validator.auto_mapper');
        }

        if ($container->has('console')) {
            $container->set('console.command.validator_debug', new Definition(DebugCommand::class))->public(false)->tag('console.command', 'debug:validator');
        }
    }

    /**
     * {@inheritdoc}
     */
    public function boot(Container $container): void
    {
        if (!$container->has('validator')) {
            return;
        }

        $files = ['xml' => [], 'yml' => []];
        $globalNamespaces = $servicesToNamespaces = $validators = [];
        $validatorBuilder = $container->definition('validator.builder');
        $this->registerValidatorMapping($container, $files);

        if (!empty($files['xml'])) {
            $validatorBuilder->bind('addXmlMappings', [$files['xml']]);
        }

        if (!empty($files['yml'])) {
            $validatorBuilder->bind('addYamlMappings', [$files['yml']]);
        }

        foreach ($container->parameters['validator.auto_mapping'] as $namespace => $value) {
            if ([] === $value['services']) {
                $globalNamespaces[] = $namespace;

                continue;
            }

            foreach ($value['services'] as $service) {
                $servicesToNamespaces[$service][] = $namespace;
            }
        }

        foreach ($container->tagged('validator.auto_mapper') as $AId => $tags) {
            $regexp = $this->getRegexp(\array_merge($globalNamespaces, $servicesToNamespaces[$AId] ?? []));
            $validatorBuilder->bind('addLoader', [new Reference($AId)]);
            $container->definition($AId)->arg('classValidatorRegexp', $regexp);
        }

        foreach ($container->tagged('validator.constraint_validator') as $cId => $attributes) {
            $definition = $container->definition($cId);
            $validators[$definition->getEntity()] = $cRef = new Reference($cId);

            if (isset($attributes['alias'])) {
                $validators[$attributes['alias']] = $cRef;
            }
        }

        if ($container->tagged('validator.initializer')) {
            $validatorBuilder->bind('addObjectInitializers', [new TaggedLocator('validator.initializer')]);
        }

        $container->definition('validator.validator_factory')->arg(0, new Statement(ServiceLocator::class, $validators));
        unset($container->parameters['validator.auto_mapping']);
    }

    private function registerValidatorMapping(Container $container, array &$files): void
    {
        $fileRecorder = function ($extension, $path) use (&$files): void {
            $files['yaml' === $extension ? 'yml' : $extension][] = $path;
        };

        if ($container->hasExtension(FormExtension::class) || \class_exists(Form::class)) {
            $reflClass = new \ReflectionClass(Form::class);
            $fileRecorder('xml', \dirname($reflClass->getFileName()).'/Resources/config/validation.xml');
        }

        if (isset($configs['config_dir'])) {
            if ($container instanceof ContainerBuilder) {
                $container->addResource(new FileExistenceResource($dir = $container->parameter($configs['config_dir'])));
            }
            $this->registerMappingFilesFromDir($dir, $fileRecorder);
        }

        if ($container instanceof KernelInterface) {
            foreach ($container->getExtensions() as $extension) {
                try {
                    $configDir = $container->getLocation('@'.$extension::class.'/');
                    $configDir = \is_dir($configDir.'Resources/config') ? $configDir.'Resources/config' : $configDir.'config';
                } catch (\InvalidArgumentException $e) {
                    continue;
                }

                if (
                    \file_exists($file = $configDir.'/validation.yaml') ||
                    \file_exists($file = $configDir.'/validation.yml')
                ) {
                    $fileRecorder('yml', $file);
                }

                if (\file_exists($file = $configDir.'/validation.xml')) {
                    $fileRecorder('xml', $file);
                }

                if (\file_exists($dir = $configDir.'/validation')) {
                    $this->registerMappingFilesFromDir($dir, $fileRecorder);
                }
            }
        }

        $this->registerMappingFilesFromConfig($container, $container->parameters['validator.mapping.paths'], $fileRecorder);
        unset($container->parameters['validator.mapping.paths']);
    }

    /**
     * Builds a regexp to check if a class is auto-mapped.
     */
    private function getRegexp(array $patterns): ?string
    {
        if (!$patterns) {
            return null;
        }

        $regexps = [];

        foreach ($patterns as $pattern) {
            // Escape namespace
            $regex = \preg_quote(\ltrim($pattern, '\\'));

            // Wildcards * and **
            $regex = \strtr($regex, ['\\*\\*' => '.*?', '\\*' => '[^\\\\]*?']);

            // If this class does not end by a slash, anchor the end
            if (!\str_ends_with($regex, '\\')) {
                $regex .= '$';
            }

            $regexps[] = '^'.$regex;
        }

        return \sprintf('{%s}', \implode('|', $regexps));
    }
}