ZF Apigility + Doctrine + UniqueObject Validator

Building a REST API with Apigility that is built on top of Zend Framework 2 is awesome. I’m also a big fan of Doctrine. So I started with the zf-apigility-skeleton and added the zf-apigility-doctrine vendor.

I created an User API and a Doctrine REST service called “User” with the route “/users[/:userId]“. You can defined fields per REST service. Every field will become an instance of Zend\InputFilter\Input, and together they are specified in a Zend\InputFilter\InputFilter.

The username must be unique, so for the POST (adding a user) /users operation a validator must check in the database if this username already exists. For a PUT (updating a user) of User with ID 1 at route “/users/1″, the validator must check if the username already exists in the database with the current user that has ID 1 excluded.

If you use Zend\Db, then the validator ZF\ContentValidation\Validator\DbNoRecordExists is available by default. The factory will return an instance of Zend\Validator\Db\RecordExists. But if you use Doctrine there is no equivalent available at this time, so we will have to create one are selves that uses DoctrineModule/Validator/UniqueObject.php. This actually took quite some work, but it works very nicely!

Step 1

Add the following to the module/Application/config/module.config.php of your Application module:

'validators' => array(
    'factories' => array(
            'Application\ContentValidation\Validator\DoctrineUniqueObject' => 'Application\ContentValidation\Validator\Doctrine\UniqueObjectFactory',
        ),
    ),
    'validator_metadata' => array(
        'Application\ContentValidation\Validator\DoctrineUniqueObject' => array(
            'object_manager' => 'string',
            'object_repository' => 'string',
            'fields' => 'string',
        ),
    ),
),

Validator options can be specified per validator under the “validator_metadata” key. By adding the configuration above, the validator will be visible in Apigility with the object_manager, object_repository and fields options.

Create the DoctrineUniqueObject factory at module/Application/src/Application/ContentValidation/Validator/Doctrine/UniqueObjectFactory.php with the following content. This factory will return an instance of DoctrineModule\Validator\UniqueObject. The DoctrineModule also has a DoctrineModule\Validator\NoObjectExists validator, but that validator doesn’t work for updates because it doesn’t exclude the current user. So the DoctrineModule\Validator\UniqueObject is the one you want.

<?php

namespace Application\ContentValidation\Validator\Doctrine;

use DoctrineModule\Validator\UniqueObject;
use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\MutableCreationOptionsInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
use Zend\Stdlib\ArrayUtils;

class UniqueObjectFactory implements FactoryInterface, MutableCreationOptionsInterface
{
    /**
     * @var array
     */

    protected $options = array();

    /**
     * Sets options property
     *
     * @param array $options
     */

    public function setCreationOptions(array $options)
    {
        $this->options = $options;
    }

    /**
     * Creates the service
     *
     * @param ServiceLocatorInterface $validators
     *
     * @return UniqueObject
     */

    public function createService(ServiceLocatorInterface $validators)
    {
        if (isset($this->options['object_manager'])) {
            $objectManager = $validators->getServiceLocator()->get($this->options['object_manager']);
            $objectRepository = $objectManager->getRepository($this->options['object_repository']);

            $fields = array();
            $parts = explode(',', $this->options['fields']);
            foreach ($parts as $part) {
                $fields[] = trim($part);
            }

            return new UniqueObject(ArrayUtils::merge(
                $this->options,
                array(
                    'object_manager' => $objectManager,
                    'object_repository' => $objectRepository,
                    'fields' => $fields,
                )
            ));
        }

        return new UniqueObject($this->options);
    }
}

Now you can get to the same state as the following image. You’ll have to fill in the values of the validator options in Apigility, after you selected and added the validator.

At this moment test your API with a “POST /users” request with the RAW body:

{
  "username": "example",
  "email": "example@localhost"
}

Step 2

Problem 1

You will probably get an exception “Expected context to contain id”. This exception is thrown inside the DoctrineModule\Validator\UniqueObject on line 166 in the getExpectedIdentifiers method. The User entity has an auto increment ID, so you will never add the id property with the creation of a user. The id field defined in Apigility will be NULL and isset() on a NULL value will result in FALSE which causes the exception. Normally this will go fine if the validator is used with a Zend\Form\Form but not in a REST service. To work around this problem we need to add the property “id” to the JSON object in the PHP code with an empty string as value. In form context, the value would also be an empty string.

Problem 2

A comparable problem occurs if you execute a “PUT /users/1″ request to update the user with ID 1, the RAW body:

{
  "username": "example",
  "email": "johndoe@localhost"
}

The id property is in this case still not a body param, because the id is defined as route param.

Solution

The solutions below fixes these two problems by adding an id property to the body params with an empty string for a POST request and adding the route param as id property for a PUT request.
The solutions is not hardcoded on the name id, but looks at the configured route_identifier_name and entity_identifier_name. You can see the values at module/User/config/module.config.php:

    'zf-rest' => array(
        'User\\V1\\Rest\\User\\Controller' => array(
            'listener' => 'User\\V1\\Rest\\User\\UserResource',
            'route_name' => 'user.rest.doctrine.user',
            'route_identifier_name' => 'userId',
            'entity_identifier_name' => 'id',
            'collection_name' => 'users',
            'entity_http_methods' => array(
                0 => 'GET',
                1 => 'PATCH',
                2 => 'PUT',
                3 => 'DELETE',
                4 => 'POST',
            ),
            'collection_http_methods' => array(
                0 => 'GET',
                1 => 'POST',
            ),
            'collection_query_whitelist' => array(),
            'page_size' => 25,
            'page_size_param' => 'pageSize',
            'entity_class' => 'Application\\V1\\Entity\\User',
            'collection_class' => 'User\\V1\\Rest\\User\\UserCollection',
            'service_name' => 'User',
        ),
    ),

