From 1e39d42cfb9f07429f917435d688795223087baf Mon Sep 17 00:00:00 2001 From: Amine Ben hammou Date: Thu, 14 Sep 2017 04:35:58 +0200 Subject: [PATCH] add config loader to Command --- .github/ISSUE_TEMPLATE.md | 2 - README.md | 43 +++++++++++++- src/Command.php | 24 +++++++- src/Config/Config.php | 36 +++++++++++ src/Config/ConfigLoader.php | 48 +++++++++++++++ src/Helpers/Decoders/JsonDecoder.php | 13 ++++ src/Interfaces/Config/ConfigInterface.php | 11 ++++ .../Config/ConfigLoaderInterface.php | 12 ++++ src/Interfaces/Helpers/DecoderInterface.php | 8 +++ src/SubCommand.php | 1 + src/Template/TemplateLoader.php | 3 +- tests/Acceptance/LoadsConfigurationTest.php | 29 +++++++++ tests/Unit/Config/ConfigLoaderTest.php | 59 +++++++++++++++++++ tests/Unit/Config/ConfigTest.php | 37 ++++++++++++ 14 files changed, 320 insertions(+), 6 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 src/Config/Config.php create mode 100644 src/Config/ConfigLoader.php create mode 100644 src/Helpers/Decoders/JsonDecoder.php create mode 100644 src/Interfaces/Config/ConfigInterface.php create mode 100644 src/Interfaces/Config/ConfigLoaderInterface.php create mode 100644 src/Interfaces/Helpers/DecoderInterface.php create mode 100644 tests/Acceptance/LoadsConfigurationTest.php create mode 100644 tests/Unit/Config/ConfigLoaderTest.php create mode 100644 tests/Unit/Config/ConfigTest.php diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index d75eea7..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,2 +0,0 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 3bdf616..a4a7536 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,11 @@ A library to build command line applications using PHP. This is part of the [Tar - [Defining Arguments and Options](#defining-arguments-and-options) -- [Reading Arguments and Options Interactively](#reading-arguments-and-options-interactively) **New on version 1.1.0** +- [Reading Arguments and Options Interactively](#reading-arguments-and-options-interactively) **Since version 1.1.0** - [Handeling The Filesystem](#handeling-the-filesystem) +- [Loading Configuration](#loading-configuration) **New on version 1.2.0** + - [Rendering Templates](#rendering-templates) - [Adding SubCommands](#adding-sub-commands) @@ -330,6 +332,43 @@ protected function init() } ``` +# Loading Configuration + +In addition to the command line arguments, the user can provide data to your command via configuration files. This is useful because it lets you define a default configuration file and lets the user change some values with a custom configuration file. + +Let's write an example command which have a global configuration file at `/home/user/.config.json`. It lets the user customize value via the file `config.json` in the current directory: + +```php +class ConfigCommand extends Command { + protected function init() + { + // ... + $this->configPaths(['/home/user/.config.json', 'config.json']); + } + + protected function execute() + { + // getting a config value + // assuming that $data is the merged content of the config files + $this->config('name'); // returns $data['name'] + $this->config('foo.bar.baz'); // returns $data['foo']['bar']['baz'] + $this->config(); // returns $data + } +} +``` + +- The method `configPaths` take a list of paths, loads them and merges them into one configuration (it use `array_replace_recursive` internally). + +- The method `config` is used to retreive configuration values. + +Note that: + +- Only `json` files are supported as configuration files for the moment. Please open an issue or make a Pull Request to add other formats. + +- `configPaths` will silently ignore paths which does not exist in the filesystem. + +- A subcommand will always have the same configuration data as its parent command, unless `configPaths` is used to override it. + # Rendering Templates The `Command` class gives also possibility to render templates. The default template engine is [Twig](https://twig.symfony.com) but you can use your favorite one by implementing the interfaces `TemplateLoaderInterface` and `TemplateInterface`. @@ -588,6 +627,8 @@ Please take a look at the examples in the `examples` directory, and try using th # Development Notes +- **Version 1.2.0** Commands can now load configuration from multiple JSON files. + - **Version 1.1.1** Fixed a bug with subcommands not having the default `--help`, `--version` and `-i` subcommands. - **Version 1.1.0** The flag `-i` added to commands to enable interactive reading of arguments and options. diff --git a/src/Command.php b/src/Command.php index e6a83cd..84c3eca 100644 --- a/src/Command.php +++ b/src/Command.php @@ -3,6 +3,7 @@ use Tarsana\Command\Commands\HelpCommand; use Tarsana\Command\Commands\InteractiveCommand; use Tarsana\Command\Commands\VersionCommand; +use Tarsana\Command\Config\ConfigLoader; use Tarsana\Command\Console\Console; use Tarsana\Command\Console\ExceptionPrinter; use Tarsana\Command\Interfaces\Console\ConsoleInterface; @@ -33,6 +34,7 @@ class Command { protected $console; protected $fs; protected $templatesLoader; + protected $config; public static function create(callable $action = null) { $command = new Command; @@ -51,6 +53,7 @@ public function __construct() ->options([]) ->console(new Console) ->fs(new Filesystem('.')) + ->configPaths([]) ->setupSubCommands() ->init(); } @@ -233,7 +236,8 @@ public function templatesLoader(TemplateLoaderInterface $value = null) return $this; } - public function templatesPath(string $path, string $cachePath = null) { + public function templatesPath(string $path, string $cachePath = null) + { $this->templatesLoader = new TemplateLoader($path, $cachePath); foreach ($this->commands as $name => $command) { $command->templatesLoader = $this->templatesLoader(); @@ -241,12 +245,28 @@ public function templatesPath(string $path, string $cachePath = null) { return $this; } - public function template(string $name) { + public function template(string $name) + { if (null === $this->templatesLoader) throw new \Exception("Please initialize the templates loader before trying to load templates!"); return $this->templatesLoader->load($name); } + public function configPaths(array $paths) + { + $configLoader = new ConfigLoader($this->fs); + $this->config = $configLoader->load($paths); + foreach ($this->commands as $name => $command) { + $command->config = $this->config; + } + return $this; + } + + public function config(string $path = null) + { + return $this->config->get($path); + } + /** * action getter and setter. * diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..a316125 --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,36 @@ +data = $data; + } + + /** + * Gets a configuration value by path. + */ + public function get(string $path = null) + { + if (null === $path) + return $this->data; + $keys = explode('.', $path); + $value = $this->data; + foreach ($keys as $key) { + if (!is_array($value) || !array_key_exists($key, $value)) + throw new \Exception("Unable to find a configuration value with path '{$path}'"); + $value = $value[$key]; + } + return $value; + } +} diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php new file mode 100644 index 0000000..5d62c7b --- /dev/null +++ b/src/Config/ConfigLoader.php @@ -0,0 +1,48 @@ + JsonDecoder::class + ]; + + protected $fs; + + public function __construct(FilesystemInterface $fs) + { + $this->fs = $fs; + } + + public function load(array $paths) : ConfigInterface + { + if (empty($paths)) + return new Config([]); + $data = []; + foreach ($paths as $path) { + $data[] = $this->decode($path); + } + $data = call_user_func_array('array_replace_recursive', $data); + return new Config($data); + } + + protected function decode(string $path) : array { + if (! $this->fs->isFile($path)) + return []; + $file = $this->fs->file($path); + $ext = $file->extension(); + if (! array_key_exists($ext, static::$decoders)) + throw new \Exception("Unknown configuration file extension '{$ext}'"); + $decoderClass = static::$decoders[$ext]; + $decoder = new $decoderClass; + return $decoder->decode($file->content()); + } +} diff --git a/src/Helpers/Decoders/JsonDecoder.php b/src/Helpers/Decoders/JsonDecoder.php new file mode 100644 index 0000000..4ae16cd --- /dev/null +++ b/src/Helpers/Decoders/JsonDecoder.php @@ -0,0 +1,13 @@ +console($parent->console()) ->fs($parent->fs) ->templatesLoader($parent->templatesLoader); + $this->config = $parent->config; } /** diff --git a/src/Template/TemplateLoader.php b/src/Template/TemplateLoader.php index 3cc9464..f949bea 100644 --- a/src/Template/TemplateLoader.php +++ b/src/Template/TemplateLoader.php @@ -2,6 +2,7 @@ use Tarsana\Command\Interfaces\Template\TemplateInterface; use Tarsana\Command\Interfaces\Template\TemplateLoaderInterface; +use Tarsana\Command\Template\Twig\TwigLoader; use Tarsana\IO\Filesystem; @@ -18,7 +19,7 @@ class TemplateLoader implements TemplateLoaderInterface { * @var array */ protected static $providers = [ - 'twig' => 'Tarsana\Command\Template\Twig\TwigLoader' + 'twig' => TwigLoader::class ]; /** diff --git a/tests/Acceptance/LoadsConfigurationTest.php b/tests/Acceptance/LoadsConfigurationTest.php new file mode 100644 index 0000000..fd24afc --- /dev/null +++ b/tests/Acceptance/LoadsConfigurationTest.php @@ -0,0 +1,29 @@ +configPaths(['/home/user/.config.json', 'config.json']); + + $name = $app->config('name'); + $repoURL = $app->config('repo.url'); + $app->console()->line("{$name}:{$repoURL}"); + }); + + $this->fs + ->file('/home/user/.config.json', true) + ->content(json_encode(['name' => 'user'])); + + $this->fs + ->file('config.json', true) + ->content(json_encode(['repo' => ['url' => 'tarsana']])); + + $this->command($c) + ->prints('user:tarsana
'); + } +} diff --git a/tests/Unit/Config/ConfigLoaderTest.php b/tests/Unit/Config/ConfigLoaderTest.php new file mode 100644 index 0000000..65eefdb --- /dev/null +++ b/tests/Unit/Config/ConfigLoaderTest.php @@ -0,0 +1,59 @@ +mkdir('.', 0777, true); + $this->fs = new Filesystem('.', $adapter); + $this->loader = new ConfigLoader($this->fs); + } + + public function test_it_loads_single_config() { + $data = ['name' => 'foo', 'repo' => 'bar']; + $this->fs->file('config.json', true) + ->content(json_encode($data)); + $this->assertEquals($data, $this->loader->load(['config.json'])->get()); + } + + public function test_it_loads_many_configs() { + $data1 = ['name' => 'foo', 'repo' => 'bar']; + $data2 = ['repo' => ['type' => 'git']]; + $data3 = ['repo' => ['name' => 'baz'], 'descr' => 'blabla']; + $merged = ['name' => 'foo', 'repo' => ['type' => 'git', 'name' => 'baz'], 'descr' => 'blabla']; + + $this->fs->file('/opt/command/config.json', true)->content(json_encode($data1)); + $this->fs->file('/home/user/config.json', true)->content(json_encode($data2)); + $this->fs->file('config.json', true)->content(json_encode($data3)); + + $this->assertEquals($merged, $this->loader->load([ + '/opt/command/config.json', + '/home/user/config.json', + '/projects/config.json', // this is missing + 'config.json' + ])->get()); + } + + public function test_it_loads_empty_config_when_no_path_is_given() { + $this->assertEquals([], $this->loader->load([])->get()); + } + + /** + * @expectedException Exception + */ + public function test_it_throws_exception_when_unknown_extension() { + $this->fs->file('config.xml', true); + $this->loader->load(['config.xml']); + } + +} diff --git a/tests/Unit/Config/ConfigTest.php b/tests/Unit/Config/ConfigTest.php new file mode 100644 index 0000000..63ad7b9 --- /dev/null +++ b/tests/Unit/Config/ConfigTest.php @@ -0,0 +1,37 @@ + 'Foo', + 'urls' => [ + 'github' => 'some-link-here' + ] + ]; + + $c = new Config($data); + $this->assertEquals($data, $c->get()); + $this->assertEquals('Foo', $c->get('name')); + $this->assertEquals('some-link-here', $c->get('urls.github')); + } + + /** + * @expectedException Exception + */ + public function test_it_throws_exception() { + $data = [ + 'name' => 'Foo', + 'urls' => [ + 'github' => 'some-link-here' + ] + ]; + $c = new Config($data); + $c->get('bar'); + } + +}