diff --git a/README.md b/README.md index 3f9c58c3..c8b2c85b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/JsonSchema/Constraints/CallableConstraint.php b/src/JsonSchema/Constraints/CallableConstraint.php new file mode 100644 index 00000000..ba1f6397 --- /dev/null +++ b/src/JsonSchema/Constraints/CallableConstraint.php @@ -0,0 +1,59 @@ +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); + } + } +} \ No newline at end of file diff --git a/src/JsonSchema/Constraints/Constraint.php b/src/JsonSchema/Constraints/Constraint.php index cb3ee809..4c2604cc 100644 --- a/src/JsonSchema/Constraints/Constraint.php +++ b/src/JsonSchema/Constraints/Constraint.php @@ -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 diff --git a/src/JsonSchema/Constraints/Factory.php b/src/JsonSchema/Constraints/Factory.php index a4570f61..d6ddca2f 100644 --- a/src/JsonSchema/Constraints/Factory.php +++ b/src/JsonSchema/Constraints/Factory.php @@ -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(); } @@ -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. */ @@ -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); } + } diff --git a/src/JsonSchema/Constraints/UndefinedConstraint.php b/src/JsonSchema/Constraints/UndefinedConstraint.php index 658b1b7a..6318569f 100644 --- a/src/JsonSchema/Constraints/UndefinedConstraint.php +++ b/src/JsonSchema/Constraints/UndefinedConstraint.php @@ -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); + } + } } /** diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index 14dbb609..85dbbbab 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -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 @@ -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); + } } diff --git a/tests/JsonSchema/Tests/Constraints/CustomConstraintTest.php b/tests/JsonSchema/Tests/Constraints/CustomConstraintTest.php new file mode 100644 index 00000000..b6270a7a --- /dev/null +++ b/tests/JsonSchema/Tests/Constraints/CustomConstraintTest.php @@ -0,0 +1,124 @@ +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')); + } + +} diff --git a/tests/JsonSchema/Tests/Constraints/Fixtures/CustomConstraint.php b/tests/JsonSchema/Tests/Constraints/Fixtures/CustomConstraint.php new file mode 100644 index 00000000..2c5f25ba --- /dev/null +++ b/tests/JsonSchema/Tests/Constraints/Fixtures/CustomConstraint.php @@ -0,0 +1,12 @@ +