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 + [] + +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 +``` diff --git a/src/Tool/ConfigDumper.php b/src/Tool/ConfigDumper.php index 24661e7e..dbc23e40 100644 --- a/src/Tool/ConfigDumper.php +++ b/src/Tool/ConfigDumper.php @@ -15,15 +15,7 @@ class ConfigDumper { - const CONFIG_TEMPLATE = <<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] + ); + } +}