Robo.li: reducing code base and other tricks

Last time I showed some basic tricks on how to use Robo.li with ease and that was a big post (compared to my other ones), but still didn’t cover some of the very basic things that can save a lot of time and efforts.

Abstract Base Task

To avoid doing same thing over and over again, let’s start with the abstract base task that will extend \Robo\Task\BaseTask. From the last time example you saw that my tasks were extending the Foo\Robo\AbstractTask, so let see three main things that are done there:

<?php

namespace Foo\Robo;

use \Robo\Common\ConfigAwareTrait;
use Robo\Task\BaseTask;
use Robo\Result;

/**
 * Foo base task.
 */
abstract class AbstractTask extends BaseTask
{
    use ConfigAwareTrait;

    /**
     * @var array $data Task data fields
     */
    protected $data = [];

    /**
     * @var array $requiredData List of required data fields keys
     */
    protected $requiredData = [];

    /**
     * @var string $configPrefix Config path prefix
     */
    protected static $configPrefix = "task.";

    /**
     * @var string $configClassRegexPattern Regex to extract class name for config
     */
    protected static $configClassRegexPattern = "/^.*Tasks?\.(.*)\.[^\.]+$/";

    /**
     * @var string $configClassRegexReplacement Regex match to use as extracted class name for config
     */
    protected static $configClassRegexReplacement = '${1}';

    public function __construct($params)
    {
    }

    /**
     * {inheritdoc}
     */
    public function run()
    {
        // for any key defind in data
        // set it to config value, if available
        foreach ($this->data as $k => $v) {
            $default = $this->getConfigValue($k);

            // specifically check for null to avoid problems with false and 0
            // being overwriten
            if ($this->data[$k] === null && $default !== null) {
                $this->data[$k] = $default;
                continue;
            }
            // if key value is an array, merge the config value to it
            if (is_array($this->data[$k]) && is_array($default)) {
                $this->data[$k] = array_merge_recursive($this->data[$k], $default);
            }
            continue;
        }        

        // check if we have all required data fields
        $res = $this->checkRequiredData();
        if (!$res->wasSuccessful()) {
            return $res;
        }

        // general success, as will be overriden by child classes
        return Result::success($this, "Task completed successfully", $this->data);
    }


    /**
     * Magic setters via __call
     * Make sure only valid data passes thtough
     *
     * @param string $name data key name
     * @param mixed $value data value name
     */
    public function __call($name, $value)
    {
        // we use snake_case field keys
        // but camelCase setters
        $name = $this->decamelize($name);

        // only set values for predefined data keys
        if (array_key_exists($name, $this->data)) {
            $this->data[$name] = $value[0];
        }

        return $this;
    }

    /**
     * Check that all required data present
     *
     * @return \Robo\Result
     */
    protected function checkRequiredData()
    {
        $missing = [];
        foreach ($this->requiredData as $key) {
            if (!isset($this->data[$key]) or empty($this->data[$key])) {
                $missing []= $key;
            }
        }

        return count($missing)
            ? Result::error($this, "Missing required data field(s) [" . implode(",", array_map([$this,"camelize"], $missing)) . "].", $this->data)
            : Result::success($this, "All required data fields preset", $this->data);
    }


    /**
     * Ported from Ruby's String#decamelize
     *
     * @param string $word String to convert
     * @return string
     */
    protected function decamelize($word)
    {
        return preg_replace_callback(
            '/(^|[a-z])([A-Z])/',
            function ($matches) {
                return strtolower(strlen($matches[1]) ? $matches[1] . "_" . $matches[2] : $matches[2]);
            },
            $word
        );
    }

    /**
     * Ported from Ruby's String#camelize
     *
     * @param string $word String to convert
     * @return string
     */
    protected function camelize($word)
    {
        return preg_replace_callback(
            '/(^|[a-z])([A-Z])/',
            function ($matches) {
                return strtoupper($matches[2]);
            },
            $word
        );
    }

    /**
     * Override of Robo\Common\ConfigAwareTrait configPrefix()
     */
    protected static function configPrefix()
    {
        return static::$configPrefix;
    }

    /**
     * Override of Robo\Common\ConfigAwareTrait configClassIdentifier($classname)
     */
    protected static function configClassIdentifier($classname)
    {
        return preg_replace(
            static::$configClassRegexPattern,
            static::$configClassRegexReplacement,
            str_replace(
                '\\',
                '.',
                $classname
            )
        );
    }

    /**
     * Override of Robo\Common\ConfigAwareTrait getClassKey()
     *
     * makes method protected instead of private
     */
    protected static function getClassKey($key)
    {
        return sprintf(
            "%s%s.%s", 
            static::configPrefix(),
            static::configClassIdentifier(get_called_class()),
            $key
        );
    }