Implement the solutions by adding the following lines to module/Application/config/module.config.php to override the ContentValidationListener.

'service_manager' => array(
    'factories' => array(
        'ZF\ContentValidation\ContentValidationListener' => 'Application\ContentValidation\ContentValidationListenerFactory',
    ),
),

Create the file module/Application/src/Application/ContentValidation/ContentValidationListenerFactory.php with the following content:

<?php

namespace Application\ContentValidation;

use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;

class ContentValidationListenerFactory implements FactoryInterface
{
    /**
     * @param ServiceLocatorInterface $services
     * @return ContentValidationListener
     */

    public function createService(ServiceLocatorInterface $services)
    {
        $config = array();
        if ($services->has('Config')) {
            $allConfig = $services->get('Config');
            if (isset($allConfig['zf-content-validation'])) {
                $config = $allConfig['zf-content-validation'];
            }
        }
        return new ContentValidationListener($config, $services->get('InputFilterManager'));
    }
}

Create the file module/Application/src/Application/ContentValidation/ContentValidationListener.php with the following content:

<?php

namespace Application\ContentValidation;

use Zend\Http\Request as HttpRequest;
use Zend\InputFilter\Exception\InvalidArgumentException as InputFilterInvalidArgumentException;
use Zend\Mvc\MvcEvent;
use Zend\Mvc\Router\RouteMatch;
use ZF\ApiProblem\ApiProblem;
use ZF\ApiProblem\ApiProblemResponse;
use ZF\ContentValidation\ContentValidationListener as ZendContentValidationListener;
use ZF\ContentNegotiation\ParameterDataContainer;

class ContentValidationListener extends ZendContentValidationListener
{
    /**
     * {@inheritDoc}
     */

    public function onRoute(MvcEvent $e)
    {
        $request = $e->getRequest();
        if (! $request instanceof HttpRequest) {
            return;
        }

        $method = $request->getMethod();
        if (in_array($method, $this->methodsWithoutBodies)) {
            return;
        }

        $routeMatches = $e->getRouteMatch();
        if (! $routeMatches instanceof RouteMatch) {
            return;
        }
        $controllerService = $routeMatches->getParam('controller', false);
        if (! $controllerService) {
            return;
        }

        $inputFilterService = $this->getInputFilterService($controllerService, $method);
        if (! $inputFilterService) {
            return;
        }

        if (! $this->hasInputFilter($inputFilterService)) {
            return new ApiProblemResponse(
                new ApiProblem(
                    500,
                    sprintf('Listed input filter "%s" does not exist; cannot validate request', $inputFilterService)
                )
            );
        }

        $dataContainer = $e->getParam('ZFContentNegotiationParameterData', false);
        if (! $dataContainer instanceof ParameterDataContainer) {
            return new ApiProblemResponse(
                new ApiProblem(
                    500,
                    'ZF\\ContentNegotiation module is not initialized; cannot validate request'
                )
            );
        }
        $data = $dataContainer->getBodyParams();
        if (null === $data || '' === $data) {
            $data = array();
        }

        $inputFilter = $this->getInputFilter($inputFilterService);
        $e->setParam('ZF\ContentValidation\InputFilter', $inputFilter);

        if ($request->isPatch()) {
            try {
                $inputFilter->setValidationGroup(array_keys($data));
            } catch (InputFilterInvalidArgumentException $ex) {
                $pattern = '/expects a list of valid input names; "(?P<field>[^"]+)" was not found/';
                $matched = preg_match($pattern, $ex->getMessage(), $matches);
                if (!$matched) {
                    return new ApiProblemResponse(
                        new ApiProblem(400, $ex)
                    );
                }

                return new ApiProblemResponse(
                    new ApiProblem(400, 'Unrecognized field "' . $matches['field'] . '"')
                );
            }
        }

        $config = $e->getApplication()->getServiceManager()->get('Config');
        if (isset($config['zf-rest'][$controllerService])) {
            $controllerServiceConfig = $config['zf-rest'][$controllerService];

            $data = $this->setEntityIdentifierInBodyParams(
                $data,
                $controllerServiceConfig,
                $dataContainer->getRouteParams()
            );
        }

        $inputFilter->setData($data);
        if ($inputFilter->isValid()) {
            return;
        }

        return new ApiProblemResponse(
            new ApiProblem(422, 'Failed Validation', null, null, array(
                'validation_messages' => $inputFilter->getMessages(),
            ))
        );
    }

    /**
     * Sets the entity identifier in the specified body params
     *
     * @param array  $data
     * @param array  $controllerServiceConfig
     * @param array  $routeParams
     *
     * @return array
     */

    protected function setEntityIdentifierInBodyParams(array $data, array $controllerServiceConfig, array $routeParams)
    {
        if (isset($routeParams[$controllerServiceConfig['route_identifier_name']])
            && isset($controllerServiceConfig['entity_identifier_name'])) {

            $data[$controllerServiceConfig['entity_identifier_name']] =
                $routeParams[$controllerServiceConfig['route_identifier_name']];
        } else {
            if (isset($controllerServiceConfig['entity_identifier_name'])) {
                $data[$controllerServiceConfig['entity_identifier_name']] = '';
            }
        }

        return $data;
    }
}

It’s too bad that the the $inputFilter->isValid() check is not executed in a separate method, so I had to override the large onRoute method. I only added the call to the setEntityIdentifierInBodyParams method and its declaration. If you have improvements, please feel free to leave a comment!

Everything should be working fine now! Enjoy!

Tags: ,,,,