Monday, May 07, 2012

How to use Symfony2 validator component without forms (Entities and data arrays) | PHPLand y otros pensamientos

There seems to be a lot of confusion when using Symfony2 validation outside CRUD and Forms but the thing is that these component can be used in many ways to validate stuff all around the application.
I will show 2 examples of how we use validation component at Ulabox for 2 quite common issues in e-commerce:
- Credit Card Validation using Entity Classes (without being linked to a table) and Annotations
- Customer Register array data validation

Validating Credit Cards Example

For this example I will use Entity classes (although they are not linked to Doctrine2 on any database) and annotations. There will be an abstract entity CreditCard and 3 entities extending from it (Visa, Mastercard and American Express)
So the 4 Entities created look like this:
- Abstract class CreditCard
namespace Ulabox\PaymentBundle\Services\TPV\Entity;
 
use Symfony\Component\Validator\Constraints as Assert;
 
abstract class CreditCard
{
    /**
     * @Assert\NotBlank()
     */
    public $cardnumber;
 
    /**
     * @Assert\NotBlank()
     * @Assert\Regex(pattern="/^[0-9]{3,4}$/", message="payment.validations.cvv.invalidcvv")
     */
    public $cvv;
 
    /**
     * Format needs to be yymm
     *
     * @Assert\NotBlank()
     * @Assert\Regex(pattern="/^[0-9]{4}$/", message="payment.validations.expmonth.invalidexpdate")
     */
    public $expirydate;
 
    /**
     * This is based in Luhn Algorithm
     * @see http://en.wikipedia.org/wiki/Luhn_algorithm
     *
     * @Assert\True(message="payment.validations.cardnumber.checksum")
     * @return bool
     */
   public function isChecksumCorrect()
   {
        $cardnumber = $this->cardnumber;
 
        $aux = '';
        foreach (str_split(strrev($cardnumber)) as $pos => $digit) {
            // Multiply * 2 all even digits
            $aux .= ($pos % 2 != 0) ? $digit * 2 : $digit;
        }
        // Sum all digits in string
        $checksum = array_sum(str_split($aux));
 
        // Card is OK if the sum is an even multiple of 10 and not 0
        return ($checksum != 0 && $checksum % 10 == 0);
    }
 
    /**
     * @Assert\True(message="payment.validations.expmonth.cardexpired")
     * @return bool
     */
    public function isExpirationDateValid()
    {
        if(substr($this->expirydate, 2, 2) < 1 || substr($this->expirydate, 2, 2) > 12) return false;
        if($this->expirydate < date('ym')) return false;
    }
}
- Visa extending from CreditCard

 
namespace Ulabox\PaymentBundle\Services\TPV\Entity;
 
use Symfony\Component\Validator\Constraints as Assert;
 
class Visa extends CreditCard
{
    /**
     * @Assert\Regex(pattern="/^4[0-9]{12}(?:[0-9]{3})?$/", message="payment.validations.cardnumber.invalidvisa")
     */
    public $cardnumber;
}
- Mastercard extending from CreditCard

 
namespace Ulabox\PaymentBundle\Services\TPV\Entity;
 
use Symfony\Component\Validator\Constraints as Assert;
 
class Mastercard extends CreditCard
{
    /**
     * @Assert\Regex(pattern="/^5[1-5][0-9]{14}$/", message="payment.validations.cardnumber.invalidmastercard")
     */
    public $cardnumber;
}
- Amex extending from CreditCard

 
namespace Ulabox\PaymentBundle\Services\TPV\Entity;
 
use Symfony\Component\Validator\Constraints as Assert;
 
class Amex extends CreditCard
{
    /**
     * @Assert\Regex(pattern="/^3[47][0-9]{13}$/", message="payment.validations.cardnumber.invalidamex")
     */
    public $cardnumber;
}
- And my code in my service layer is as follows:

 
/**
 * Imagine that we are receiving a Card Data somewhere and our class we have set
 *   protected $method;
 *   protected $cardnumber;
 *   protected $cvv;
 *   protected $expirydate;
 *   protected $cardholder;
 */
 
use Ulabox\PaymentBundle\Services\TPV\Entity;
 
    /**
     * Returns entity to Validate payment params (like cardnumber format, etc...)
     *
     * @return Entity\CreditCard interface entity
     * @throws HttpException 400 if invalid payment method is supplied
     */
    protected function getCreditCardEntity()
    {
        switch ($this->method) {
            case 'visa':
                return new Entity\Visa();
                break;
            case 'mastercard':
                return new Entity\Mastercard();
                break;
            case 'amex':
                return new Entity\Amex();
                break;
            default:
                throw new HttpException(400, 'Payment method ' . $this->method . ' not developed as Entity. Check Services/TPV/Entity');
        }
    }
 
    /**
     * Validates payment data with Symfony2 standard Validator service
     *
     * @throws HttpException 400 if invalid data is found in validator
     */
    protected function validatePaymentData()
    {
        $creditcard = $this->getCreditCardEntity();
 
        $creditcard->cardnumber = $this->cardnumber;
        $creditcard->cvv = $this->cvv;
        $creditcard->expirydate = $this->expirydate;
 
        /**
         * Validate expects an object with all constraints defined in it and just validates its properties to what is expected to satisfy
         */
        $errors = $this->container->get('validator')->validate($creditcard);
 
        if (count($errors) > 0) {
            throw new HttpException(400, $errors[0]->getMessage());
        }
    }
