alistairphillips.com

I’m : a web and mobile developer based in the Australia.


Reflection in PHP

Testing is extremely important in any application that you write and should be something that you begin to do from day one. Unfortunately until you know how to go about if you're likely to have a large existing code base that will require tests to be retrofitted onto the back of it. Something that I know quite well after having to recently go about doing that for a project! Of course it's not too bad trying to remember what classes and their methods do when they're 6 months if you've got some good inline documentation. So yay for that indeed!

If your application is dealing with a lot of data it's worthwhile trying to automate checking report data. But how do you go about it in a web-based application? There is selenium which would assist you in scripting the report request but not in checking all the data. And what happens when you want to automatically compare your data to the exact report run right there and then on production to development?

One way of going about this would be to have a url entrypoint like http://application/test/ which would allow you to execute certain model methods. We'd then be able to have a simple bit of code like this:

<?php
class Tests_IndexController extends Zend_Controller_Action
{

    /**
     * The default action is "indexAction", unless explicitly set to something else.
     * It does not do anything yet
     */
    public function indexAction()
    {
        $this->_helper->layout->disableLayout();
        $query = array( 'class'  => 'someClass',
                        'method' => 'someMethod',
                        'params' => array( ) );

        $request = base64_encode( Zend_Json::encode( $query ) );
        $key     = md5( $request . 'random_phrase' );

        $url = new Zend_Http_Client( 'http://localhost/tests/index/query/', array( 'timeout' => 3600 ) );
        $url->setMethod( Zend_Http_Client::POST );
        $url->setParameterPost( 'key', $key  );
        $url->setParameterPost( 'request', $request );
        $result = $url->request();

        $methodResult = Zend_Json::decode( $result->getBody() );
        echo '<pre>' . print_r( $methodResult, true ) . '</pre>';
    }

Here we're posting to our /test/ entrypoint with the query we'd like to run. In it we pass in the class, method and any parameters that you need to send along to it. One thing to note was that almost all of the methods in the project were defined as static so can be called without having to instantiate the class.

The request itself is a json encoded array further encoded in base64 so that we don't have issues characters that could cause issues. For security we're generating an md5 key of ( json_string + a_random_phrase ) which we'll use later to ensure that only valid requests are executed. And that's all there really is to requesting the execution. Note that results will be returned as a JSON encoded string which you can decode to work with.

Now onto the real code which actually handles calling the class methods. First we disable all output since we're sending back a JSON encoded string so have no need for any templates which may want to render. Validation is done to ensure that we actually received the key and request parameters and then further that the key matches. Once we're happy with that we then decode the JSON and then move on to verifying the key.

Verification is done by re-encoding the JSON string, adding the salt and then applying the base64 function to it, after which the compairson is done. Anything that does not match is not allowed to proceed. After ensuring that a class and method were included in the request we use PHP's ReflectionMethod to gain access to the requested method.

<?php
    public function queryAction()
    {
        // Since we're responding with JSON we don't need a layout
        $this->_helper->layout->disableLayout();

        $salt    = 'random_phrase';
        $key     = $this->_request->getParam( 'key', null );
        $request = $this->_request->getParam( 'request', null );

        if ( is_null( $key ) || is_null( $request ) ) {
            echo 'both key and request must be supplied';
            return false;
        }

        // Validate that the request is authentic
        $request = (array)Zend_Json::decode( base64_decode( $request ) );
        if ( ( md5( base64_encode( Zend_Json::encode( $request ) ) . $salt ) ) != $key ) {
            echo 'key is invalid' . PHP_EOL;
            return false;
        }

        $class  = $request['class'];
        $method = $request['method'];
        $params = $request['params'];

        if ( empty( $class) || empty( $method ) ) {
            echo 'class and method are not optional parameters';
            return false;
        }

We could run into an error here so we put this in a try...catch block which will gracefully handle an errors such as invalid class/method. If all is still well we then turn to parameter validation of the method the user requested. By calling the getNumberOfParameters() method we can determine the number of parameters that the method requires. This includes all parameters, including those that are optional, so it would be wrong to simply require that the number of parameters sent in the JSON is equal to the result of getNumberOfParameters(). Instead we loop through all of the parameters using a foreach on the $requestedMethod->getParameters() method and check to see if it's optional. If it is we increase our counter and eventually end up with the total number of required parameters which, at a minimum, the request would have to satisfy.

<?php
        // Instantiate the reflection API and determine the number of parameters
        try {
           $requestedMethod = new ReflectionMethod( $class, $method );
        } catch ( Exception $ex ) {
            echo('Unable to instantiate an instance of ' . $class . '::' . $method );
            return false;
        }

        $requestedMethodTotalParameters    = $requestedMethod->getNumberOfParameters();
        $requestedMethodOptionalParameters = 0;

        foreach( $requestedMethod->getParameters() as $parameter ) {
            if ( $parameter->isOptional() ) $requestedMethodOptionalParameters++;
        }

        $requestedMethodRequiredParameters = $requestedMethodTotalParameters - $requestedMethodOptionalParameters;

Using the figures we calculated before we ensure that we are indeed sending through sufficient parameters. The user parameters needs to be greater than or equal to the 'required' (aka non-optional parameters) and less than or equal to the total number of parameters. All being good we then call the $requestedMethod->invokeArgs() method passing in the the method and our array of parameters. This is then JSON encoded and returned to the client.

<?php
        // Has the user supplied sufficient parameters for us to execute this?
        if ( sizeof( $params ) >= $requestedMethodRequiredParameters
             && sizeof( $params ) <= $requestedMethodTotalParameters ) {
            echo Zend_Json::encode( $requestedMethod->invokeArgs( $requestedMethod, $params ) );
        } else {
            echo 'Incorrect number of parameters sent, ' . sizeof( $params ) . ', when ' . $class . '::' . $method;
            echo ' requires ' . $requestedMethodRequiredParameters . PHP_EOL;
            echo PHP_EOL;
            echo 'Method is defined as follows: ' . $class . '::' . $method . '(';

            foreach( $requestedMethod->getParameters() as $parameter ) {
                echo $parameter->getName() . ',';
            }

            echo ')' . PHP_EOL;
        }
    }
}

The PHP Reflection classes are extremely powerful and let you easily examine classes, methods, functions, docblocks, etc in your PHP code. You're then free to create your own documentation or dynamically execute code as you see fit. Of course you need to ensure that something like this sample is adequately protected since you really don't want anyone from outside getting access. But using this you should now be able to fire off requests to both live and development sites iterating over the results and looking for differences. Great for working your way through complex reports with lots of calculations.