    /**
     * A quick fix on printInfo, as it is not very friendly
     * when you use 'name' placeholders or even just have 'name'
     * set in the data
     */
    protected function printInfo($msg, $data = null)
    {
        // pass-through when no 'name' found in data
        if ($data == null || !isset($data['name'])) {
            return $this->printTaskInfo($msg, $data);
        }

        // doubt someone will use this ever in data
        $key = 'print_task_info_name_replacement_macro';

        // replace 'name' with above key both in data
        // and in msg placeholders
        $data[$key] = $data['name'];
        unset($data['name']);
        $msg = str_replace('{name}','{' . $key . '}', $msg);

        // print nice message
        $result = $this->printTaskInfo($msg, $data);

        return $result;
    }
}

Configuration

The first thing to note is that we use Robo’s config aware trait, that gives all our tasks access to the configuration (that we have covered in the previous post about Robo). This is very handy, as we don’t need hard-code anything and have couple of interesting tricks, but that’s a bit later. Obviously we had to override couple of methods from configAwareTrait to make thing work for us (and actually took me some time to figure out how all of this works in Robo).

Dynamic Data

The second trick here is to avoid using plain class properties and end up writing a hell amount of getters/setters/validators/etc. Instead we use $data array property with magic __call method that will do a job for us. The only restriction we put in here is that __call will set data only if key is already defined in the array. This restriction is there to avoid rubbish in our data, since we use to pass data as a whole set in many places, and need to make sure we know what’s in there. Another sub-trick here is that we user $requiredData property with a list of keys that are essential to us on run. While this is not something very tricky, it really helps in child classes run() method when called like

public function run() 
{
   $result = parent::run();
   if (!$result->wasSuccessful()) {
       return $result;
   }

   ...
}

So all base validation is done in parent class as long as you have your $data and $requiredData populated.

Please note that we also have to utility methods camelize() and decamelize() that are handy, as magic methods for setters are in camelCase, while actual data keys are in snake_case and those two methods are used to convert between the two.

Dynamic Data Configuration

The third trick, which is actually happens even before the second one, is that since we have access to configuration, whenever something is not set in $data during the run, but we have it in the corresponding config path – apply it to the data, but be careful apply only those configuration variables, that are present as our $data key. This is very handy and very flexible. First of all, many of the defaults are defined in the config and are easy to change. The second advantage is that we can have some other custom things in the config that can be used by any other custom command or component, that will not conflict with our tasks. For example configuration of an API with keys/secrets. Moreover, since tasks can also write to the config, same tasks can share things between them if required (for example some runtime cache data).

Task Information Output

Another handy Robo’s Task thing is the ability to inform about task activity via

$this->printTaskInfo("some message with {some_var} variable", ['some_var' => $this->some_var]);

This is a very cool feature and it looks awesome in the console, as some_var will be highlighted and so on. The above example is exactly of the format I found it on Robo.li documentation, but while using it, I found two problems:

Coming up with different {some_var} macros and using the second arg as above is just ugly and unproductive. That was one of the things that pushed me to using $this->data array instead, so in my case, when I need to print something, I will just do like this:

$this->printTaskInfo("This guy's name is {name}", $this->data);

Assuming we have $this->data[‘name’] of course. The trick with the data property saved me a day, but later on I found out one drawback: the output has a defined format, and if the above would run from \Foo\Robo\Task\Guy\PrintName, the output I would expect would be:

[\Foo\Robo\Task\Guy\PrintName] This guys name is Some Guy

But instead I was getting

[Some Guy] This guys name is Some Guy

This is due to the way Robo loggers are trying to find their context and finally end up using name supplied as their name. To fix the problem, I simply wrote a printInfo() method that would replace name with something else during the debug.

Task Return Value

Make sure your task always returns \Robo\Result instance as it will make your life much easier when you will be going to use your tasks in the commands. Make sure you always specify the reason/message that clearly describes why you return this type of result both for success and errors. Finally make sure you return some kind of supporting data (passed as a third argument to result constructor) to make commands life even easier. Normally I would put $this->data  in most of the cases, for example:

public function run()
{
    try {
        $res = $this->tryToValidataDataExampleMethod();
        if (!$res) {
            return Result::error($this, "Failed to validate data", $this->data);
        }
        return Result::success($this, "Data successfully validated", $this->data);
    } catch (\Exception $e) {
        return Result::fromException($this, $e);
    }
}

There few interesting things you can do with the data from the commands, but that I will probably cover in a later posts.

Leave a Reply