diff --git a/.gitignore b/.gitignore index 8924c80..2e18838 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ composer.lock /vendor/ -/.idea/ \ No newline at end of file +/.idea/ +.phpunit.result.cache diff --git a/.travis.yml b/.travis.yml index 3ba22f2..ef07f04 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,4 +12,4 @@ before_script: composer install --ignore-platform-reqs script: - vendor/bin/phpunit --configuration phpunit.xml < tests/input.txt -- vendor/bin/psalm --show-info=true +- vendor/bin/phpcs -p diff --git a/README.md b/README.md index 0821434..1e9aa09 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,45 @@ And than, run from command line: php script.php command-name --email=me@example.com ``` +### Hooks + +There are three types of hooks, init hooks, shutdown hooks and error hooks. Init hooks are executed before the task is executed. Shutdown hook is executed after task is executed before application shuts down. Finally error hooks are executed whenever there's an error in the application lifecycle. You can provide multiple hooks for each stage. + +```php +require_once __DIR__ . '/../../vendor/autoload.php'; + +use Utopia\App; +use Utopia\Request; +use Utopia\Response; + +CLI::setResource('res1', function() { + return 'resource 1'; +}) + +CLI::init() + inject('res1') + ->action(function($res1) { + Console::info($res1); + }); + +CLI::error() + ->inject('error') + ->action(function($error) { + Console::error('Error occurred ' . $error); + }); + +$cli = new CLI(); + +$cli + ->task('command-name') + ->param('email', null, new Wildcard()) + ->action(function ($email) { + Console::success($email); + }); + +$cli->run(); +``` + ### Log Messages ```php diff --git a/composer.json b/composer.json index 6db2a02..66086c7 100755 --- a/composer.json +++ b/composer.json @@ -10,6 +10,11 @@ "email": "eldad@appwrite.io" } ], + "scripts": { + "test": "vendor/bin/phpunit --configuration phpunit.xml < tests/input.txt", + "lint": "vendor/bin/phpcs", + "format": "vendor/bin/phpcbf" + }, "autoload": { "psr-4": {"Utopia\\CLI\\": "src/CLI"} }, @@ -19,7 +24,7 @@ }, "require-dev": { "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.0.1" + "squizlabs/php_codesniffer": "^3.6" }, "minimum-stability": "dev" } diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..342c6bc --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,16 @@ + + + + ./src + ./tests + + + + + * + + + + * + + \ No newline at end of file diff --git a/src/CLI/CLI.php b/src/CLI/CLI.php index 4aed97b..6731963 100644 --- a/src/CLI/CLI.php +++ b/src/CLI/CLI.php @@ -3,6 +3,7 @@ namespace Utopia\CLI; use Exception; +use Utopia\Hook; use Utopia\Validator; class CLI @@ -14,7 +15,17 @@ class CLI * * @var string */ - protected $command = ''; + protected string $command = ''; + + /** + * @var array + */ + protected array $resources = []; + + /** + * @var array + */ + protected static array $resourcesCallbacks = []; /** * Args @@ -23,7 +34,7 @@ class CLI * * @var array */ - protected $args = []; + protected array $args = []; /** * Tasks @@ -32,34 +43,34 @@ class CLI * * @var array */ - protected $tasks = []; + protected array $tasks = []; /** * Error * * An error callback * - * @var callable + * @var Hook[] */ - protected $error; + protected $errors = []; /** * Init * * A callback function that is initialized on application start * - * @var callable[] + * @var Hook[] */ - protected $init = []; + protected array $init = []; /** * Shutdown * * A callback function that is initialized on application end * - * @var callable[] + * @var Hook[] */ - protected $shutdown = []; + protected array $shutdown = []; /** * CLI constructor. @@ -73,7 +84,7 @@ public function __construct(array $args = []) throw new Exception('CLI tasks can only work from the command line'); } - $this->args = $this->parse((!empty($args) || !isset($_SERVER['argv'])) ? $args: $_SERVER['argv']); + $this->args = $this->parse((!empty($args) || !isset($_SERVER['argv'])) ? $args : $_SERVER['argv']); $this->error = function (Exception $error): void { Console::error($error->getMessage()); @@ -87,13 +98,13 @@ public function __construct(array $args = []) * * Set a callback function that will be initialized on application start * - * @param callable $callback - * @return $this + * @return Hook */ - public function init(callable $callback): self + public function init(): Hook { - $this->init[] = $callback; - return $this; + $hook = new Hook(); + $this->init[] = $hook; + return $hook; } /** @@ -101,13 +112,13 @@ public function init(callable $callback): self * * Set a callback function that will be initialized on application end * - * @param $callback - * @return $this + * @return Hook */ - public function shutdown(callable $callback): self + public function shutdown(): Hook { - $this->shutdown[] = $callback; - return $this; + $hook = new Hook(); + $this->shutdown[] = $hook; + return $hook; } /** @@ -115,22 +126,22 @@ public function shutdown(callable $callback): self * * An error callback for failed or no matched requests * - * @param $callback - * @return $this + * @return Hook */ - public function error(callable $callback): self + public function error(): Hook { - $this->error = $callback; - return $this; + $hook = new Hook(); + $this->errors[] = $hook; + return $hook; } /** * Task - * + * * Add a new command task - * + * * @param string $name - * + * * @return Task */ public function task(string $name): Task @@ -142,6 +153,65 @@ public function task(string $name): Task return $task; } + /** + * If a resource has been created return it, otherwise create it and then return it + * + * @param string $name + * @param bool $fresh + * @return mixed + * @throws Exception + */ + public function getResource(string $name, bool $fresh = false): mixed + { + if (!\array_key_exists($name, $this->resources) || $fresh || self::$resourcesCallbacks[$name]['reset']) { + if (!\array_key_exists($name, self::$resourcesCallbacks)) { + throw new Exception('Failed to find resource: "' . $name . '"'); + } + + $this->resources[$name] = \call_user_func_array( + self::$resourcesCallbacks[$name]['callback'], + $this->getResources(self::$resourcesCallbacks[$name]['injections']) + ); + } + + self::$resourcesCallbacks[$name]['reset'] = false; + + return $this->resources[$name]; + } + + /** + * Get Resources By List + * + * @param array $list + * @return array + */ + public function getResources(array $list): array + { + $resources = []; + + foreach ($list as $name) { + $resources[$name] = $this->getResource($name); + } + + return $resources; + } + + /** + * Set a new resource callback + * + * @param string $name + * @param callable $callback + * @param array $injections + * + * @throws Exception + * + * @return void + */ + public static function setResource(string $name, callable $callback, array $injections = []): void + { + self::$resourcesCallbacks[$name] = ['callback' => $callback, 'injections' => $injections, 'reset' => true]; + } + /** * task-name --foo=test * @@ -167,14 +237,14 @@ public function parse(array $args): array } } - /** - * Refer to this answer + /** + * Refer to this answer * https://stackoverflow.com/questions/18669499/php-issue-with-looping-over-an-array-twice-using-foreach-and-passing-value-by-re/18669732 */ unset($arg); foreach ($args as $arg) { - $pair = explode("=",$arg); + $pair = explode("=", $arg); $key = $pair[0]; $value = $pair[1]; $output[$key][] = $value; @@ -195,17 +265,44 @@ public function parse(array $args): array /** * Find the command that should be triggered - * + * * @return Task|null */ - public function match() + public function match(): ?Task { return isset($this->tasks[$this->command]) ? $this->tasks[$this->command] : null; } + /** + * Get Params + * Get runtime params for the provided Hook + * + * @param Hook $hook + * @return array + */ + protected function getParams(Hook $hook): array + { + $params = []; + + foreach ($hook->getParams() as $key => $param) { + $value = (isset($this->args[$key])) ? $this->args[$key] : $param['default']; + + $this->validate($key, $param, $value); + + $params[$param['order']] = $value; + } + + foreach ($hook->getInjections() as $key => $injection) { + $params[$injection['order']] = $this->getResource($injection['name']); + } + + ksort($params); + return $params; + } + /** * Run - * + * * @return $this */ public function run(): self @@ -214,32 +311,24 @@ public function run(): self try { if ($command) { - foreach ($this->init as $init) { - \call_user_func_array($init, []); - } - - $params = []; - - foreach ($command->getParams() as $key => $param) { - // Get value from route or request object - $value = (isset($this->args[$key])) ? $this->args[$key] : $param['default']; - - $this->validate($key, $param, $value); - - $params[$key] = $value; + foreach ($this->init as $hook) { + \call_user_func_array($hook->getAction(), $this->getParams($hook)); } // Call the callback with the matched positions as params - \call_user_func_array($command->getAction(), $params); + \call_user_func_array($command->getAction(), $this->getParams($command)); - foreach ($this->shutdown as $shutdown) { - \call_user_func_array($shutdown, []); + foreach ($this->shutdown as $hook) { + \call_user_func_array($hook->getAction(), $this->getParams($hook)); } } else { throw new Exception('No command found'); } } catch (Exception $e) { - \call_user_func_array($this->error, array($e)); + foreach ($this->errors as $hook) { + self::setResource('error', fn () => $e); + \call_user_func_array($hook->getAction(), $this->getParams($hook)); + } } return $this; @@ -247,7 +336,7 @@ public function run(): self /** * Get list of all tasks - * + * * @return Task[] */ public function getTasks(): array @@ -257,7 +346,7 @@ public function getTasks(): array /** * Get list of all args - * + * * @return array */ public function getArgs(): array @@ -291,7 +380,7 @@ protected function validate(string $key, array $param, $value): void } if (!$validator->isValid($value)) { - throw new Exception('Invalid ' .$key . ': ' . $validator->getDescription(), 400); + throw new Exception('Invalid ' . $key . ': ' . $validator->getDescription(), 400); } } else { if (!$param['optional']) { @@ -299,4 +388,9 @@ protected function validate(string $key, array $param, $value): void } } } -} \ No newline at end of file + + public static function reset(): void + { + self::$resourcesCallbacks = []; + } +} diff --git a/src/CLI/Console.php b/src/CLI/Console.php index 0c7fbd3..2c2da1a 100644 --- a/src/CLI/Console.php +++ b/src/CLI/Console.php @@ -7,12 +7,12 @@ class Console /** * Title * - * Sets the process title visible in tools such as top and ps. + * Sets the process title visible in tools such as top and ps. * * @param string $title * @return bool */ - static public function title(string $title) + public static function title(string $title): bool { return @\cli_set_process_title($title); } @@ -25,7 +25,7 @@ static public function title(string $title) * @param string $message * @return bool|int */ - static public function log(string $message) + public static function log(string $message): int|false { return \fwrite(STDOUT, $message . "\n"); } @@ -38,7 +38,7 @@ static public function log(string $message) * @param string $message * @return bool|int */ - static public function success(string $message) + public static function success(string $message): int|false { return \fwrite(STDOUT, "\033[32m" . $message . "\033[0m\n"); } @@ -51,7 +51,7 @@ static public function success(string $message) * @param string $message * @return bool|int */ - static public function error(string $message) + public static function error(string $message): int|false { return \fwrite(STDERR, "\033[31m" . $message . "\033[0m\n"); } @@ -64,7 +64,7 @@ static public function error(string $message) * @param string $message * @return bool|int */ - static public function info(string $message) + public static function info(string $message): int|false { return \fwrite(STDOUT, "\033[34m" . $message . "\033[0m\n"); } @@ -77,7 +77,7 @@ static public function info(string $message) * @param string $message * @return bool|int */ - static public function warning(string $message) + public static function warning(string $message): int|false { return \fwrite(STDERR, "\033[1;33m" . $message . "\033[0m\n"); } @@ -90,7 +90,7 @@ static public function warning(string $message) * @param string $question * @return string */ - static public function confirm(string $question) + public static function confirm(string $question): string { if (!self::isInteractive()) { return ''; @@ -102,7 +102,7 @@ static public function confirm(string $question) $line = \trim(\fgets($handle)); \fclose($handle); - + return $line; } @@ -114,16 +114,16 @@ static public function confirm(string $question) * @param string $message * @return void */ - static public function exit(int $status = 0): void + public static function exit(int $status = 0): void { exit($status); } /** * Execute a Commnad - * + * * This function was inspired by: https://stackoverflow.com/a/13287902/2299554 - * + * * @param string $cmd * @param string $stdin * @param string $stdout @@ -131,7 +131,7 @@ static public function exit(int $status = 0): void * @param int $timeout * @return int */ - static public function execute(string $cmd, string $stdin, string &$stdout, string &$stderr, int $timeout = -1): int + public static function execute(string $cmd, string $stdin, string &$stdout, string &$stderr, int $timeout = -1): int { $cmd = '( ' . $cmd . ' ) 3>/dev/null ; echo $? >&3'; @@ -171,7 +171,7 @@ static public function execute(string $cmd, string $stdin, string &$stdout, stri \fclose($pipes[2]); \proc_close($process); - $exitCode = (int) str_replace("\n","",$status); + $exitCode = (int) str_replace("\n", "", $status); return $exitCode; } @@ -184,10 +184,10 @@ static public function execute(string $cmd, string $stdin, string &$stdout, stri /** * Is Interactive Mode? - * + * * @return bool */ - static public function isInteractive(): bool + public static function isInteractive(): bool { return ('cli' === PHP_SAPI && defined('STDOUT')); } @@ -197,7 +197,7 @@ static public function isInteractive(): bool * @param float $sleep // in seconds! * @param callable $onError */ - static public function loop(callable $callback, $sleep = 1 /* seconds */, callable $onError = null): void + public static function loop(callable $callback, $sleep = 1 /* seconds */, callable $onError = null): void { gc_enable(); @@ -206,8 +206,8 @@ static public function loop(callable $callback, $sleep = 1 /* seconds */, callab while (!connection_aborted() || PHP_SAPI == "cli") { try { $callback(); - } catch(\Exception $e) { - if($onError != null) { + } catch (\Exception $e) { + if ($onError != null) { $onError($e); } else { throw $e; @@ -217,18 +217,18 @@ static public function loop(callable $callback, $sleep = 1 /* seconds */, callab $intSeconds = intval($sleep); $microSeconds = ($sleep - $intSeconds) * 1000000; - if($intSeconds > 0) { + if ($intSeconds > 0) { sleep($intSeconds); } - if($microSeconds > 0) { + if ($microSeconds > 0) { usleep($microSeconds); } $time = $time + $sleep; if (PHP_SAPI == "cli") { - if($time >= 60 * 5) { // Every 5 minutes + if ($time >= 60 * 5) { // Every 5 minutes $time = 0; gc_collect_cycles(); //Forces collection of any existing garbage cycles } diff --git a/src/CLI/Task.php b/src/CLI/Task.php index cb6cdf4..736e9db 100644 --- a/src/CLI/Task.php +++ b/src/CLI/Task.php @@ -1,46 +1,18 @@ name = $name; - $this->action = function(): void {}; - } - - /** - * Add Description - * - * @param string $desc - * @return $this - */ - public function desc($desc): self - { - $this->desc = $desc; - return $this; - } - - /** - * Add Action - * - * @param callable $action - * @return $this - */ - public function action(callable $action): self - { - $this->action = $action; - return $this; - } - - /** - * Add Param - * - * @param string $key - * @param mixed $default - * @param Validator $validator - * @param string $description - * @param bool $optional - * - * @return $this - */ - public function param(string $key, $default, Validator $validator, string $description = '', bool $optional = false): self - { - $this->params[$key] = array( - 'default' => $default, - 'validator' => $validator, - 'description' => $description, - 'optional' => $optional, - 'value' => null, - ); - - return $this; - } - - /** - * Add Label - * - * @param string $key - * @param mixed $value - * - * @return $this - */ - public function label(string $key, $value): self - { - $this->labels[$key] = $value; - - return $this; + $this->action = function (): void { + }; } /** @@ -124,48 +34,4 @@ public function getName(): string { return $this->name; } - - /** - * Get Description - * - * @return string - */ - public function getDesc(): string - { - return $this->desc; - } - - /** - * Get Action - * - * @return callable - */ - public function getAction(): callable - { - return $this->action; - } - - /** - * Get Params - * - * @return array - */ - public function getParams(): array - { - return $this->params; - } - - /** - * Get Label - * - * Return given label value or default value if label doesn't exists - * - * @param string $key - * @param mixed $default - * @return mixed - */ - public function getLabel(string $key, $default) - { - return (isset($this->labels[$key])) ? $this->labels[$key] : $default; - } -} \ No newline at end of file +} diff --git a/tests/CLI/CLITest.php b/tests/CLI/CLITest.php index 7746c5d..ba2b579 100755 --- a/tests/CLI/CLITest.php +++ b/tests/CLI/CLITest.php @@ -1,4 +1,5 @@ getResource('second'); + $first = $cli->getResource('first'); + $this->assertEquals('second', $second); + $this->assertEquals('first-second', $first); + + $resource = $cli->getResource('rand'); + + $this->assertNotEmpty($resource); + $this->assertEquals($resource, $cli->getResource('rand')); + $this->assertEquals($resource, $cli->getResource('rand')); + $this->assertEquals($resource, $cli->getResource('rand')); + } + public function testAppSuccess() { ob_start(); @@ -79,7 +105,7 @@ public function testAppArray() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $cli->run(); @@ -98,7 +124,7 @@ public function testGetTasks() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $cli @@ -106,7 +132,7 @@ public function testGetTasks() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $this->assertCount(2, $cli->getTasks()); @@ -121,7 +147,7 @@ public function testGetArgs() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $cli @@ -129,13 +155,68 @@ public function testGetArgs() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $this->assertCount(2, $cli->getArgs()); $this->assertEquals(['email' => 'me@example.com', 'list' => ['item1', 'item2']], $cli->getArgs()); } + public function testHook() + { + CLI::reset(); + + $cli = new CLI(['test.php', 'build', '--email=me@example.com', '--list=item1', '--list=item2']); + + $cli + ->init() + ->action(function () { + echo '(init)-'; + }); + + $cli + ->shutdown() + ->action(function () { + echo '-(shutdown)'; + }); + + $cli + ->task('build') + ->param('email', null, new Text(0), 'Valid email address') + ->param('list', null, new ArrayList(new Text(256)), 'List of strings') + ->action(function ($email, $list) { + echo $email . '-' . implode('-', $list); + }); + + \ob_start(); + + $cli->run(); + $result = \ob_get_clean(); + + $this->assertEquals('(init)-me@example.com-item1-item2-(shutdown)', $result); + } + + public function testInjection() + { + ob_start(); + + $cli = new CLI(['test.php', 'build', '--email=me@example.com']); + CLI::setResource('test', fn() => 'test-value'); + + $cli->task('build') + ->inject('test') + ->param('email', null, new Text(15), 'valid email address') + ->action(function ($test, $email) { + echo $test . '-' . $email; + }); + + $cli->run(); + + $result = ob_get_clean(); + + $this->assertEquals('test-value-me@example.com', $result); + } + public function testMatch() { $cli = new CLI(['test.php', 'build2', '--email=me@example.com', '--list=item1', '--list=item2']); // Mock command request @@ -145,7 +226,7 @@ public function testMatch() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $cli @@ -153,7 +234,7 @@ public function testMatch() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $this->assertEquals('build2', $cli->match()->getName()); @@ -165,7 +246,7 @@ public function testMatch() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $cli @@ -173,9 +254,9 @@ public function testMatch() ->param('email', null, new Text(0), 'Valid email address') ->param('list', null, new ArrayList(new Text(256)), 'List of strings') ->action(function ($email, $list) { - echo $email.'-'.implode('-', $list); + echo $email . '-' . implode('-', $list); }); $this->assertEquals(null, $cli->match()); } -} \ No newline at end of file +} diff --git a/tests/CLI/ConsoleTest.php b/tests/CLI/ConsoleTest.php index e37bbc4..717f8fe 100755 --- a/tests/CLI/ConsoleTest.php +++ b/tests/CLI/ConsoleTest.php @@ -1,4 +1,5 @@ assertEquals('', $stderr); $this->assertGreaterThan(30, count(explode("\n", $stdout))); diff --git a/tests/CLI/TaskTest.php b/tests/CLI/TaskTest.php index 0b3c01b..e1f39f6 100755 --- a/tests/CLI/TaskTest.php +++ b/tests/CLI/TaskTest.php @@ -1,4 +1,5 @@ assertCount(1, $this->task->getParams()); } + + public function testResources() + { + $this->assertEquals([], $this->task->getInjections()); + + $this->task + ->inject('user') + ->inject('time') + ->action(function () { + }) + ; + + $this->assertCount(2, $this->task->getInjections()); + $this->assertEquals('user', $this->task->getInjections()['user']['name']); + $this->assertEquals('time', $this->task->getInjections()['time']['name']); + } } diff --git a/tests/resources/loop.php b/tests/resources/loop.php index 8e73d8d..cef0bca 100644 --- a/tests/resources/loop.php +++ b/tests/resources/loop.php @@ -2,8 +2,8 @@ use Utopia\CLI\Console; -include __DIR__.'/../../vendor/autoload.php'; +include __DIR__ . '/../../vendor/autoload.php'; -Console::loop(function() { +Console::loop(function () { echo "Hello\n"; -}); \ No newline at end of file +});