From 8ed653b9faad2ddf547b3e1ff88e2ffcfc694046 Mon Sep 17 00:00:00 2001 From: Matthew Weier O'Phinney Date: Thu, 22 Sep 2016 09:06:18 -0500 Subject: [PATCH 1/2] Created tool to inject factory maps into configuration This patch adds a new tool, `create-factory-map`, which will map a given class to a given factory in the specified configuration file, under the provided configuration key (defaulting to `service_manager`). ``` Usage: ./vendor/zendframework/zend-servicemanager/bin/create-factory-map [-h|--help|help] [] Arguments: -h|--help|help This usage message Path to an config file in which to map the factory. If the file does not exist, it will be created. If it does exist, it must return an array. Name of the class to map to a factory. Name of the factory class to use with . [] (Optional) The top-level configuration key under which the factory map should appear; defaults to "service_manager". ``` As part of this work, I moved the methods for dumping configuration files into a trait; this trait is now composed by both the `ConfigDumper` and `FactoryMapperCommand`. --- bin/create-factory-map | 25 +++ composer.json | 1 + src/Tool/ConfigDumper.php | 88 +------- src/Tool/ConfigDumperTrait.php | 101 +++++++++ src/Tool/FactoryMapperCommand.php | 279 +++++++++++++++++++++++++ test/Tool/FactoryMapperCommandTest.php | 209 ++++++++++++++++++ 6 files changed, 616 insertions(+), 87 deletions(-) create mode 100755 bin/create-factory-map create mode 100644 src/Tool/ConfigDumperTrait.php create mode 100644 src/Tool/FactoryMapperCommand.php create mode 100644 test/Tool/FactoryMapperCommandTest.php diff --git a/bin/create-factory-map b/bin/create-factory-map new file mode 100755 index 00000000..403c3883 --- /dev/null +++ b/bin/create-factory-map @@ -0,0 +1,25 @@ +#!/usr/bin/env php +prepareConfig($config); - return sprintf( - self::CONFIG_TEMPLATE, - get_class($this), - date('Y-m-d H:i:s'), - $prepared - ); - } - - /** - * @param array|Traversable $config - * @param int $indentLevel - * @return string - */ - private function prepareConfig($config, $indentLevel = 1) - { - $indent = str_repeat(' ', $indentLevel * 4); - $entries = []; - foreach ($config as $key => $value) { - $key = $this->createConfigKey($key); - $entries[] = sprintf( - '%s%s%s,', - $indent, - $key ? sprintf('%s => ', $key) : '', - $this->createConfigValue($value, $indentLevel) - ); - } - - $outerIndent = str_repeat(' ', ($indentLevel - 1) * 4); - - return sprintf( - "[\n%s\n%s]", - implode("\n", $entries), - $outerIndent - ); - } - - /** - * @param string|int|null $key - * @return null|string - */ - private function createConfigKey($key) - { - if (is_string($key) && class_exists($key)) { - return sprintf('\\%s::class', $key); - } - - if (is_int($key)) { - return null; - } - - return sprintf("'%s'", $key); - } - - /** - * @param mixed $value - * @param int $indentLevel - * @return string - */ - private function createConfigValue($value, $indentLevel) - { - if (is_array($value) || $value instanceof Traversable) { - return $this->prepareConfig($value, $indentLevel + 1); - } - - if (is_string($value) && class_exists($value)) { - return sprintf('\\%s::class', $value); - } - - return var_export($value, true); - } } diff --git a/src/Tool/ConfigDumperTrait.php b/src/Tool/ConfigDumperTrait.php new file mode 100644 index 00000000..042b7bc4 --- /dev/null +++ b/src/Tool/ConfigDumperTrait.php @@ -0,0 +1,101 @@ +prepareConfig($config); + return sprintf( + $this->configTemplate, + get_class($this), + date('Y-m-d H:i:s'), + $prepared + ); + } + + /** + * @param array|Traversable $config + * @param int $indentLevel + * @return string + */ + private function prepareConfig($config, $indentLevel = 1) + { + $indent = str_repeat(' ', $indentLevel * 4); + $entries = []; + foreach ($config as $key => $value) { + $key = $this->createConfigKey($key); + $entries[] = sprintf( + '%s%s%s,', + $indent, + $key ? sprintf('%s => ', $key) : '', + $this->createConfigValue($value, $indentLevel) + ); + } + + $outerIndent = str_repeat(' ', ($indentLevel - 1) * 4); + + return sprintf( + "[\n%s\n%s]", + implode("\n", $entries), + $outerIndent + ); + } + + /** + * @param string|int|null $key + * @return null|string + */ + private function createConfigKey($key) + { + if (is_string($key) && class_exists($key)) { + return sprintf('\\%s::class', $key); + } + + if (is_int($key)) { + return null; + } + + return sprintf("'%s'", $key); + } + + /** + * @param mixed $value + * @param int $indentLevel + * @return string + */ + private function createConfigValue($value, $indentLevel) + { + if (is_array($value) || $value instanceof Traversable) { + return $this->prepareConfig($value, $indentLevel + 1); + } + + if (is_string($value) && class_exists($value)) { + return sprintf('\\%s::class', $value); + } + + return var_export($value, true); + } +} diff --git a/src/Tool/FactoryMapperCommand.php b/src/Tool/FactoryMapperCommand.php new file mode 100644 index 00000000..91c128cc --- /dev/null +++ b/src/Tool/FactoryMapperCommand.php @@ -0,0 +1,279 @@ +Usage: + + %s [-h|--help|help] [] + +Arguments: + + -h|--help|help This usage message + Path to an config file in which to map the factory. + If the file does not exist, it will be created. If + it does exist, it must return an array. + Name of the class to map to a factory. + Name of the factory class to use with . + [] (Optional) The top-level configuration key under which + the factory map should appear; defaults to + "service_manager". + +Reads the provided configuration file, creating it if necessary, and +injects it with a mapping of the given class to its factory. If key is +provided, the factory configuration will be injected under that key, and +not the default "service_manager" key. +EOH; + + /** + * @var ConsoleHelper + */ + private $helper; + + /** + * @var string + */ + private $scriptName; + + /** + * @param string $scriptName + */ + public function __construct($scriptName = self::DEFAULT_SCRIPT_NAME, ConsoleHelper $helper = null) + { + $this->scriptName = $scriptName; + $this->helper = $helper ?: new ConsoleHelper(); + } + + /** + * @param array $args Argument list, minus script name + * @return int Exit status + */ + public function __invoke(array $args) + { + $arguments = $this->parseArgs($args); + + switch ($arguments->command) { + case self::COMMAND_HELP: + $this->help(); + return 0; + case self::COMMAND_ERROR: + $this->helper->writeErrorMessage($arguments->message); + $this->help(STDERR); + return 1; + case self::COMMAND_MAP: + // fall-through + default: + break; + } + + $dumper = new ConfigDumper(); + try { + $config = $this->createFactoryMap( + $arguments->config, + $arguments->class, + $arguments->factory, + $arguments->key + ); + } catch (Exception\InvalidArgumentException $e) { + $this->helper->writeErrorMessage(sprintf( + 'Unable to create factory map for "%s": %s', + $arguments->class, + $e->getMessage() + )); + $this->help(STDERR); + return 1; + } + + file_put_contents($arguments->configFile, $this->dumpConfigFile($config)); + + $this->helper->writeLine(sprintf( + '[DONE] Changes written to %s', + $arguments->configFile + )); + return 0; + } + + /** + * @param array $args + * @return \stdClass + */ + private function parseArgs(array $args) + { + if (! count($args)) { + return $this->createHelpArgument(); + } + + $arg1 = array_shift($args); + + if (in_array($arg1, ['-h', '--help', 'help'], true)) { + return $this->createHelpArgument(); + } + + if (count($args) < 2) { + return $this->createErrorArgument('Missing arguments'); + } + + $configFile = $arg1; + + switch (file_exists($configFile)) { + case true: + $config = require $configFile; + if (! is_array($config)) { + return $this->createErrorArgument(sprintf( + 'Configuration at path "%s" does not return an array.', + $configFile + )); + } + break; + case false: + // fall-through + default: + if (! is_writable(dirname($configFile))) { + return $this->createErrorArgument(sprintf( + 'Configuration at path "%s" cannot be created; directory is not writable.', + $configFile + )); + } + + $config = []; + break; + } + + $class = array_shift($args); + + if (! class_exists($class)) { + return $this->createErrorArgument(sprintf( + 'Class "%s" does not exist or could not be autoloaded.', + $class + )); + } + + $factory = array_shift($args); + + if (! class_exists($factory)) { + return $this->createErrorArgument(sprintf( + 'Factory "%s" does not exist or could not be autoloaded.', + $factory + )); + } + + $key = count($args) ? array_shift($args) : self::DEFAULT_CONFIG_KEY; + + if (array_key_exists($key, $config) + && ! is_array($config[$key]) + ) { + return $this->createErrorArgument(sprintf( + 'Config file "%s" contains the key "%s", but it is not an array; aborting.', + $configFile, + $key + )); + } + + return $this->createArguments(self::COMMAND_MAP, $configFile, $config, $class, $factory, $key); + } + + /** + * @param resource $resource Defaults to STDOUT + * @return void + */ + private function help($resource = STDOUT) + { + $this->helper->writeLine(sprintf( + self::HELP_TEMPLATE, + $this->scriptName + ), true, $resource); + } + + /** + * @param string $command + * @param string $configFile File from which config originates, and to + * which it will be written. + * @param array $config Parsed configuration. + * @param string $class Name of class to map. + * @param string $factory Name of factory to which to map. + * @param string $key Name of config key under which to create mapping. + * @return \stdClass + */ + private function createArguments($command, $configFile, $config, $class, $factory, $key) + { + return (object) [ + 'command' => $command, + 'configFile' => $configFile, + 'config' => $config, + 'class' => $class, + 'factory' => $factory, + 'key' => $key, + ]; + } + + /** + * @param string $message + * @return \stdClass + */ + private function createErrorArgument($message) + { + return (object) [ + 'command' => self::COMMAND_ERROR, + 'message' => $message, + ]; + } + + /** + * @return \stdClass + */ + private function createHelpArgument() + { + return (object) [ + 'command' => self::COMMAND_HELP, + ]; + } + + /** + * @param array $config Configuration to inject. + * @param string $class Class name to map to factory. + * @param string $factory Factory to which to map. + * @param string $key Top-level configuration key under which to create map. + * @return array + * @throws Exception\InvalidArgumentException when $config[$key]['factories'] + * is not an array value. + */ + private function createFactoryMap(array $config, $class, $factory, $key) + { + if (! array_key_exists($key, $config)) { + $config[$key] = []; + } + + if (! array_key_exists('factories', $config[$key])) { + $config[$key]['factories'] = []; + } + + if (! is_array($config[$key]['factories'])) { + throw new Exception\InvalidArgumentException(sprintf( + 'Configuration at key "%s" contains factories configuration, but it is not an array.', + $key + )); + } + + $config[$key]['factories'][$class] = $factory; + return $config; + } +} diff --git a/test/Tool/FactoryMapperCommandTest.php b/test/Tool/FactoryMapperCommandTest.php new file mode 100644 index 00000000..71b77563 --- /dev/null +++ b/test/Tool/FactoryMapperCommandTest.php @@ -0,0 +1,209 @@ +configDir = vfsStream::setup('project'); + $this->helper = $this->prophesize(ConsoleHelper::class); + $this->command = new FactoryMapperCommand(FactoryMapperCommand::class, $this->helper->reveal()); + } + + public function assertHelp($stream = STDOUT) + { + $this->helper->writeLine( + Argument::containingString('Usage:'), + true, + $stream + )->shouldBeCalled(); + } + + public function assertErrorRaised($message) + { + $this->helper->writeErrorMessage( + Argument::containingString($message) + )->shouldBeCalled(); + } + + public function testEmitsHelpWhenNoArgumentsProvided() + { + $command = $this->command; + $this->assertHelp(); + $this->assertEquals(0, $command([])); + } + + public function helpArguments() + { + return [ + 'short' => ['-h'], + 'long' => ['--help'], + 'literal' => ['help'], + ]; + } + + /** + * @dataProvider helpArguments + */ + public function testEmitsHelpWhenHelpArgumentProvidedAsFirstArgument($argument) + { + $command = $this->command; + $this->assertHelp(); + $this->assertEquals(0, $command([$argument])); + } + + public function testEmitsErrorWhenTooFewArgumentsPresent() + { + $command = $this->command; + $this->assertErrorRaised('Missing arguments'); + $this->assertHelp(STDERR); + $this->assertEquals(1, $command(['foo'])); + } + + public function testEmitsErrorWhenConfigFileDoesNotReturnAnArray() + { + $command = $this->command; + vfsStream::newFile('config/invalid.config.php') + ->at($this->configDir) + ->setContent('<' . "?php\n// invalid"); + $config = vfsStream::url('project/config/invalid.config.php'); + + $this->assertErrorRaised('Configuration at path "' . $config . '" does not return an array.'); + $this->assertHelp(STDERR); + $this->assertEquals(1, $command([$config, 'Not\A\Real\Class', 'Not\A\Real\Factory'])); + } + + public function testEmitsErrorWhenUnableToCreateConfigFile() + { + $command = $this->command; + vfsStream::newDirectory('config', 0551) + ->at($this->configDir); + $config = vfsStream::url('project/config/invalid.config.php'); + + $this->assertErrorRaised( + 'Configuration at path "' . $config . '" cannot be created; directory is not writable.' + ); + $this->assertHelp(STDERR); + $this->assertEquals(1, $command([$config, 'Not\A\Real\Class', 'Not\A\Real\Factory'])); + } + + public function testEmitsErrorWhenClassIsNotFound() + { + $command = $this->command; + vfsStream::newFile('config/test.config.php') + ->at($this->configDir) + ->setContent(file_get_contents(realpath(__DIR__ . '/../TestAsset/config/test.config.php'))); + $config = vfsStream::url('project/config/test.config.php'); + $this->assertErrorRaised('Class "Not\\A\\Real\\Class" does not exist or could not be autoloaded.'); + $this->assertHelp(STDERR); + $this->assertEquals(1, $command([$config, 'Not\A\Real\Class', 'Not\A\Real\Factory'])); + } + + public function testEmitsErrorWhenFactoryIsNotFound() + { + $command = $this->command; + vfsStream::newFile('config/test.config.php') + ->at($this->configDir) + ->setContent(file_get_contents(realpath(__DIR__ . '/../TestAsset/config/test.config.php'))); + $config = vfsStream::url('project/config/test.config.php'); + $this->assertErrorRaised('Factory "Not\\A\\Real\\Factory" does not exist or could not be autoloaded.'); + $this->assertHelp(STDERR); + $this->assertEquals(1, $command([$config, SimpleDependencyObject::class, 'Not\A\Real\Factory'])); + } + + public function testEmitsErrorWhenConfigUnderKeyIsMalformed() + { + $command = $this->command; + vfsStream::newFile('config/test.config.php') + ->at($this->configDir) + ->setContent('<' . "?php\nreturn [\n 'foo' => false,\n];"); + $config = vfsStream::url('project/config/test.config.php'); + $this->assertErrorRaised(sprintf( + 'Config file "%s" contains the key "%s", but it is not an array; aborting.', + $config, + 'foo' + )); + $this->assertHelp(STDERR); + $this->assertEquals(1, $command([$config, SimpleDependencyObject::class, InvokableFactory::class, 'foo'])); + } + + public function testEmitsErrorWhenFactoriesValueUnderKeyIsMalformed() + { + $command = $this->command; + vfsStream::newFile('config/test.config.php') + ->at($this->configDir) + ->setContent('<' . "?php\nreturn [\n 'foo' => [\n 'factories' => false,\n ],\n];"); + $config = vfsStream::url('project/config/test.config.php'); + $this->assertErrorRaised( + 'Configuration at key "foo" contains factories configuration, but it is not an array.' + ); + $this->assertHelp(STDERR); + $this->assertEquals(1, $command([$config, SimpleDependencyObject::class, InvokableFactory::class, 'foo'])); + } + + public function testWritesMapToConfigFileWhenSuccessful() + { + $command = $this->command; + vfsStream::newFile('config/test.config.php') + ->at($this->configDir) + ->setContent(file_get_contents(__DIR__ . '/../TestAsset/config/test.config.php')); + $config = vfsStream::url('project/config/test.config.php'); + + $this->assertEquals( + 0, + $command([$config, SimpleDependencyObject::class, InvokableFactory::class, 'controllers']) + ); + + $generated = include $config; + $this->assertInternalType('array', $generated); + $this->assertArrayHasKey('controllers', $generated); + $this->assertInternalType('array', $generated['controllers']); + $this->assertArrayHasKey('factories', $generated['controllers']); + $this->assertInternalType('array', $generated['controllers']['factories']); + $this->assertArrayHasKey(SimpleDependencyObject::class, $generated['controllers']['factories']); + $this->assertEquals( + InvokableFactory::class, + $generated['controllers']['factories'][SimpleDependencyObject::class] + ); + } + + public function testCanCreateConfigFileWhenSuccessful() + { + $command = $this->command; + vfsStream::newDirectory('config', 0775) + ->at($this->configDir); + $config = vfsStream::url('project/config/test.config.php'); + + $this->assertEquals( + 0, + $command([$config, SimpleDependencyObject::class, InvokableFactory::class, 'controllers']) + ); + + $generated = include $config; + $this->assertInternalType('array', $generated); + $this->assertArrayHasKey('controllers', $generated); + $this->assertInternalType('array', $generated['controllers']); + $this->assertArrayHasKey('factories', $generated['controllers']); + $this->assertInternalType('array', $generated['controllers']['factories']); + $this->assertArrayHasKey(SimpleDependencyObject::class, $generated['controllers']['factories']); + $this->assertEquals( + InvokableFactory::class, + $generated['controllers']['factories'][SimpleDependencyObject::class] + ); + } +} From 7a0465159ad0a180135fcde7a83b896337e7152b Mon Sep 17 00:00:00 2001 From: Matthew Weier O'Phinney Date: Thu, 22 Sep 2016 09:17:45 -0500 Subject: [PATCH 2/2] Documented create-factory-map tool with examples --- doc/book/console-tools.md | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/doc/book/console-tools.md b/doc/book/console-tools.md index bb3e6cf8..87ff3924 100644 --- a/doc/book/console-tools.md +++ b/doc/book/console-tools.md @@ -61,3 +61,53 @@ $ ./vendor/bin/generate-factory-for-class \ The class generated implements `Zend\ServiceManager\Factory\FactoryInterface`, and is generated within the same namespace as the originating class. + +## create-factory-map + +```bash +Usage: + + create-factory-map [-h|--help|help] [] + +Arguments: + + -h|--help|help This usage message + Path to an config file in which to map the factory. + If the file does not exist, it will be created. If + it does exist, it must return an array. + Name of the class to map to a factory. + Name of the factory class to use with . + [] (Optional) The top-level configuration key under which + the factory map should appear; defaults to + "service_manager". + +Reads the provided configuration file, creating it if necessary, and +injects it with a mapping of the given class to its factory. If key is +provided, the factory configuration will be injected under that key, and +not the default "service_manager" key. +``` + +This utility maps the given class to the given factory, writing it to the +specified configuration file. If a key is given, then the mapping will occur +under that top-level key (the default is the `service_manager` key). + +As an example, if using this to map a controller for a zend-mvc application, you +might use: + +```bash +$ ./vendor/bin/create-factory-map \ +> module/Application/config/module.config.php \ +> "Application\\Controller\\PingController" \ +> "Zend\\Mvc\Controller\\LazyControllerAbstractFactory" \ +> controllers +``` + +For Expressive, you might do the following to map middleware to a factory: + +```bash +$ ./vendor/bin/create-factory-map \ +> config/autoload/routes.global.php \ +> "Application\\Middleware\\PingMiddleware" \ +> "Zend\\ServiceManager\\AbstractFactory\\ReflectionBasedAbstractFactory" \ +> dependencies +```