Skip to content

Custom constraints #198

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,32 @@ if ($validator->isValid()) {
}
```

## Custom Constraints
Add custom constraints via `$validator->addConstraint($name, $constraint)`;

The given `$constraint` is applied when `$name` is found within the current evaluated schema path.

### Add a callable constraint

$validator->addConstraint('test', \Callable);

* Inherits _current_ ctr params (uriRetriever, factory)
* The callable is the `ConstraintInterface->check` signature `function check($value, $schema = null, $path = null, $i = null)`

### Add by custom Constraint instance

$validator->addConstraint('test', new MyCustomConstraint(...));

* Requires adding the correct ctr params (uriRetriever, factory et al)
* `MyCustomConstraint` must be of type `JsonSchema\Constraints\ConstraintInterface`

### Add by custom Constraint class-name

$validator->addConstraint('test', 'FQCN');

* Inherits _current_ ctr params (uriRetriever, factory)
* `FQCN` must be of type `JsonSchema\Constraints\ConstraintInterface`

## Running the tests

$ vendor/bin/phpunit
59 changes: 59 additions & 0 deletions src/JsonSchema/Constraints/CallableConstraint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/*
* This file is part of the JsonSchema package.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace JsonSchema\Constraints;

use JsonSchema\Uri\UriRetriever;

/**
* CallableConstraint
*
* Used internally to register a custom callable constraint via:
* ($validator|$factory)->addConstraint('name', \Callable)
*
*/
class CallableConstraint extends Constraint
{

/**
* @var \Callable
*/
private $callable;

/**
* @param int $checkMode
* @param UriRetriever $uriRetriever
* @param Factory $factory
* @param Callable $callable
*/
public function __construct(
$checkMode = self::CHECK_MODE_NORMAL,
UriRetriever $uriRetriever = null,
Factory $factory = null,
$callable
) {
$this->callable = $callable;
parent::__construct($checkMode, $uriRetriever, $factory);
}

/**
* {@inheritDoc}
*/
public function check($element, $schema = null, $path = null, $i = null)
{
if ( ! is_callable($this->callable)) {
return;
}

$result = call_user_func($this->callable, $element, $schema, $path, $i);

if ($result) {
$this->addError($path, $result);
}
}
}
8 changes: 8 additions & 0 deletions src/JsonSchema/Constraints/Constraint.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,14 @@ protected function checkFormat($value, $schema = null, $path = null, $i = null)
$this->addErrors($validator->getErrors());
}

protected function checkCustom($constraint, $value, $schema = null, $path = null, $i = null)
{
$validator = $this->getFactory()->createInstanceFor($constraint);
$validator->check($value, $schema, $path, $i);

$this->addErrors($validator->getErrors());
}

/**
* @param string $uri JSON Schema URI
* @return string JSON Schema contents
Expand Down
71 changes: 70 additions & 1 deletion src/JsonSchema/Constraints/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ class Factory
*/
protected $uriRetriever;

/**
* @var array
*/
private $constraints = array();

/**
* @param UriRetriever $uriRetriever
*/
public function __construct(UriRetriever $uriRetriever = null)
{
if (!$uriRetriever) {
if ( ! $uriRetriever) {
$uriRetriever = new UriRetriever();
}

Expand All @@ -43,10 +48,69 @@ public function getUriRetriever()
return $this->uriRetriever;
}

/**
* Add a custom constraint
*
* By instance:
* $factory->addConstraint('name', new \FQCN(...)); // need to provide own ctr params
*
* By class name:
* $factory->addConstraint('name', '\FQCN'); // inherits ctr params from current
*
* As a \Callable (the Constraint::checks() method):
* $factory->addConstraint('name', \Callable); // inherits ctr params from current
*
* NOTE: By class-name or as a Callable will inherit the current configuration (uriRetriever, factory)
*
* @param string $name
* @param ConstraintInterface|string|\Callable $constraint
*
* @todo possible own exception?
*
* @throws InvalidArgumentException if the $constraint is either not a class or not a ConstraintInterface
*/
public function addConstraint($name, $constraint)
{

if (is_callable($constraint)) {
$this->constraints[$name] = new CallableConstraint(Constraint::CHECK_MODE_NORMAL, $this->uriRetriever,
$this, $constraint);

return;
}

if (is_string($constraint)) {
if ( ! class_exists($constraint)) {
// @todo possible own exception?
throw new InvalidArgumentException('Constraint class "' . $constraint . '" is not a Class');
}
$constraint = new $constraint(Constraint::CHECK_MODE_NORMAL, $this->uriRetriever, $this);
}

if ( ! $constraint instanceof ConstraintInterface) {
// @todo possible own exception?
throw new InvalidArgumentException('Constraint class "' . get_class($constraint) . '" is not an instance of ConstraintInterface');
}

$this->constraints[$name] = $constraint;
}

/**
* @param $constraintName
*
* @return bool
*/
public function hasConstraint($constraintName)
{
return ! empty($this->constraints[$constraintName])
&& $this->constraints[$constraintName] instanceof ConstraintInterface;
}

/**
* Create a constraint instance for the given constraint name.
*
* @param string $constraintName
*
* @return ConstraintInterface|ObjectConstraint
* @throws InvalidArgumentException if is not possible create the constraint instance.
*/
Expand Down Expand Up @@ -76,6 +140,11 @@ public function createInstanceFor($constraintName)
return new Validator(Constraint::CHECK_MODE_NORMAL, $this->uriRetriever, $this);
}

if ($this->hasConstraint($constraintName)) {
return $this->constraints[$constraintName];
}

throw new InvalidArgumentException('Unknown constraint ' . $constraintName);
}

}
18 changes: 18 additions & 0 deletions src/JsonSchema/Constraints/UndefinedConstraint.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ public function check($value, $schema = null, $path = null, $i = null)

// check known types
$this->validateTypes($value, $schema, $path, $i);

// check custom
$this->validateCustom($value, $schema, $path, $i);
}

/**
* @param $value
* @param null $schema
* @param null $path
* @param null $i
*/
public function validateCustom($value, $schema = null, $path = null, $i = null)
{
foreach (array_keys((array)$schema) as $check) {
if ($this->getFactory()->hasConstraint($check)) {
$this->checkCustom($check, $value, $check, $path, $i);
}
}
}

/**
Expand Down
27 changes: 26 additions & 1 deletion src/JsonSchema/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@

namespace JsonSchema;

use JsonSchema\Constraints\SchemaConstraint;
use JsonSchema\Constraints\ConstraintInterface;
use JsonSchema\Constraints\Constraint;
use JsonSchema\Exception\InvalidArgumentException;

/**
* A JsonSchema Constraint
Expand All @@ -37,4 +38,28 @@ public function check($value, $schema = null, $path = null, $i = null)

$this->addErrors(array_unique($validator->getErrors(), SORT_REGULAR));
}

/**
* Add a custom constraint
*
* By instance:
* $factory->addConstraint('name', new \FQCN(...)); // need to provide own ctr params
*
* By class name:
* $factory->addConstraint('name', '\FQCN'); // inherits ctr params from current
*
* As a \Callable (the Constraint::checks() method):
* $factory->addConstraint('name', \Callable); // inherits ctr params from current
*
* NOTE: By class-name or as a Callable will inherit the current configuration (uriRetriever, factory)
*
* @param string $name
* @param ConstraintInterface|string|\Callable $constraint
*
* @throws InvalidArgumentException if the $constraint is either not a class or not a ConstraintInterface
*/
public function addConstraint($name, $constraint)
{
$this->getFactory()->addConstraint($name, $constraint);
}
}
124 changes: 124 additions & 0 deletions tests/JsonSchema/Tests/Constraints/CustomConstraintTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

/*
* This file is part of the JsonSchema package.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace JsonSchema\Tests\Constraints;

use PHPUnit_Framework_TestCase as TestCase;
use JsonSchema\Constraints\Factory;
use JsonSchema\Constraints\Constraint;

class CustomConstraintTest extends TestCase
{

/**
* @return array
*/
public function constraintNameProvider()
{
return array(
array('exists', 'JsonSchema\Constraints\StringConstraint'),
array('custom', 'JsonSchema\Tests\Constraints\Fixtures\CustomConstraint'),
);
}

/**
* @dataProvider constraintNameProvider
*
* @param string $constraintName
* @param string $className
*/
public function testConstraintInstanceWithoutCtrParams($constraintName, $className)
{
$factory = new Factory();

// NOTE the uriRetriever and factory will be new instances; this is due to the Constraint class..
$factory->addConstraint($constraintName, new $className());

$constraint = $factory->createInstanceFor($constraintName);

$this->assertInstanceOf($className, $constraint);
$this->assertInstanceOf('JsonSchema\Constraints\ConstraintInterface', $constraint);

$this->assertNotSame($factory->getUriRetriever(), $constraint->getUriRetriever());
}

/**
* @dataProvider constraintNameProvider
*
* @param string $constraintName
* @param string $className
*/
public function testConstraintInstanceWithCtrParams($constraintName, $className)
{
$factory = new Factory();
$factory->addConstraint($constraintName,
new $className(Constraint::CHECK_MODE_NORMAL, $factory->getUriRetriever(), $factory));
$constraint = $factory->createInstanceFor($constraintName);

$this->assertInstanceOf($className, $constraint);
$this->assertInstanceOf('JsonSchema\Constraints\ConstraintInterface', $constraint);
$this->assertSame($factory->getUriRetriever(), $constraint->getUriRetriever());
$this->assertSame($factory, $constraint->getFactory());
}

/**
* @dataProvider constraintNameProvider
*
* @param string $constraintName
* @param string $className
*/
public function testConstraintClassNameStringInjectingCtrParamsHasSame($constraintName, $className)
{
$factory = new Factory();
$factory->addConstraint($constraintName, $className);
$constraint = $factory->createInstanceFor($constraintName);

$this->assertInstanceOf($className, $constraint);
$this->assertInstanceOf('JsonSchema\Constraints\ConstraintInterface', $constraint);
$this->assertSame($factory->getUriRetriever(), $constraint->getUriRetriever());
$this->assertSame($factory, $constraint->getFactory());
}

/**
* @todo possible own exception?
* @expectedException \JsonSchema\Exception\InvalidArgumentException
*/
public function testConstraintClassNameStringIsNotAClass()
{
$name = 'NotAClass';

$factory = new Factory();
$factory->addConstraint($name, $name);
$factory->createInstanceFor($name);
}

/**
* @todo possible own exception?
* @expectedException \JsonSchema\Exception\InvalidArgumentException
*/
public function testConstraintClassNameStringIsAClassButNotAConstraint()
{
$name = 'JsonSchema\RefResolver'; // Class but not ConstraintInterface

$factory = new Factory();
$factory->addConstraint($name, $name);
$factory->createInstanceFor($name);
}

/**
*
*/
public function testConstraintCallable()
{
$factory = new Factory();
$factory->addConstraint('callable', function () {});
$this->assertInstanceOf('JsonSchema\Constraints\CallableConstraint', $factory->createInstanceFor('callable'));
}

}
Loading