Can this be cleaner? We just define constraints using annotations, get the Entity class to test and just call validate with the object recovered. I think the code is pretty self explanatory, but in case you have any doubts please feel free to comment.
Please note that errors are coded like this: message=”payment.validations.cardnumber.invalidamex” so that we can later translate in Twig with {{ error | trans }}:
(This is content for Resources/translations/messages.en.yml but could be applied to any language used in our web application)
payment:
  validations:
    cardnumber:
      invalidvisa: This is not a valid VISA number
      invalidmastercard: This is not a valid MASTERCARD number
      invalidamex: This is not a valid AMEX number
      checksum: Invalid credit card number
    cvv:
      invalidcvv: Invalid CVV
    expmonth:
      cardexpired: Credit card expired
      invalidexpdate: Invalid expiration date

Validating Register Data array

In this example I will deal directly with the request data array and I will define the constraint with good old plain PHP

use Ulabox\CoreBundle\Entity\Customer;
 
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\MinLength;
use Symfony\Component\Validator\Constraints\MaxLength;
use Symfony\Component\Validator\Constraints\Collection;
use Symfony\Component\Validator\Constraints\True;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\ExecutionContext;
 
   /**
     * Validates Register Data passed as an array to be reused
     * 
     * @throws \Symfony\Component\HttpKernel\Exception\HttpException
     * @param array $registerData
     */
    protected function validateRegisterData(array $registerData)
    {
        /**
          * Imagine that we get an array with these keys: email, pass, postcode and termandco (which is actually Ulabox register data needed)
          */
        $collectionConstraint = new Collection(array(
            'email' => array(
                        new NotBlank(),
                        new Email(),
                        new Callback(array('methods' => array(
                                        array($this, 'checkMailNotRegistered')
                                     ))),
                        ),
            'pass'  => array(
                         new NotBlank(),
                         new MinLength(array('limit' => 8)),
                         new MaxLength(array('limit' => 22)),
                        ),
            'postcode' => array(
                         new NotBlank(),
                         new MinLength(array('limit' => 5)),
                         new MaxLength(array('limit' => 5)),
                         new Callback(array('methods' => array(
                                         array($this, 'isValidPostalCode')
                                      ))),
                         ),
            'termandcon' => array(
                         new NotNull(),
                         new True(),
                        ),
        ));
 
        /**
         * validateValue expects either a scalar value and its constraint or an array and a constraint Collection (which actually extends Constraint)
         */
        $errors = $this->container->get('validator')->validateValue($registerData, $collectionConstraint);
 
        /**
         * To use symfony2 default validation errors, we must call it this way...
         * Count is used as this is not an array but a ConstraintViolationList
         */
        if (count($errors) !== 0) {
            throw new HttpException(400, $errors[0]->getPropertyPath() . ':' . $this->container->get('translator')->trans($errors[0]->getMessage(), array(), 'validators'));
        }
    }
 
    /**
     * Validates PostalCode against Postalcode Entity
     *
     * @param string $postalcode
     * @param \Symfony\Component\Validator\ExecutionContext $context
     */
    public function isValidPostalCode($postalcode, ExecutionContext $context)
    {
        if (!$this->container->get('logistics')->checkPostalcode($postalcode)) {
            $context->addViolation('customer.register.invalidpostalcode', array(), null);
        }
    }
 
    /**
     * Checks that e-mail is not already registered
     * 
     * @param string $email
     * @param \Symfony\Component\Validator\ExecutionContext $context
     */
    public function checkMailNotRegistered($email, ExecutionContext $context)
    {
        $em = $this->container->get('doctrine')->getEntityManager();
        $customer = $em->getRepository('UlaboxCoreBundle:Customer')->findOneBy(array('email' => $email));
        if ($customer instanceof Customer) {
            $context->addViolation('customer.register.mailregistered', array(), null);
        }
    }
This time we are using directly the Constraints and building up a Collection of constraints that the array must satisfy. As you can see, this syntax make it easy to understand what the code is doing, even if you’re not a talented programmer!
Note that some of them are quite simple (string lengths, email format, but there are some interesting ones with the Callback constraint. In this case, we also validate that an email cannot be registered twiceand that the postalcode is a real one and not just one that fits the 5 length characters. Of course, for a fully working example, repositories for that should be created but I think that again the code is pretty self-explanatory.
Also note that again, messages are “codified” to use translations in yml, but this time we are not using a messages.LANG.yml file but a validators.LANG.yml file to store the messages. This is because Symfony2 has most of the common messages already coded in many languages so that we don’t have to translate the “This is not a valid e-mail” message and stuff like this. And these translations are merged with our own ones.
This is achieved with $this->container->get(‘translator’)->trans($errors[0]->getMessage(), array(), ‘validators’));
Hope you liked this pieces of code that I think can be useful for inspiration and understand a little bit more how Symfony2 validation (and translation) works!


How to use Symfony2 validator component without forms (Entities and data arrays) | PHPLand y otros pensamientos

More than 3 requests, I'll translate this to Chinese.
超过3个请求,我就会把这篇文章翻译成中文。

No comments: