├── .gitignore ├── tests ├── Fixtures │ ├── CustomArrayObject.php │ ├── SchemaValidationFail.php │ ├── ExtendedSchema.php │ ├── CustomArray.php │ └── TestValidation.php ├── phpunit.php ├── ArrayValidationTest.php ├── FilterTest.php ├── ValidationClassDeprecationTest.php ├── InputOutputTest.php ├── ObjectValidationTest.php ├── NumericValidationTest.php ├── PropertyTest.php ├── ArrayRefLookupTest.php ├── RealWorldTest.php ├── Version1CompatTest.php ├── ValidationClassTest.php ├── AllOfTest.php ├── MultipleTypesTest.php ├── BackwardsCompatibilityTest.php ├── SchemaRefTest.php ├── ValidationErrorMessageTest.php ├── OperationsTest.php ├── DiscriminatorTest.php ├── ParseTest.php ├── AbstractSchemaTest.php ├── StringValidationTest.php ├── NestedSchemaTest.php └── BasicSchemaTest.php ├── .editorconfig ├── src ├── ParseException.php ├── RefNotFoundException.php ├── Invalid.php ├── ValidationException.php ├── ArrayRefLookup.php ├── ValidationField.php └── Validation.php ├── composer.json ├── phpunit.xml.dist ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── open-api.json ├── CHANGELOG.md └── UPGRADE.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea/ 3 | composer.lock 4 | coverage.clover 5 | .phpunit.result.cache -------------------------------------------------------------------------------- /tests/Fixtures/CustomArrayObject.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests\Fixtures; 9 | 10 | 11 | class CustomArrayObject extends \ArrayObject { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.json] 10 | indent_style = tab 11 | intent_size = 2 12 | 13 | [*.xml] 14 | indent_size = 2 15 | 16 | [*.xml.dist] 17 | indent_size = 2 -------------------------------------------------------------------------------- /src/ParseException.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema; 9 | 10 | /** 11 | * An exception that occurs when parsing a schema. 12 | */ 13 | class ParseException extends \Exception { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /tests/phpunit.php: -------------------------------------------------------------------------------- 1 | =8.0" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Garden\\Schema\\": "src" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Garden\\Schema\\Tests\\": "tests" 23 | } 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^9.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/RefNotFoundException.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema; 9 | 10 | use Throwable; 11 | 12 | /** 13 | * An exception that represents a reference not being found. 14 | */ 15 | class RefNotFoundException extends \Exception { 16 | /** 17 | * RefNotFoundException constructor. 18 | * 19 | * @param string $message The error message. 20 | * @param int $number The error number. 21 | * @param Throwable|null $previous The previous exeption. 22 | */ 23 | public function __construct(string $message = "", $number = 404, Throwable $previous = null) { 24 | parent::__construct($message, $number, $previous); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Fixtures/SchemaValidationFail.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests\Fixtures; 9 | 10 | use Garden\Schema\Schema; 11 | use Garden\Schema\ValidationField; 12 | use Garden\Schema\ValidationException; 13 | 14 | /** 15 | * A Schema that throws a validation error from itself. 16 | */ 17 | class SchemaValidationFail extends Schema { 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function validate($data, $options = false) { 22 | $field = new ValidationField($this->createValidation(), $this->getSchemaArray(), '', '', $options); 23 | $field->addError('invalid', ['messageCode' => '{field} is always invalid.']); 24 | throw new ValidationException($field->getValidation()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Fixtures/ExtendedSchema.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests\Fixtures; 9 | 10 | use Garden\Schema\Schema; 11 | 12 | /** 13 | * A basic subclass of Schema. 14 | */ 15 | class ExtendedSchema extends Schema { 16 | 17 | /** @var string */ 18 | public $controller; 19 | 20 | /** @var string */ 21 | public $method; 22 | 23 | /** @var string */ 24 | public $type; 25 | 26 | /** 27 | * ExtendedSchema constructor. 28 | * 29 | * @param array $schema 30 | * @param string $controller 31 | * @param string $method 32 | * @param string $type 33 | */ 34 | public function __construct(array $schema = [], $controller = null, $method = null, $type = null) { 35 | parent::__construct($schema); 36 | 37 | $this->controller = $controller; 38 | $this->method = $method; 39 | $this->type = $type; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | 7 | 8 | vendor 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ./tests/ 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | concurrency: 10 | # Concurrency is only limited on pull requests. head_ref is only defined on PR triggers so otherwise it will use the random run id and always build all pushes. 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | phpunit-tests: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | php-version: ["8.0", "8.3"] 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Installing PHP ${{ matrix.php-version }} 27 | uses: shivammathur/setup-php@v2 28 | with: 29 | php-version: ${{ matrix.php-version }} 30 | - name: Composer Install 31 | run: composer install -o 32 | - name: PHPUnit 33 | run: ./vendor/bin/phpunit -c ./phpunit.xml.dist 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | =============== 3 | 4 | Copyright (c) 2014-2016 Vanilla Forums Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/Invalid.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema; 9 | 10 | 11 | /** 12 | * A singleton that represents an invalid value. 13 | * 14 | * The purpose of this class is to provide an alternative to **null** for invalid values when **null** could be considered 15 | * valid. This class is not meant to be used outside of this library unless you are extended the schema somehow. 16 | */ 17 | class Invalid { 18 | private static $value; 19 | 20 | /** 21 | * Private constructor to enforce singleton. 22 | * 23 | * @noinspection PhpUnusedPrivateMethodInspection 24 | */ 25 | private function __construct() { 26 | // no-op 27 | } 28 | 29 | /** 30 | * Return the invalid value. 31 | * 32 | * @return Invalid Returns the invalid value. 33 | */ 34 | public static function value() { 35 | if (self::$value === null) { 36 | self::$value = new Invalid(); 37 | } 38 | return self::$value; 39 | } 40 | 41 | /** 42 | * Tests a value to see if it is invalid. 43 | * 44 | * @param mixed $value The value to test. 45 | * @return bool Returns **true** of the value is invalid or **false** otherwise. 46 | */ 47 | public static function isInvalid($value) { 48 | return $value === self::value(); 49 | } 50 | 51 | /** 52 | * Tests whether a value could be valid. 53 | * 54 | * Unlike {@link Invalid::inValid()} a value could still be invalid in some way even if this method returns true. 55 | * 56 | * @param mixed $value The value to test. 57 | * @return bool Returns **true** of the value could be invalid or **false** otherwise. 58 | */ 59 | public static function isValid($value) { 60 | return $value !== self::value(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/ArrayValidationTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | 12 | /** 13 | * Tests specific to arrays. 14 | */ 15 | class ArrayValidationTest extends AbstractSchemaTest { 16 | /** 17 | * Test the maxItems property for arrays. 18 | */ 19 | public function testMinItems() { 20 | $sch = new Schema(['type' => 'array', 'minItems' => 1]); 21 | 22 | $this->assertTrue($sch->isValid([1])); 23 | $this->assertTrue($sch->isValid([1, 2])); 24 | $this->assertFalse($sch->isValid([])); 25 | } 26 | 27 | /** 28 | * Test the minItems property for arrays. 29 | */ 30 | public function testMaxItems() { 31 | $sch = new Schema(['type' => 'array', 'maxItems' => 2]); 32 | 33 | $this->assertTrue($sch->isValid([1])); 34 | $this->assertTrue($sch->isValid([1, 2])); 35 | $this->assertFalse($sch->isValid([1, 2, 3])); 36 | } 37 | 38 | /** 39 | * Test the uniqueItems property for arrays. 40 | * 41 | * @param array $value The value to test. 42 | * @param bool $expected The expected valid result. 43 | * @dataProvider provideUniqueItemsTests 44 | */ 45 | public function testUniqueItems(array $value, bool $expected) { 46 | $sch = new Schema(['type' => 'array', 'uniqueItems' => true]); 47 | 48 | $valid = $sch->isValid($value); 49 | $this->assertSame($expected, $valid); 50 | } 51 | 52 | /** 53 | * Provide uniqueItems tests. 54 | * 55 | * @return array Returns a data provider. 56 | */ 57 | public function provideUniqueItemsTests() { 58 | $r = [ 59 | [[], true], 60 | [[1, 2], true], 61 | [[1, 2, 1], false], 62 | ]; 63 | 64 | return $r; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/FilterTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Invalid; 11 | use Garden\Schema\Schema; 12 | use PHPUnit\Framework\TestCase; 13 | 14 | /** 15 | * Tests for schema filters. 16 | */ 17 | class FilterTest extends TestCase { 18 | /** 19 | * A validating filter can make an invalid value, valid. 20 | */ 21 | public function testValidatingFilterValid() { 22 | $sch = new Schema([ 23 | 'type' => 'array' 24 | ]); 25 | $sch->addFilter('', function ($v) { 26 | return (int)$v; 27 | }, true); 28 | 29 | $this->assertSame(123, $sch->validate('123')); 30 | } 31 | 32 | /** 33 | * A validating filter can make a valid value invalid. 34 | */ 35 | public function testValidatingFilterInvalid() { 36 | $sch = new Schema([ 37 | 'type' => 'array' 38 | ]); 39 | $sch->addFilter('', function ($v) { 40 | return Invalid::value(); 41 | }, true); 42 | 43 | $this->assertFalse($sch->isValid([1, 2, 3])); 44 | } 45 | 46 | /** 47 | * A format filter applies to a field's format rather than its path. 48 | */ 49 | public function testFormatFilter() { 50 | $sch = new Schema([ 51 | 'type' => 'integer', 52 | 'format' => 'foo', 53 | ]); 54 | $sch->addFormatFilter('foo', function ($v) { 55 | return 123; 56 | }); 57 | 58 | $this->assertSame(123, $sch->validate(456)); 59 | } 60 | 61 | /** 62 | * A validating format filter can override the default format behaviour. 63 | */ 64 | public function testFormatFilterOverride() { 65 | $sch = new Schema([ 66 | 'type' => 'string', 67 | 'format' => 'date-time', 68 | ]); 69 | $sch->addFormatFilter('date-time', function ($v) { 70 | $dt = new \DateTime($v); 71 | return $dt->format(\DateTime::RFC3339); 72 | }, true); 73 | 74 | $this->assertSame('2018-03-26T00:00:00+00:00', $sch->validate('March 26 2018 UTC')); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/ValidationClassDeprecationTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Validation; 11 | 12 | /** 13 | * Test some deprecations in the validation class. 14 | */ 15 | class ValidationClassDeprecationTest extends AbstractSchemaTest { 16 | /** 17 | * The main status has been renamed to main number. 18 | * @deprecated 19 | */ 20 | public function testMainStatusToNumber() { 21 | $vld = new Validation(); 22 | 23 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 24 | $vld->setMainStatus(123); 25 | 26 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 27 | $this->assertSame($vld->getMainCode(), $vld->getMainStatus()); 28 | } 29 | 30 | /** 31 | * The status has been renamed to number. 32 | * @deprecated 33 | */ 34 | public function testStatusToNumber() { 35 | $vld = new Validation(); 36 | 37 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 38 | $vld->setMainStatus(123); 39 | 40 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 41 | $this->assertSame($vld->getCode(), $vld->getStatus()); 42 | } 43 | 44 | /** 45 | * An integer error for options is deprecated. 46 | * @deprecated 47 | */ 48 | public function testIntOptions() { 49 | $vld = new Validation(); 50 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 51 | $vld->addError('foo', 'bar', 123); 52 | $this->assertSame(123, $vld->getCode()); 53 | } 54 | 55 | /** 56 | * Options must be an integer or array. 57 | */ 58 | public function testInvalidOptions() { 59 | $vld = new Validation(); 60 | 61 | $this->expectException(\InvalidArgumentException::class); 62 | $vld->addError('foo', 'bar', 'invalid'); 63 | } 64 | 65 | /** 66 | * An options of 'status' should be converted to 'number', but be deprecated. 67 | */ 68 | public function testStatusToNumberOptions() { 69 | $vld = new Validation(); 70 | 71 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 72 | $vld->addError('foo', 'bar', ['status' => 456]); 73 | $this->assertSame(456, $vld->getCode()); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ValidationException.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema; 9 | 10 | /** 11 | * An exception that was built from a {@link Validation} object. 12 | * 13 | * The validation object collects errors and is mutable. Once it's ready to be thrown as an exception it gets converted 14 | * to an instance of the immutable {@link ValidationException} class. 15 | */ 16 | class ValidationException extends \Exception implements \JsonSerializable { 17 | /** 18 | * @var Validation 19 | */ 20 | private $validation; 21 | 22 | /** 23 | * Initialize an instance of the {@link ValidationException} class. 24 | * 25 | * @param Validation $validation The {@link Validation} object for the exception. 26 | */ 27 | public function __construct(Validation $validation) { 28 | $this->validation = $validation; 29 | parent::__construct($validation->getFullMessage(), $validation->getCode()); 30 | } 31 | 32 | /** 33 | * Specify data which should be serialized to JSON. 34 | * 35 | * @link http://php.net/manual/en/jsonserializable.jsonserialize.php 36 | * @return mixed data which can be serialized by json_encode, 37 | * which is a value of any type other than a resource. 38 | */ 39 | #[\ReturnTypeWillChange] 40 | public function jsonSerialize() { 41 | return $this->validation->jsonSerialize(); 42 | } 43 | 44 | /** 45 | * Get the validation object that contain specific errors. 46 | * 47 | * @return Validation Returns a validation object. 48 | */ 49 | public function getValidation() { 50 | return $this->validation; 51 | } 52 | 53 | /** 54 | * Get the first validation error message from the internal Validation object. 55 | * 56 | * Useful to present a user-friendly error message when multiple validation errors may exist, but we only need the first one. 57 | * 58 | * @return string 59 | */ 60 | public function getFirstValidationMessage(): string { 61 | $errors = $this->validation->getErrors(); 62 | 63 | if (!empty($errors)) { 64 | $first = reset($errors); 65 | return $first['messageCode'] ?? $first['message'] ?? 'Validation error'; 66 | } 67 | 68 | return 'Validation failed.'; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ArrayRefLookup.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema; 9 | 10 | /** 11 | * A schema `$ref` lookup that searches arrays. 12 | * 13 | * @see https://swagger.io/docs/specification/using-ref/ 14 | */ 15 | class ArrayRefLookup { 16 | /** 17 | * @var array|\ArrayAccess 18 | */ 19 | private $array; 20 | 21 | /** 22 | * ArrayRefLookup constructor. 23 | * 24 | * @param array|\ArrayAccess $array The array that is searched. 25 | */ 26 | public function __construct($array) { 27 | $this->array = $array; 28 | } 29 | 30 | /** 31 | * Lookup a schema based on a JSON ref. 32 | * 33 | * @param string $ref A valid JSON ref. 34 | * @return mixed|null Returns the value at the reference or **null** if the reference isn't found. 35 | * @see https://swagger.io/docs/specification/using-ref/ 36 | */ 37 | public function __invoke(string $ref) { 38 | $urlParts = parse_url($ref); 39 | if (!empty($urlParts['host']) || !empty($urlParts['path'])) { 40 | throw new \InvalidArgumentException("Only local schema references are supported. ($ref)", 400); 41 | } 42 | $fragment = $urlParts['fragment'] ?? ''; 43 | if (strlen($fragment) === 0 || $fragment[0] !== '/') { 44 | throw new \InvalidArgumentException("Relative schema references are not supported. ($ref)", 400); 45 | } 46 | 47 | if ($fragment === '/') { 48 | return $this->array; 49 | } 50 | $parts = Schema::explodeRef(substr($fragment, 1)); 51 | 52 | $value = $this->array; 53 | foreach ($parts as $key) { 54 | if (!is_string($value) && isset($value[$key])) { 55 | $value = $value[$key]; 56 | } else { 57 | return null; 58 | } 59 | } 60 | return $value; 61 | } 62 | 63 | /** 64 | * Get the array. 65 | * 66 | * @return array|\ArrayAccess Returns the array. 67 | */ 68 | public function getArray() { 69 | return $this->array; 70 | } 71 | 72 | /** 73 | * Set the array. 74 | * 75 | * @param array|\ArrayAccess $array 76 | * @return $this 77 | */ 78 | public function setArray($array) { 79 | $this->array = $array; 80 | return $this; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Fixtures/CustomArray.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests\Fixtures; 9 | 10 | 11 | use Traversable; 12 | 13 | class CustomArray implements \ArrayAccess, \IteratorAggregate { 14 | private $arr = []; 15 | 16 | 17 | /** 18 | * Whether a offset exists 19 | * @link http://php.net/manual/en/arrayaccess.offsetexists.php 20 | * @param mixed $offset

21 | * An offset to check for. 22 | *

23 | * @return boolean true on success or false on failure. 24 | *

25 | *

26 | * The return value will be casted to boolean if non-boolean was returned. 27 | * @since 5.0.0 28 | */ 29 | public function offsetExists($offset): bool { 30 | return isset($this->arr[$offset]); 31 | } 32 | 33 | /** 34 | * Offset to retrieve 35 | * @link http://php.net/manual/en/arrayaccess.offsetget.php 36 | * @param mixed $offset

37 | * The offset to retrieve. 38 | *

39 | * @return mixed Can return all value types. 40 | * @since 5.0.0 41 | */ 42 | #[\ReturnTypeWillChange] 43 | public function offsetGet($offset) { 44 | return $this->arr[$offset]; 45 | } 46 | 47 | /** 48 | * Offset to set 49 | * @link http://php.net/manual/en/arrayaccess.offsetset.php 50 | * @param mixed $offset

51 | * The offset to assign the value to. 52 | *

53 | * @param mixed $value

54 | * The value to set. 55 | *

56 | * @return void 57 | * @since 5.0.0 58 | */ 59 | public function offsetSet($offset, $value): void { 60 | if ($offset === null) { 61 | $this->arr[] = $value; 62 | } else { 63 | $this->arr[$offset] = $value; 64 | } 65 | } 66 | 67 | /** 68 | * Offset to unset 69 | * @link http://php.net/manual/en/arrayaccess.offsetunset.php 70 | * @param mixed $offset

71 | * The offset to unset. 72 | *

73 | * @return void 74 | * @since 5.0.0 75 | */ 76 | public function offsetUnset($offset): void { 77 | unset($this->arr[$offset]); 78 | } 79 | 80 | public function getArrayCopy() { 81 | return $this->arr; 82 | } 83 | 84 | /** 85 | * Retrieve an external iterator 86 | * @link http://php.net/manual/en/iteratoraggregate.getiterator.php 87 | * @return Traversable An instance of an object implementing Iterator or 88 | * Traversable 89 | * @since 5.0.0 90 | */ 91 | public function getIterator(): Traversable { 92 | return new \ArrayIterator($this->arr); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Fixtures/TestValidation.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests\Fixtures; 9 | 10 | use Garden\Schema\Validation; 11 | 12 | /** 13 | * A validation object for testing the translations. 14 | */ 15 | class TestValidation extends Validation { 16 | private $prefix; 17 | 18 | /** 19 | * TestValidation constructor. 20 | * 21 | * @param string $prefix The prefix for each translation. 22 | */ 23 | public function __construct(string $prefix = '!') { 24 | $this->prefix = $prefix; 25 | } 26 | 27 | /** 28 | * Call the parent's translation for testing translations specifically. 29 | * 30 | * @param string $str The string to translate. 31 | * @return string Returns the translated string. 32 | */ 33 | public function parentTranslate(string $str): string { 34 | return parent::translate($str); 35 | } 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public function translate(string $str): string { 41 | if (substr($str, 0, 1) === '@') { 42 | // This is a literal string that bypasses translation. 43 | return substr($str, 1); 44 | } else { 45 | return $this->prefix.parent::translate($str); 46 | } 47 | } 48 | 49 | /** 50 | * Add brackets around fields so they can be seen in tests. 51 | * 52 | * @param string $field The field name. 53 | * @return string Returns the formatted field name. 54 | */ 55 | public function formatFieldName(string $field): string { 56 | if ($this->getTranslateFieldNames()) { 57 | return parent::formatFieldName($field); 58 | } 59 | return "[$field]"; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function formatValue($value): string { 66 | $r = parent::formatValue($value); 67 | if ($this->getTranslateFieldNames()) { 68 | $r = $this->translate($r); 69 | } 70 | return $r; 71 | } 72 | 73 | /** 74 | * Create a factory function for this class. 75 | * 76 | * @param string $prefix The prefix for the constructor. 77 | * @param bool $translateFieldNames Whether or not the validation object has the `translateFieldNames` property set. 78 | * @return \Closure Returns a factory function. 79 | */ 80 | public static function createFactory(string $prefix = '!', bool $translateFieldNames = false) { 81 | return function () use ($prefix, $translateFieldNames) { 82 | $r = new static($prefix); 83 | $r->setTranslateFieldNames($translateFieldNames); 84 | 85 | return $r; 86 | }; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /open-api.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "Garden Schema Data Formats", 5 | "version": "2.0.0-alpha", 6 | "license": { 7 | "name": "MIT", 8 | "url": "https://github.com/vanilla/garden-schema/blob/master/LICENSE.md" 9 | } 10 | }, 11 | "paths": { 12 | "/validation-error": { 13 | "get": { 14 | "responses": { 15 | "200": { 16 | "description": "Get a sample validation error.", 17 | "content": { 18 | "application/json": { 19 | "schema": { 20 | "$ref": "#/components/schemas/ValidationError" 21 | } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | }, 29 | "components": { 30 | "schemas": { 31 | "ValidationError": { 32 | "description": "Contains error information when a schema fails to validate.", 33 | "type": "object", 34 | "properties": { 35 | "message": { 36 | "type": "string", 37 | "description": "The main error message." 38 | }, 39 | "code": { 40 | "type": "integer", 41 | "description": "The maximum error code.", 42 | "default": 400 43 | }, 44 | "errors": { 45 | "title": "Field Errors", 46 | "description": "A mapping of field references to errors.", 47 | "type": "array", 48 | "items": { 49 | "type": "object", 50 | "properties": { 51 | "message": { 52 | "description": "A human-readable error message.", 53 | "type": "string" 54 | }, 55 | "error": { 56 | "description": "A string code for the specific validation error meant for consumption by code.", 57 | "type": "string" 58 | }, 59 | "code": { 60 | "description": "A string code for the specific validation error meant for consumption by code.", 61 | "type": "string" 62 | }, 63 | "status": { 64 | "description": "An HTTP-style error code for the error.", 65 | "type": "integer" 66 | }, 67 | "field": { 68 | "description": "Field the error applies to", 69 | "type": "string" 70 | } 71 | }, 72 | "required": [ 73 | "message", 74 | "error", 75 | "status", 76 | "field" 77 | ], 78 | "example": { 79 | "message": "The value should be at least 10 characters long.", 80 | "error": "minLength", 81 | "field": "myField", 82 | "status": 400 83 | } 84 | } 85 | } 86 | }, 87 | "required": ["message", "code", "errors"], 88 | "example": { 89 | "message": "Validation failed.", 90 | "code": 400, 91 | "errors": { 92 | "booleans": [ 93 | { "message": "The value is not a valid boolean.", "error": "type" } 94 | ], 95 | "strings": [ 96 | { "message": "The value is not a valid string.", "error": "type" }, 97 | { "message": "The value should be at least 5 characters long.", "error": "minLength" }, 98 | { "message": "The value is 4 characters too long.", "error": "maxLength" }, 99 | { "message": "The value doesn't match the required pattern.", "error": "pattern" } 100 | ], 101 | "objects": [ 102 | { "message": "The value is not a valid object.", "error": "type" }, 103 | { "message": "Property is required.", "error": "required" } 104 | ] 105 | } 106 | } 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/InputOutputTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | 12 | /** 13 | * Tests for OpenAPI's readOnly and writeOnly community. 14 | */ 15 | class InputOutputTest extends AbstractSchemaTest { 16 | /** 17 | * @var Schema 18 | */ 19 | private $schema; 20 | 21 | /** 22 | * @var array 23 | */ 24 | private $data; 25 | 26 | /** 27 | * Create some test data for each test. 28 | */ 29 | public function setUp(): void { 30 | parent::setUp(); 31 | 32 | $this->schema = new Schema([ 33 | 'type' => 'object', 34 | 'properties' => [ 35 | 'r' => [ 36 | 'type' => 'string', 37 | 'readOnly' => true, 38 | ], 39 | 'w' => [ 40 | 'type' => 'string', 41 | 'writeOnly' => true, 42 | ], 43 | 'rw' => [ 44 | 'type' => 'string' 45 | ] 46 | ], 47 | 'additionalProperties' => true, 48 | 'required' => ['r', 'w'], 49 | ]); 50 | 51 | $this->data = [ 52 | 'r' => 'r', 53 | 'w' => 'w', 54 | 'rw' => 'rw', 55 | ]; 56 | } 57 | 58 | /** 59 | * Requests should strip readOnly properties. 60 | */ 61 | public function testReadOnlyRequestStrip() { 62 | $valid = $this->schema->validate($this->data, ['request' => true]); 63 | 64 | $this->assertEquals(['w' => 'w', 'rw' => 'rw'], $valid); 65 | } 66 | 67 | /** 68 | * Required readOnly properties are not required when making a request. 69 | */ 70 | public function testReadOnlyRequest() { 71 | $valid = $this->schema->validate(['w' => 'w'], ['request' => true]); 72 | 73 | $this->assertEquals(['w' => 'w'], $valid); 74 | } 75 | 76 | /** 77 | * Responses should strip writeOnly properties. 78 | */ 79 | public function testWriteOnlyResponseStrip() { 80 | $valid = $this->schema->validate($this->data, ['response' => true]); 81 | $this->assertEquals(['r' => 'r', 'rw' => 'rw'], $valid); 82 | } 83 | 84 | /** 85 | * Required writeOnly properties are not required when validating a response. 86 | */ 87 | public function testWriteOnlyResponse() { 88 | $valid = $this->schema->validate(['r' => 'r'], ['response' => true]); 89 | 90 | $this->assertEquals(['r' => 'r'], $valid); 91 | } 92 | 93 | /** 94 | * Requests should not treat readOnly properties as additional properties. 95 | */ 96 | public function testReadOnlyWithAdditionalProperties() { 97 | $valid = $this->schema->validate($this->data + ['a' => 'a'], ['request' => true]); 98 | $this->assertEquals(['w' => 'w', 'rw' => 'rw', 'a' => 'a'], $valid); 99 | } 100 | 101 | /** 102 | * Responses should not treat writeOnly properties as additional properties. 103 | */ 104 | public function testWriteOnlyWithAdditionalProperties() { 105 | $valid = $this->schema->validate($this->data + ['a' => 'a'], ['response' => true]); 106 | $this->assertEquals(['r' => 'r', 'rw' => 'rw', 'a' => 'a'], $valid); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tests/ObjectValidationTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | use Garden\Schema\ValidationException; 12 | 13 | class ObjectValidationTest extends AbstractSchemaTest { 14 | /** 15 | * Test the maxProperties property. 16 | */ 17 | public function testMaxProperties() { 18 | $sch = new Schema([ 19 | 'type' => 'object', 20 | 'maxProperties' => 1 21 | ]); 22 | 23 | $this->assertTrue($sch->isValid(['a' => 1])); 24 | $this->assertFalse($sch->isValid(['a' => 1, 'b' => 2])); 25 | } 26 | 27 | /** 28 | * Test the minProperties property. 29 | */ 30 | public function testMinProperties() { 31 | $sch = new Schema([ 32 | 'type' => 'object', 33 | 'minProperties' => 2 34 | ]); 35 | 36 | $this->assertTrue($sch->isValid(['a' => 1, 'b' => 2])); 37 | $this->assertFalse($sch->isValid(['a' => 1])); 38 | } 39 | 40 | /** 41 | * Test the additionalProperties validator without a properties validator. 42 | */ 43 | public function testAdditionalProperties() { 44 | $sch = new Schema([ 45 | 'type' => 'object', 46 | 'additionalProperties' => [ 47 | 'type' => 'boolean', 48 | ], 49 | ]); 50 | 51 | $valid = $sch->validate(['a' => 'false', 'B' => 'true']); 52 | $this->assertEquals(['a' => false, 'B' => true], $valid); 53 | } 54 | 55 | /** 56 | * Test properties and additionalProperties together. 57 | */ 58 | public function testPropertiesWithAdditionalProperties() { 59 | $sch = new Schema([ 60 | 'type' => 'object', 61 | 'properties' => [ 62 | 'b' => [ 63 | 'type' => 'string', 64 | ], 65 | ], 66 | 'additionalProperties' => [ 67 | 'type' => 'boolean', 68 | ], 69 | ]); 70 | 71 | $valid = $sch->validate(['A' => 'false', 'b' => 'true']); 72 | $this->assertEquals(['A' => false, 'b' => 'true'], $valid); 73 | } 74 | 75 | /** 76 | * Additional Properties of **true** should always validate. 77 | */ 78 | public function testTrueAdditionalProperties() { 79 | $sch = new Schema([ 80 | 'type' => 'object', 81 | 'properties' => [ 82 | 'b' => [ 83 | 'type' => 'string', 84 | ], 85 | ], 86 | 'additionalProperties' => true, 87 | ]); 88 | 89 | $valid = $sch->validate(['A' => 'false', 'b' => 'true']); 90 | $this->assertEquals(['A' => 'false', 'b' => 'true'], $valid); 91 | } 92 | 93 | /** 94 | * Properties that have special characters should show up as escaped in errors. 95 | */ 96 | public function testPropertyEscapingErrors() { 97 | $sch = new Schema([ 98 | 'type' => 'object', 99 | 'properties' => [ 100 | '~/' => [ 101 | 'type' => 'integer', 102 | ], 103 | ], 104 | ]); 105 | 106 | try { 107 | $valid = $sch->validate(['~/' => 123.4]); 108 | $this->fail("There should be a validation exception."); 109 | } catch (ValidationException $ex) { 110 | $this->assertSame('value: 123.4 is not a valid integer.', $ex->getValidation()->getConcatMessage('~0~1')); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/NumericValidationTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | use Garden\Schema\ValidationException; 12 | use PHPUnit\Framework\TestCase; 13 | 14 | class NumericValidationTest extends TestCase { 15 | 16 | 17 | /** 18 | * Test multipleOf tests. 19 | * 20 | * @param int|float $value The value to test. 21 | * @param int|float $multipleOf The multiple of property. 22 | * @param bool $expected Whether or not the value should be valid. 23 | * @dataProvider provideMultipleOfTests 24 | */ 25 | public function testMultipleOf($value, $multipleOf, bool $expected) { 26 | $sch = new Schema([ 27 | 'type' => 'number', 28 | 'multipleOf' => $multipleOf 29 | ]); 30 | 31 | $valid = $sch->isValid($value); 32 | $this->assertSame($expected, $valid); 33 | } 34 | 35 | /** 36 | * Generate multipleOf tests. 37 | * 38 | * @return array Returns a data provider. 39 | */ 40 | public function provideMultipleOfTests() { 41 | $r = [ 42 | [1, 1, true], 43 | [3, 2, false], 44 | [5.5, 5, false], 45 | [5.5, 1.1, true], 46 | ]; 47 | 48 | return $r; 49 | } 50 | 51 | /** 52 | * Test the maximum and exclusiveMaximum properties. 53 | * 54 | * @param int $max The maximum property. 55 | * @param bool $exclusive The exclusiveMaximum property. 56 | * @param bool $expected The expected valid result. 57 | * @dataProvider provideMaximumTests 58 | */ 59 | public function testMaximum($max, bool $exclusive, bool $expected) { 60 | $sch = new Schema([ 61 | 'type' => 'integer', 62 | 'maximum' => $max, 63 | 'exclusiveMaximum' => $exclusive, 64 | ]); 65 | 66 | try { 67 | $sch->validate(5); 68 | $this->assertTrue($expected); 69 | } catch (ValidationException $ex) { 70 | $this->assertFalse($expected); 71 | } 72 | } 73 | 74 | /** 75 | * Generate maximum property test data. 76 | * 77 | * @return mixed Returns a data provider array. 78 | */ 79 | public function provideMaximumTests() { 80 | $r = [ 81 | [5, false, true], 82 | [4, false, false], 83 | [5, true, false], 84 | [6, true, true], 85 | ]; 86 | 87 | return $r; 88 | } 89 | 90 | /** 91 | * Test the minimum and exclusiveMinimum properties. 92 | * 93 | * @param int $min The minimum property. 94 | * @param bool $exclusive The exclusiveMinimum property. 95 | * @param bool $expected The expected valid result. 96 | * @dataProvider provideMinimumTests 97 | */ 98 | public function testMinimum($min, bool $exclusive, bool $expected) { 99 | $sch = new Schema([ 100 | 'type' => 'integer', 101 | 'minimum' => $min, 102 | 'exclusiveMinimum' => $exclusive, 103 | ]); 104 | 105 | try { 106 | $sch->validate(5); 107 | $this->assertTrue($expected); 108 | } catch (ValidationException $ex) { 109 | $this->assertFalse($expected); 110 | } 111 | } 112 | 113 | /** 114 | * Generate minimum property test data. 115 | * 116 | * @return mixed Returns a data provider array. 117 | */ 118 | public function provideMinimumTests() { 119 | $r = [ 120 | [5, false, true], 121 | [6, false, false], 122 | [5, true, false], 123 | [4, true, true], 124 | ]; 125 | 126 | return $r; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v1.6](https://github.com/vanilla/garden-schema/tree/v1.6) (2017-08-22) 4 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v1.5.0...v1.6) 5 | 6 | **Implemented enhancements:** 7 | 8 | - Minor schema enhancements [\#17](https://github.com/vanilla/garden-schema/pull/17) ([tburry](https://github.com/tburry)) 9 | 10 | **Closed issues:** 11 | 12 | - \[API v2\] Date time does not validate properly if the timezone is used [\#16](https://github.com/vanilla/garden-schema/issues/16) 13 | 14 | **Merged pull requests:** 15 | 16 | - Upgrade Travis dist to Trusty [\#14](https://github.com/vanilla/garden-schema/pull/14) ([initvector](https://github.com/initvector)) 17 | 18 | ## [v1.5.0](https://github.com/vanilla/garden-schema/tree/v1.5.0) (2017-05-27) 19 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v1.4.2...v1.5.0) 20 | 21 | **Implemented enhancements:** 22 | 23 | - Make the validation of empty values a bit more lax [\#13](https://github.com/vanilla/garden-schema/pull/13) ([tburry](https://github.com/tburry)) 24 | - Add schema accessor methods [\#11](https://github.com/vanilla/garden-schema/pull/11) ([tburry](https://github.com/tburry)) 25 | 26 | **Merged pull requests:** 27 | 28 | - README tweaks [\#12](https://github.com/vanilla/garden-schema/pull/12) ([kaecyra](https://github.com/kaecyra)) 29 | 30 | ## [v1.4.2](https://github.com/vanilla/garden-schema/tree/v1.4.2) (2017-03-17) 31 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v1.4.1...v1.4.2) 32 | 33 | **Implemented enhancements:** 34 | 35 | - Add ability to pass constructor arguments with Schema::parse [\#10](https://github.com/vanilla/garden-schema/pull/10) ([initvector](https://github.com/initvector)) 36 | 37 | ## [v1.4.1](https://github.com/vanilla/garden-schema/tree/v1.4.1) (2017-03-17) 38 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v1.4.0...v1.4.1) 39 | 40 | **Implemented enhancements:** 41 | 42 | - Allow Schema subclasses to be returned from parse method [\#9](https://github.com/vanilla/garden-schema/pull/9) ([initvector](https://github.com/initvector)) 43 | 44 | ## [v1.4.0](https://github.com/vanilla/garden-schema/tree/v1.4.0) (2017-03-14) 45 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v1.3.0...v1.4.0) 46 | 47 | **Implemented enhancements:** 48 | 49 | - Add the Schema::add\(\) method [\#6](https://github.com/vanilla/garden-schema/pull/6) ([tburry](https://github.com/tburry)) 50 | 51 | ## [v1.3.0](https://github.com/vanilla/garden-schema/tree/v1.3.0) (2017-03-14) 52 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v1.2.0...v1.3.0) 53 | 54 | **Implemented enhancements:** 55 | 56 | - Add support for validating objects that implement ArrayAccess [\#5](https://github.com/vanilla/garden-schema/pull/5) ([tburry](https://github.com/tburry)) 57 | 58 | **Closed issues:** 59 | 60 | - Add an allowNull stop-gap [\#3](https://github.com/vanilla/garden-schema/issues/3) 61 | 62 | ## [v1.2.0](https://github.com/vanilla/garden-schema/tree/v1.2.0) (2017-03-12) 63 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v1.1.0...v1.2.0) 64 | 65 | **Implemented enhancements:** 66 | 67 | - Add support for allowNull and default [\#4](https://github.com/vanilla/garden-schema/pull/4) ([tburry](https://github.com/tburry)) 68 | 69 | ## [v1.1.0](https://github.com/vanilla/garden-schema/tree/v1.1.0) (2017-03-10) 70 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v1.0.0...v1.1.0) 71 | 72 | **Implemented enhancements:** 73 | 74 | - Add support for some string formats [\#1](https://github.com/vanilla/garden-schema/pull/1) ([tburry](https://github.com/tburry)) 75 | 76 | ## [v1.0.0](https://github.com/vanilla/garden-schema/tree/v1.0.0) (2017-03-10) 77 | [Full Changelog](https://github.com/vanilla/garden-schema/compare/v0.1.0...v1.0.0) 78 | 79 | ## [v0.1.0](https://github.com/vanilla/garden-schema/tree/v0.1.0) (2017-03-09) 80 | 81 | 82 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* -------------------------------------------------------------------------------- /tests/PropertyTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use PHPUnit\Framework\TestCase; 11 | use Garden\Schema\Schema; 12 | 13 | /** 14 | * Test property access of the schema object itself. 15 | */ 16 | class PropertyTest extends TestCase { 17 | /** 18 | * Test basic property access. 19 | */ 20 | public function testPropertyAccess() { 21 | $schema = Schema::parse([]); 22 | 23 | $this->assertEmpty($schema->getDescription()); 24 | $schema->setDescription('foo'); 25 | $this->assertSame('foo', $schema->getDescription()); 26 | $this->assertSame('foo', $schema->jsonSerialize()['description']); 27 | $this->assertSame('foo', $schema['description']); 28 | 29 | $schema->setFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE, false); 30 | $this->assertSame(0, $schema->getFlags()); 31 | $behaviors = [ 32 | Schema::VALIDATE_EXTRA_PROPERTY_NOTICE, 33 | Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION 34 | ]; 35 | foreach ($behaviors as $behavior) { 36 | $schema->setFlags($behavior); 37 | $this->assertSame($behavior, $schema->getFlags()); 38 | } 39 | } 40 | 41 | /** 42 | * Test **getID** and **setID**. 43 | */ 44 | public function testGetSetID() { 45 | $schema = new Schema(); 46 | 47 | $this->assertEmpty($schema->getID()); 48 | $schema->setID('test'); 49 | $this->assertEquals('test', $schema->getID()); 50 | } 51 | 52 | /** 53 | * Test flag getters and setters. 54 | */ 55 | public function testGetSetFlag() { 56 | $schema = new Schema(); 57 | 58 | $schema->setFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE, true); 59 | $this->assertTrue($schema->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)); 60 | 61 | $schema->setFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION, true); 62 | $this->assertTrue($schema->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)); 63 | $this->assertTrue($schema->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)); 64 | 65 | $schema->setFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE, false); 66 | $this->assertFalse($schema->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)); 67 | $this->assertTrue($schema->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)); 68 | } 69 | 70 | /** 71 | * The validation behavior should be an appropriate constant. 72 | */ 73 | public function testInvalidBehavior() { 74 | $this->expectException(\TypeError::class); 75 | $schema = Schema::parse([]); 76 | $schema->setFlags('foo'); 77 | } 78 | 79 | /** 80 | * Test deep field getting. 81 | */ 82 | public function testGetField() { 83 | $schema = Schema::parse([':a' => 's']); 84 | 85 | $this->assertSame('string', $schema->getField('items/type')); 86 | } 87 | 88 | /** 89 | * Test deep field setting. 90 | */ 91 | public function testSetField() { 92 | $schema = Schema::parse([':a' => 's']); 93 | 94 | $schema->setField('items/type', 'integer'); 95 | $this->assertSame('integer', $schema->getField('items/type')); 96 | } 97 | 98 | /** 99 | * The schema object should be accessible like an array. 100 | */ 101 | public function testArrayAccess() { 102 | $schema = Schema::parse([':a' => 's']); 103 | 104 | $schema['id'] = 'foo'; 105 | $this->assertEquals('foo', $schema['id']); 106 | 107 | unset($schema['id']); 108 | $this->assertFalse(isset($schema['id'])); 109 | } 110 | 111 | /** 112 | * Test nested schema deep field access. 113 | */ 114 | public function testNestedGetSetField() { 115 | $sc1 = Schema::parse([':a' => 's']); 116 | $sc2 = Schema::parse(['id:i', 'name:s']); 117 | 118 | $sc1->setField('items', $sc2); 119 | 120 | $this->assertSame('string', $sc1->getField('items/properties/name/type')); 121 | 122 | $sc1->setField('items/properties/name/type', 'integer'); 123 | $this->assertSame('integer', $sc1->getField('items/properties/name/type')); 124 | $this->assertSame('integer', $sc2->getField('properties/name/type')); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/ArrayRefLookupTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\ArrayRefLookup; 11 | use PHPUnit\Framework\TestCase; 12 | 13 | /** 14 | * Tests for the `ArrayRefLookup` class. 15 | */ 16 | class ArrayRefLookupTest extends TestCase { 17 | /** 18 | * @var ArrayRefLookup 19 | */ 20 | private $lookup; 21 | 22 | /** 23 | * Create test object for each test. 24 | */ 25 | public function setUp(): void { 26 | parent::setUp(); 27 | 28 | $this->lookup = new ArrayRefLookup([ 29 | 'foo' => [ 30 | 'bar' => new \ArrayObject([ 31 | '~/baz' => 123, 32 | ]), 33 | ], 34 | ]); 35 | } 36 | 37 | /** 38 | * Test basic getter/setter behavior. 39 | */ 40 | public function testArrayGetterSetter() { 41 | $arr = new ArrayRefLookup(['foo' => 'bar']); 42 | $this->assertEquals(['foo' => 'bar'], $arr->getArray()); 43 | 44 | $arr->setArray(['foo' => 'baz']); 45 | $this->assertEquals(['foo' => 'baz'], $arr->getArray()); 46 | } 47 | 48 | /** 49 | * Lookup a reference on the test lookup object. 50 | * 51 | * @param string $ref The reference to find. 52 | * @return mixed|null Returns the resolved reference. 53 | */ 54 | protected function lookupRef(string $ref) { 55 | return $this->lookup->__invoke($ref); 56 | } 57 | 58 | /** 59 | * The root reference should return the entire array.s 60 | */ 61 | public function testRootLookup() { 62 | $this->assertRefLookup('#/', $this->lookup->getArray()); 63 | } 64 | 65 | /** 66 | * Test a lookup one array deep. 67 | */ 68 | public function testFolder() { 69 | $this->assertRefLookup('#/foo', $this->lookup->getArray()['foo']); 70 | } 71 | 72 | /** 73 | * Test a lookup one level deep. 74 | */ 75 | public function testPath() { 76 | $this->assertRefLookup('#/foo/bar', $this->lookup->getArray()['foo']['bar']); 77 | } 78 | 79 | /** 80 | * Test a lookup on a path with special characters. 81 | */ 82 | public function testEscapedPath() { 83 | $this->assertRefLookup('#/foo/bar/~0~1baz', $this->lookup->getArray()['foo']['bar']['~/baz']); 84 | } 85 | 86 | /** 87 | * Test a lookup where the key doesn't exist. 88 | */ 89 | public function testMissingKey() { 90 | $this->assertRefLookup('#/not', null); 91 | } 92 | 93 | /** 94 | * Test a lookup where the parent key exists, but the child doesn't. 95 | */ 96 | public function testPartialMissingKey() { 97 | $this->assertRefLookup('#/foo/not', null); 98 | } 99 | 100 | /** 101 | * The array lookup does not support hosts. 102 | */ 103 | public function testHostLookup() { 104 | $this->expectException(\InvalidArgumentException::class); 105 | $this->assertRefLookup('http://example.com#/foo', null); 106 | } 107 | 108 | /** 109 | * The array lookup does not support paths. 110 | */ 111 | public function testPathLookup() { 112 | $this->expectException(\InvalidArgumentException::class); 113 | $this->assertRefLookup('/foo#/foo', null); 114 | } 115 | 116 | /** 117 | * The array lookup does not support relative references. 118 | */ 119 | public function testRelativeReference() { 120 | $this->expectException(\InvalidArgumentException::class); 121 | $this->assertRefLookup('#foo', null); 122 | } 123 | 124 | /** 125 | * The array lookup does not support relative references. 126 | */ 127 | public function testEmptyReference() { 128 | $this->expectException(\InvalidArgumentException::class); 129 | $this->assertRefLookup('#', null); 130 | } 131 | 132 | /** 133 | * Assert the value of a reference lookup./ 134 | * 135 | * @param string $ref The reference to lookup. 136 | * @param mixed $expected The expected lookup result. 137 | */ 138 | public function assertRefLookup(string $ref, $expected) { 139 | $val = $this->lookupRef($ref); 140 | if ($expected === null) { 141 | $this->assertNull($val); 142 | } else { 143 | $this->assertEquals($expected, $val); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/RealWorldTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | use PHPUnit\Framework\TestCase; 12 | 13 | class RealWorldTest extends TestCase { 14 | /** 15 | * An optional field should be stripped when provided with an empty string. 16 | */ 17 | public function testEmptyOptional() { 18 | $sch = Schema::parse(['a:i?']); 19 | 20 | $valid = $sch->validate(['a' => '']); 21 | $this->assertSame([], $valid); 22 | } 23 | 24 | /** 25 | * An optional field should be stripped when provided with null. 26 | */ 27 | public function testNullOptional() { 28 | $sch = Schema::parse(['a:i?']); 29 | 30 | $valid = $sch->validate(['a' => null]); 31 | $this->assertSame([], $valid); 32 | } 33 | 34 | /** 35 | * A nullable field should convert an empty string to null. 36 | */ 37 | public function testEmptyNullable() { 38 | $sch = Schema::parse(['a:i|n']); 39 | 40 | $valid = $sch->validate(['a' => '']); 41 | $this->assertSame(['a' => null], $valid); 42 | } 43 | 44 | /** 45 | * A nullable optional field should convert various values to null. 46 | */ 47 | public function testNullableOptional() { 48 | $sch = Schema::parse(['a:i|n?']); 49 | 50 | $valid = $sch->validate(['a' => '']); 51 | $this->assertSame(['a' => null], $valid); 52 | 53 | $valid = $sch->validate(['a' => null]); 54 | $this->assertSame(['a' => null], $valid); 55 | } 56 | 57 | /** 58 | * An optional string field should not strip empty strings. 59 | */ 60 | public function testOptionalEmptyString() { 61 | $sch = Schema::parse(['a:s?']); 62 | 63 | $valid = $sch->validate(['a' => '']); 64 | $this->assertSame(['a' => ''], $valid); 65 | } 66 | 67 | /** 68 | * Test schema extending! 69 | */ 70 | public function testNestedMergedFilteredAdd() { 71 | $data = [ 72 | 'property1' => true, 73 | 'property2' => false, 74 | 'sub-schema' => [ 75 | 'sub-property1' => true, 76 | 'sub-property2' => false, 77 | ] 78 | ]; 79 | $expectedData = [ 80 | 'property1' => true, 81 | 'sub-schema' => [ 82 | 'sub-property2' => false, 83 | ] 84 | ]; 85 | 86 | $subSchema1Definition = [ 87 | 'sub-property1:b' => 'Sub property 1', 88 | ]; 89 | $subSchema2Definition = [ 90 | 'sub-property2:b' => 'Sub property 2', 91 | ]; 92 | $mergedSubSchema = Schema::parse($subSchema1Definition)->merge( 93 | Schema::parse($subSchema2Definition) 94 | ); 95 | 96 | $schema1Definition = [ 97 | 'property1:b' => 'Property 1', 98 | 'sub-schema' => Schema::parse($subSchema1Definition), 99 | ]; 100 | $schema2Definition = [ 101 | 'property2:b' => 'Property 2', 102 | 'sub-schema' => Schema::parse($subSchema2Definition), 103 | ]; 104 | $mergedSchema = Schema::parse($schema1Definition)->merge( 105 | Schema::parse($schema2Definition) 106 | ); 107 | 108 | // Buil a schema by extending other schemas! 109 | $filteredSchema = Schema::parse([ 110 | 'property1' => null, 111 | 'sub-schema' => Schema::parse([ 112 | 'sub-property2' => null, 113 | ])->add($mergedSubSchema), 114 | ])->add($mergedSchema); 115 | 116 | $validatedData = $filteredSchema->validate($data); 117 | $this->assertEquals($expectedData, $validatedData); 118 | } 119 | 120 | /** 121 | * Colons should be allowed in property name short definitions. 122 | */ 123 | public function testColonShortParse() { 124 | $sch = Schema::parse([ 125 | 'foo:bar:b', 126 | ]); 127 | 128 | $this->assertEquals('boolean', $sch->getField('properties/foo:bar/type')); 129 | } 130 | 131 | /** 132 | * A nested schema object should have its default value respected. 133 | */ 134 | public function testNestedDefaultRequired() { 135 | $schema = Schema::parse([ 136 | 'letter' => Schema::parse([ 137 | 'default' => 'a', 138 | 'type' => 'string', 139 | ]) 140 | ]); 141 | $data = []; 142 | $data = $schema->validate($data); 143 | 144 | $this->assertEquals(['letter' => 'a'], $data); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/Version1CompatTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2025 Higher Logic 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | use PHPUnit\Framework\TestCase; 12 | use Garden\Schema\Validation; 13 | use Garden\Schema\Tests\Fixtures\TestValidation; 14 | 15 | /** 16 | * Version 2.x made some huge backwards incompatibilities. 17 | * 18 | * Version 4.x undoes these so vanilla/vanilla-cloud can upgrade. 19 | * 20 | * 21 | */ 22 | class Version1CompatTest extends TestCase { 23 | 24 | /** 25 | * @return void 26 | */ 27 | public function testDateTimeAndTimestampAliases(): void { 28 | $schema = Schema::parse([ 29 | "tsShortform:ts?", 30 | "tsLongform?" => [ 31 | "type" => "timestamp", 32 | ], 33 | "dtShortform:dt?", 34 | "dtLongform" => [ 35 | "type" => "datetime", 36 | ], 37 | ]); 38 | 39 | $schemaArray = $schema->getSchemaArray(); 40 | 41 | $this->assertEquals([ 42 | "type" => "object", 43 | "properties" => [ 44 | "tsShortform" => [ 45 | "type" => "integer", 46 | "format" => "timestamp", 47 | ], 48 | "tsLongform" => [ 49 | "type" => "integer", 50 | "format" => "timestamp", 51 | ], 52 | "dtShortform" => [ 53 | "type" => "string", 54 | "format" => "date-time", 55 | ], 56 | "dtLongform" => [ 57 | "type" => "string", 58 | "format" => "date-time", 59 | ], 60 | ], 61 | "required" => ["dtLongform"], 62 | ], $schemaArray); 63 | } 64 | 65 | /** 66 | * @return void 67 | */ 68 | public function testGetFieldWithDot(): void { 69 | $schema = Schema::parse([ 70 | "foo" => Schema::parse([ 71 | "nested" => Schema::parse([ 72 | "bar:s", 73 | ]) 74 | ]) 75 | ]); 76 | 77 | $this->assertEquals("string", $schema->getField("properties.foo.properties.nested.properties.bar.type")); 78 | } 79 | 80 | /** 81 | * @return void 82 | */ 83 | public function testValidateWholeObjectWithEmptyString(): void { 84 | 85 | $arrFromValidator = null; 86 | 87 | $schema = Schema::parse([ 88 | "foo:s", 89 | "bar:s", 90 | ])->addValidator("", function (array $arr) use (&$arrFromValidator) { 91 | $arrFromValidator = $arr; 92 | }); 93 | 94 | $myVal = [ 95 | "foo" => "hello", 96 | "bar" => "world", 97 | ]; 98 | 99 | $schema->validate($myVal); 100 | 101 | $this->assertEquals($myVal, $arrFromValidator); 102 | } 103 | 104 | /** 105 | * @return void 106 | */ 107 | public function testValidateDotNotationProperty(): void { 108 | 109 | $arrFromValidator = null; 110 | $fooFromValidator = null; 111 | $schema = Schema::parse([ 112 | "nested" => [ 113 | "type" => "object", 114 | "properties" => [ 115 | "foo" => [ 116 | "type" => "string", 117 | ], 118 | "bar" => [ 119 | "type" => "string", 120 | ], 121 | ] 122 | ] 123 | ])->addValidator("nested", function (array $arr) use (&$arrFromValidator) { 124 | $arrFromValidator = $arr; 125 | })->addValidator("nested.foo", function ($foo) use (&$fooFromValidator) { 126 | $fooFromValidator = $foo; 127 | }); 128 | 129 | 130 | $myVal = [ 131 | "foo" => "hello", 132 | "bar" => "world", 133 | ]; 134 | 135 | $schema->validate(["nested" => $myVal]); 136 | 137 | $this->assertEquals($myVal, $arrFromValidator); 138 | $this->assertEquals("hello", $fooFromValidator); 139 | } 140 | 141 | public function testValidateSparseLegacy() { 142 | $schema = Schema::parse([ 143 | "foo:s", 144 | "bar:s", 145 | ]); 146 | 147 | // Legacy of of specifying sparse. 148 | $result = $schema->validate(["foo" => "hello"], true); 149 | 150 | $this->assertEquals([ 151 | "foo" => "hello", 152 | ], $result); 153 | 154 | $this->assertTrue($schema->isValid(["foo" => "hello"], true)); 155 | } 156 | } -------------------------------------------------------------------------------- /tests/ValidationClassTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use PHPUnit\Framework\TestCase; 11 | use Garden\Schema\Validation; 12 | use Garden\Schema\Tests\Fixtures\TestValidation; 13 | 14 | /** 15 | * Test the {@link Validation}. 16 | */ 17 | class ValidationClassTest extends TestCase { 18 | /** 19 | * Adding an error to the validation object should make the object not valid. 20 | */ 21 | public function testAddErrorNotValid() { 22 | $vld = new Validation(); 23 | 24 | $vld->addError('foo', 'error'); 25 | $this->assertFalse($vld->isValid()); 26 | } 27 | 28 | /** 29 | * Test adding an error with a path. 30 | */ 31 | public function testAddErrorWithPath() { 32 | $vld = new Validation(); 33 | 34 | $vld->addError('foo/bar', 'error'); 35 | $error = $vld->getErrors()[0]; 36 | 37 | $this->assertEquals("foo/bar", $error['field']); 38 | $this->assertEquals("error", $error['error']); 39 | } 40 | 41 | /** 42 | * Errors with "{field}" codes should be replaced. 43 | */ 44 | public function testMessageReplacements() { 45 | $vld = new Validation(); 46 | $vld->addError('foo', 'The {field}!'); 47 | 48 | $this->assertSame('The foo!', $vld->getMessage()); 49 | } 50 | 51 | /** 52 | * The status should be the max status. 53 | */ 54 | public function testCalcCode() { 55 | $vld = new Validation(); 56 | 57 | $vld->addError('foo', 'err', ['code' => 302]) 58 | ->addError('bar', 'err', ['code' => 301]); 59 | 60 | $this->assertSame(302, $vld->getCode()); 61 | } 62 | 63 | /** 64 | * If there is no status and an error then the status should be 400. 65 | */ 66 | public function testDefaultStatus() { 67 | $vld = new Validation(); 68 | 69 | $vld->addError('foo', 'err'); 70 | 71 | $this->assertSame(400, $vld->getCode()); 72 | } 73 | 74 | /** 75 | * A valid object should have a 200 status. 76 | */ 77 | public function testValidStatus() { 78 | $vld = new Validation(); 79 | $this->assertSame(200, $vld->getCode()); 80 | } 81 | 82 | /** 83 | * The main status should override any sub-statuses. 84 | */ 85 | public function testMainStatusOverride() { 86 | $vld = new Validation(); 87 | 88 | $vld->addError('foo', 'bar') 89 | ->setMainCode(100); 90 | 91 | $this->assertSame(100, $vld->getCode()); 92 | } 93 | 94 | /** 95 | * Messages should be translated. 96 | */ 97 | public function testMessageTranslation() { 98 | $vld = new TestValidation(); 99 | $vld->setTranslateFieldNames(true); 100 | 101 | $vld->addError('it', 'Keeping {field} {number}', ['number' => 100]); 102 | 103 | $this->assertSame('!Keeping !it 100', $vld->getFullMessage()); 104 | } 105 | 106 | /** 107 | * Test message plural formatting. 108 | */ 109 | public function testPlural() { 110 | $vld = new TestValidation(); 111 | $vld->addError('', '{a,plural, apple} {b,plural,berry,berries} {b, plural, pear}.', ['a' => 1, 'b' => 2]); 112 | $this->assertSame('!apple berries pears.', $vld->getMessage()); 113 | } 114 | 115 | /** 116 | * Messages that start with "@" should not be translated. 117 | */ 118 | public function testNoTranslate() { 119 | $vld = new TestValidation(); 120 | $this->assertSame('foo', $vld->parentTranslate('@foo')); 121 | } 122 | 123 | /** 124 | * The error code cannot be empty. 125 | */ 126 | public function testEmptyError() { 127 | $this->expectException(\InvalidArgumentException::class); 128 | $vld = new Validation(); 129 | $vld->addError('foo', ''); 130 | } 131 | 132 | /** 133 | * The error count function should return the correct count when filtered by field. 134 | */ 135 | public function testErrorCountWithField() { 136 | $vld = new Validation(); 137 | $vld->addError('foo', 'foo'); 138 | $vld->addError('bar', 'foo'); 139 | 140 | $this->assertSame(1, $vld->getErrorCount('foo')); 141 | $this->assertSame(2, $vld->getErrorCount()); 142 | } 143 | 144 | /** 145 | * Null and empty strings have a different meaning in `Validation::getErrorCount()`. 146 | */ 147 | public function testErrorCountNullVsEmpty() { 148 | $vld = new Validation(); 149 | $vld->addError('', 'foo'); 150 | $vld->addError('foo', 'foo'); 151 | 152 | $this->assertSame(1, $vld->getErrorCount('')); 153 | $this->assertSame(2, $vld->getErrorCount()); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/AllOfTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\ArrayRefLookup; 11 | use Garden\Schema\ParseException; 12 | use Garden\Schema\Schema; 13 | use Garden\Schema\ValidationException; 14 | use PHPUnit\Framework\TestCase; 15 | 16 | /** 17 | * Tests allOf 18 | */ 19 | class AllOfTest extends TestCase { 20 | 21 | /** 22 | * @var ArrayRefLookup 23 | */ 24 | private $lookup; 25 | 26 | public function setUp(): void { 27 | parent::setUp(); 28 | 29 | $arr = [ 30 | 'components' => [ 31 | 'schemas' => [ 32 | 'Pet' => [ 33 | 'type' => 'object', 34 | 'properties' => [ 35 | 'name' => [ 36 | 'type' => 'string', 37 | ] 38 | ] 39 | ], 40 | 'Cat' => [ 41 | 'allOf' => [ 42 | ['$ref' => '#/components/schemas/Pet'], 43 | [ 44 | 'type' => 'object', 45 | 'properties' => [ 46 | 'likes' => [ 47 | 'type' => 'string', 48 | 'enum' => ['milk', 'purring'], 49 | ] 50 | ] 51 | ] 52 | ] 53 | ], 54 | 'Dog' => [ 55 | 'allOf' => [ 56 | ['$ref' => '#/components/schemas/Pet'], 57 | [ 58 | 'type' => 'object', 59 | 'properties' => [ 60 | 'isGoodBoy' => [ 61 | 'type' => 'boolean', 62 | 'default' => true, 63 | ] 64 | ] 65 | ] 66 | ] 67 | ], 68 | 'Puppy' => [ 69 | 'allOf' => [ 70 | ['$ref' => '#/components/schemas/Dog'], 71 | [ 72 | 'type' => 'object', 73 | 'properties' => [ 74 | 'canRun' => [ 75 | 'type' => 'boolean', 76 | 'default' => false, 77 | ] 78 | ] 79 | ] 80 | ] 81 | ], 82 | 'Invalid1' => [ 83 | 'allOf' => [ 84 | ['$ref' => '#/components/schemas/Dog'], 85 | 1, 86 | ] 87 | ], 88 | ] 89 | ] 90 | ]; 91 | 92 | $this->lookup = new ArrayRefLookup($arr); 93 | } 94 | 95 | /** 96 | * Test allof with two elements 97 | */ 98 | public function testResolveOneLevel() { 99 | $schema = new Schema(['$ref' => '#/components/schemas/Cat'], $this->lookup); 100 | $valid = $schema->validate(['name' => 'mauzi', 'likes' => 'milk']); 101 | $this->assertSame(['name' => 'mauzi', 'likes' => 'milk'], $valid); 102 | } 103 | 104 | /** 105 | * Test allof with two elements while one of those has another allof 106 | */ 107 | public function testResolveTwoLevel() { 108 | $schema = new Schema(['$ref' => '#/components/schemas/Puppy'], $this->lookup); 109 | $valid = $schema->validate(['name' => 'roger', 'isGoodBoy' => true, 'canRun' => false]); 110 | $this->assertSame(['name' => 'roger', 'isGoodBoy' => true, 'canRun' => false], $valid); 111 | } 112 | 113 | /** 114 | * Fail if an invalid value is provided 115 | */ 116 | public function testInvalidProperty() { 117 | $this->expectException(ValidationException::class); 118 | $this->expectExceptionMessageMatches("/Unexpected property: notExists/"); 119 | $schema = new Schema(['$ref' => '#/components/schemas/Puppy'], $this->lookup); 120 | $schema->setFlags(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION); 121 | $schema->validate(['name' => 'roger', 'isGoodBoy' => true, 'notExists' => true]); 122 | } 123 | 124 | /** 125 | * Allof members must be array 126 | */ 127 | public function testInvalidAllOf() { 128 | $this->expectException(ParseException::class); 129 | $schema = new Schema(['$ref' => '#/components/schemas/Invalid1'], $this->lookup); 130 | $valid = $schema->validate(['type' => 'Invalid1']); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Version 1 to Version 2 4 | 5 | With version 2, Garden Schema is moving away from JSON Schema and towards Open API. Since Open API is base on JSON Schema this isn't a major transition, but there are some differences. 6 | 7 | ### Bug Fixes 8 | 9 | - Properties with colons in them no longer throw an invalid type exception. 10 | - Fixed bug where nested schema objects were not getting their default values respected. 11 | 12 | ### New Features 13 | 14 | - The `nullable` schema property has been added to allow a value to also be null. 15 | - The `readOnly` and `writeOnly` properties are now supported. To use them in validation pass either `['request' => true]` or `['response' => true]` as options to one of the schema validation functions. 16 | - Support for the following validation properties has been added: `multipleOf`, `maximum`, `exclusiveMaximum`, `minimum`, `exclusiveMinimum`, `uniqueItems`, `maxProperties`, `minProperties`, `additionalProperties`. 17 | - Schemas now support references with the `$ref` attribute! To use references you can use `Schema::setRefLookup()` with the built in `ArrayRefLookup` class. 18 | - You can now create custom `Validation` instances by using a custom `Schema::setValidationFactory()` method. This is much more flexible than the deprecated `Schema::setValidationClass()` method. 19 | - Filters can now validate date too. Filters that validate completely override the default validation behaviour allowing a new level of control. You can add a validating filter using the `$validate` parameter with `Schema::addFilter()`. 20 | - You can add a global filter for all schemas with the same `format` property using `Schema::addFormatFilter()`. 21 | - You can now validate against a dynamic schema with the new `discriminator` property. 22 | - Partial support for the `oneOf` property has been added when used in conjunction with the `disriminator` property. 23 | 24 | ### Deprecations 25 | 26 | The following deprecations will throw deprecation notices, but still work in version 2, but will be removed in the next major version. 27 | 28 | - Schemas with multiple types are deprecated. The `nullable` property has been added to schemas to allow a type to also be null which should suit most purposes. 29 | 30 | - `Schema::validate()` and `Schema::isValid()` no longer take the `$sparse` parameter. Instead, pass an array with `['sparse' => true]` to do the same thing. Right now the boolean is still supported, but will be removed in future versions. 31 | 32 | - Specifying a type of `datetime` is deprecated. Replace it with a type of `string` and a format of `date-time`. This also introduces a backwards incompatibility. 33 | 34 | - Specifying a type of `timestamp` is deprecated. Replace it with a type of `integer` and a format of `timestamp`. This also introduces a backwards incompatibility. 35 | 36 | - Specifying schema paths separated with `.` is now deprecated and will trigger a deprecated error. Paths should now be separated with `/` in `Schema::addFilter()`, `Schema::addValidator()`, `Schema::getField()`, and `Schema::setField()`. 37 | 38 | - The `Schema::setValidationClass()` and `Schema::getValidationClass()` methods are deprecated. Use the new `Schema::setValidationFactory()` and `Schema::getValidationFactory()` instead. 39 | 40 | ### Backwards Incompatibility 41 | 42 | - Protected methods of the `Schema` class have changed signatures. 43 | 44 | - The `datetime` type has been removed and replaced with the standard `string` type and a `date-time` format. The format still returns `DateTimeInterface` instances though so having an explicit type of `string` with a `date-time` format now returns a different type. 45 | 46 | - The `timestamp` type has been removed and replaced with the standard `integer` type and a `timestamp` format. A short type of `ts` is still supported, but is now converted to the aforementioned type/format combination. 47 | 48 | - Paths in validation results are now separated by `/` instead of `.` to more closely follow the JSON ref spec. 49 | 50 | - When specifying paths with `Schema::addValidator()` and `Schema::addFilter()` you should separate paths with `/` instead of `.` and specify the full schema path you want to add your callback to. So for example: `foo.bar[]` would become `properties/foo/properties/bar/items`. Currently, the schema will attempt to fix some old format validators and trigger a deprecation error, but may not catch every edge case. 51 | 52 | - Built-in validation errors are now have a code of `400` rather than a mix of `422` and `400`. 53 | 54 | - The `Validation` class has been largely changed and is largely incompatible with the previous version. 55 | 56 | - The `Validation` class now has type hints. You will need to update subclasses to avoid exceptions. 57 | - The `Validation` class now handles JSON encoding of its result. 58 | - The `Validation` class now formats error messages differently where field errors are set as headings to their specific errors. 59 | 60 | - The `ValidationException` now has a different message format where field names are used as headings and specific errors don't repeat the field name every time. 61 | 62 | - The `ValidationException` now uses the `Validation` object to JSON encode and thus has a different format. 63 | -------------------------------------------------------------------------------- /tests/MultipleTypesTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | use Garden\Schema\Schema; 10 | use PHPUnit\Framework\Error\Notice; 11 | 12 | /** 13 | * Tests for schemas where the type property is multiple types. 14 | */ 15 | class MultipleTypesTest extends AbstractSchemaTest { 16 | /** 17 | * Provide test data for basic multiple type tests. 18 | * 19 | * @return array Returns a data provider. 20 | */ 21 | public function provideBasicMultipleTypeTests() { 22 | $r = [ 23 | ['integer array 1', 123], 24 | ['integer array 2', [1]], 25 | ['boolean string 1', true], 26 | ['boolean string 2', false], 27 | ['boolean string 3', 'true'], 28 | ['boolean string 4', ''], 29 | ['boolean integer 1', 1], 30 | ['boolean number 1', 1.234], 31 | ['boolean number 2', 1, 1.0], 32 | ['integer boolean 1', true], 33 | ['datetime string 1', 'today'], 34 | ['datetime string 2', '2010-01-01', new \DateTimeImmutable('2010-01-01')], 35 | ['integer number 1', 123], 36 | ['integer number 2', 123.4], 37 | ['number integer 1', 123], 38 | ['number integer 2', 123.4], 39 | ]; 40 | 41 | return array_column($r, null, 0); 42 | } 43 | 44 | /** 45 | * Basic multiple type tests. 46 | * 47 | * @param string $types A space delimited string of type names. 48 | * @param mixed $value A value to test. 49 | * @param mixed $expected The expected valid value. 50 | * @dataProvider provideBasicMultipleTypeTests 51 | */ 52 | public function testBasicMultipleTypes($types, $value, $expected = null) { 53 | $types = array_filter(explode(' ', $types), function ($v) { 54 | return !is_numeric($v); 55 | }); 56 | 57 | $sch = new Schema([ 58 | 'type' => $types 59 | ]); 60 | 61 | $expected = $expected === null ? $value : $expected; 62 | 63 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED, false); 64 | $valid = $sch->validate($value); 65 | if (is_array($expected) || $expected instanceof \DateTimeInterface) { 66 | $this->assertEquals($expected, $valid); 67 | } else { 68 | $this->assertSame($expected, $valid); 69 | } 70 | } 71 | 72 | /** 73 | * Test a type and an array of that type. 74 | * 75 | * @param string $short The short code which is not used. 76 | * @param string $type The type to test. 77 | * @param mixed $value A valid value for the type. 78 | * @dataProvider provideTypesAndData 79 | */ 80 | public function testTypeAndArrayOfType($short, $type, $value) { 81 | if ($type === 'array') { 82 | // Just return because this isn't really a valid test to skip. 83 | $this->assertTrue(true); // dummy assertion to avoid warning. 84 | return; 85 | } 86 | 87 | $sch = new Schema([ 88 | 'type' => [ 89 | $type, 90 | 'array' 91 | ], 92 | 'items' => [ 93 | 'type' => $type 94 | ] 95 | ]); 96 | 97 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED, false); 98 | $valid = $sch->validate($value); 99 | $this->assertSame($value, $valid); 100 | 101 | $arrayValue = [$value, $value, $value]; 102 | $arrayValid = $sch->validate($arrayValue); 103 | $this->assertSame($arrayValue, $arrayValid); 104 | } 105 | 106 | /** 107 | * Strings that are expanded with the **style** property should have some fidelity to an alternative type. 108 | * 109 | * This is to help supporting the "expand" parameter that Vanilla APIs can take where the value can be true or an array of field to expand. 110 | * 111 | * @param mixed $value The value to test. 112 | * @param mixed $expected The expected valid data. 113 | * @dataProvider provideExpandUserCaseTests 114 | */ 115 | public function testExpandUseCase($value, $expected) { 116 | $sch = new Schema([ 117 | 'type' => [ 118 | 'boolean', 119 | 'array' 120 | ], 121 | 'style' => 'form', 122 | 'items' => [ 123 | 'type' => 'string' 124 | ] 125 | ]); 126 | 127 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 128 | $valid = $sch->validate($value); 129 | $this->assertSame($expected, $valid); 130 | } 131 | 132 | /** 133 | * Provide tests for **testExpandUseCase()**. 134 | * 135 | * @return array Returns a data provider array. 136 | */ 137 | public function provideExpandUserCaseTests() { 138 | $r = [ 139 | ['true', true], 140 | ['1', true], 141 | ['false', false], 142 | ['0', false], 143 | ['a,b,c', ['a', 'b', 'c']] 144 | ]; 145 | 146 | return array_column($r, null, 0); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/BackwardsCompatibilityTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | use Garden\Schema\Tests\Fixtures\TestValidation; 12 | use Garden\Schema\ValidationException; 13 | 14 | 15 | /** 16 | * Test some backwards compatibility with deprecated behavior. 17 | */ 18 | class BackwardsCompatibilityTest extends AbstractSchemaTest { 19 | 20 | /** 21 | * Test field reference compat. 22 | * 23 | * @param string $old The old field selector. 24 | * @param string $new The expected new field selector. 25 | * @dataProvider provideFieldSelectorConversionTests 26 | */ 27 | public function testFieldSelectorConversion(string $old, string $new) { 28 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED, false); 29 | $this->assertFieldSelectorConversion($old, $new); 30 | } 31 | 32 | /** 33 | * Return field conversion tests. 34 | * 35 | * @return array Returns a data provider. 36 | */ 37 | public function provideFieldSelectorConversionTests() { 38 | $r = [ 39 | ['', ''], 40 | ['backgroundColor', 'properties/backgroundColor'], 41 | ['foo.bar', 'properties/foo/properties/bar'], 42 | ['foo[]', 'properties/foo/items'], 43 | ['[]', 'items'], 44 | ]; 45 | 46 | return array_column($r, null, 0); 47 | } 48 | 49 | /** 50 | * New field selectors should not trigger deprecation errors. 51 | * 52 | * @param string $field The field selector to test. 53 | * @dataProvider provideFieldSelectorNonConversionTests 54 | */ 55 | public function testFieldSelectorNonConversion(string $field) { 56 | $this->assertFieldSelectorConversion($field, $field); 57 | } 58 | 59 | /** 60 | * Return field non-conversion tests. 61 | * 62 | * None of these tests should throw deprecation errors. 63 | * 64 | * @return array Returns a data provider. 65 | */ 66 | public function provideFieldSelectorNonConversionTests() { 67 | $r = [ 68 | ['items'], 69 | ['additionalProperties'], 70 | ['properties/foo'] 71 | ]; 72 | 73 | return array_column($r, null, 0); 74 | } 75 | 76 | /** 77 | * Dot separators should still work with `Schemna::getField()`, but trigger a deprecated error. 78 | */ 79 | public function testGetFieldSeparators() { 80 | $sch = new Schema([ 81 | 'type' => 'object', 82 | 'properties' => [ 83 | 'foo' => [ 84 | 'type' => 'integer', 85 | ], 86 | ], 87 | ]); 88 | 89 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 90 | $type = $sch->getField('properties.foo.type'); 91 | $this->assertEquals('integer', $type); 92 | } 93 | 94 | /** 95 | * Dot separators should still work with `Schema::setField()`, but trigger a deprecated error. 96 | */ 97 | public function testSetFieldSeparators() { 98 | $sch = new Schema([ 99 | 'type' => 'object', 100 | 'properties' => [ 101 | 'foo' => [ 102 | 'type' => 'integer', 103 | ], 104 | ], 105 | ]); 106 | 107 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 108 | $sch->setField('properties.foo.type', 'string'); 109 | $type = $sch->getField('properties/foo/type'); 110 | $this->assertEquals('string', $type); 111 | } 112 | 113 | /** 114 | * Assert a field selector conversion. 115 | * 116 | * @param string $old The old field selector. 117 | * @param string $new The new field selector that is expected. 118 | */ 119 | public function assertFieldSelectorConversion(string $old, string $new) { 120 | $sch = new Schema(); 121 | $fn = function ($field) { 122 | return $this->parseFieldSelector($field); 123 | }; 124 | $fn = $fn->bindTo($sch, Schema::class); 125 | 126 | $actual = $fn($old); 127 | $this->assertEquals($new, $actual); 128 | } 129 | 130 | /** 131 | * Test a custom validation class. 132 | */ 133 | public function testDifferentValidationClass() { 134 | $schema = Schema::parse([':i']); 135 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 136 | $schema->setValidationClass(TestValidation::class); 137 | 138 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 139 | $this->assertSame(TestValidation::class, $schema->getValidationClass()); 140 | 141 | try { 142 | $schema->validate('aaa'); 143 | } catch (ValidationException $ex) { 144 | $this->assertSame('![value]: "aaa" is not a valid integer.', $ex->getMessage()); 145 | } 146 | 147 | $validation = new TestValidation(); 148 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 149 | $schema->setValidationClass($validation); 150 | try { 151 | $schema->validate('aaa'); 152 | } catch (ValidationException $ex) { 153 | $this->assertSame('![value]: "aaa" is not a valid integer.', $ex->getMessage()); 154 | } 155 | 156 | $validation->setTranslateFieldNames(true); 157 | try { 158 | $schema->validate('aaa'); 159 | } catch (ValidationException $ex) { 160 | $this->assertSame('!!value: !"aaa" is not a valid integer.', $ex->getMessage()); 161 | } 162 | } 163 | 164 | /** 165 | * Test validating the null type. 166 | */ 167 | public function testNullValidation() { 168 | $sch = new Schema(['type' => 'null']); 169 | 170 | $valid = $sch->validate(null); 171 | $this->assertNull($valid); 172 | 173 | $this->expectException(ValidationException::class); 174 | $valid = $sch->validate(123); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /tests/SchemaRefTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\ArrayRefLookup; 11 | use Garden\Schema\RefNotFoundException; 12 | use Garden\Schema\Schema; 13 | use Garden\Schema\Validation; 14 | use Garden\Schema\ValidationException; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | class SchemaRefTest extends TestCase { 18 | /** 19 | * @var array A big array with multiple schemas and references. 20 | */ 21 | private $sch; 22 | 23 | /** 24 | * @var ArrayRefLookup 25 | */ 26 | private $lookup; 27 | 28 | /** 29 | * Create test data with every test. 30 | */ 31 | public function setUp(): void { 32 | parent::setUp(); 33 | 34 | $this->sch = [ 35 | 'ref' => [ 36 | '$ref' => '#/schemas/value', 37 | ], 38 | 'ref-ref' => [ 39 | '$ref' => '#/schemas/tuple', 40 | ], 41 | 'items' => [ 42 | 'type' => 'array', 43 | 'items' => [ 44 | '$ref' => '#/schemas/value', 45 | ], 46 | ], 47 | 'additionalProperties' => [ 48 | 'type' => 'object', 49 | 'additionalProperties' => [ 50 | '$ref' => '#/schemas/value', 51 | ], 52 | ], 53 | 'cycle' => [ 54 | '$ref' => '#/schemas/cycle', 55 | ], 56 | 'category' => [ 57 | 'type' => 'object', 58 | 'properties' => [ 59 | 'name' => [ 60 | 'type' => 'string', 61 | ], 62 | 'children' => [ 63 | 'type' => 'array', 64 | 'items' => [ 65 | '$ref' => '#/category', 66 | ], 67 | ], 68 | ], 69 | 'required' => ['name'], 70 | ], 71 | 'nowhere' => [ 72 | '$ref' => '#/oehroieqhwr', 73 | ], 74 | 'local-ref' => [ 75 | '$ref' => '#foo', 76 | ], 77 | 'schemas' => [ 78 | 'tuple' => [ 79 | 'type' => 'object', 80 | 'properties' => [ 81 | 'key' => [ 82 | 'type' => 'string', 83 | ], 84 | 'value' => [ 85 | '$ref' => '#/schemas/value' 86 | ], 87 | ], 88 | 'required' => ['key', 'value'], 89 | ], 90 | 'value' => [ 91 | 'type' => 'integer', 92 | ], 93 | 'cycle' => [ 94 | '$ref' => '#/cycle', 95 | ], 96 | ], 97 | ]; 98 | 99 | $this->lookup = new ArrayRefLookup($this->sch); 100 | } 101 | 102 | /** 103 | * Test a schema that is just a reference to another schema. 104 | */ 105 | public function testRootRef() { 106 | $sch = $this->schema('ref'); 107 | 108 | $valid = $sch->validate('123'); 109 | $this->assertSame(123, $valid); 110 | } 111 | 112 | /** 113 | * Test a schema ref that itself includes another ref. 114 | */ 115 | public function testRefRef() { 116 | $sch = $this->schema('ref-ref'); 117 | 118 | $valid = $sch->validate(['key' => 123, 'value' => '123']); 119 | $this->assertSame(['key' => '123', 'value' => 123], $valid); 120 | } 121 | 122 | /** 123 | * Test a schema with array items that are a ref. 124 | */ 125 | public function testItemsRef() { 126 | $sch = $this->schema('items'); 127 | 128 | $valid = $sch->validate(['1', '2', 3.0]); 129 | $this->assertSame([1, 2, 3], $valid); 130 | } 131 | 132 | /** 133 | * Objects should allow references in additional properties. 134 | */ 135 | public function testAdditionalPropertiesRef() { 136 | $sch = $this->schema('additionalProperties'); 137 | 138 | $valid = $sch->validate(['a' => '1', 'b' => 2.0]); 139 | $this->assertSame(['a' => 1, 'b' => 2], $valid); 140 | } 141 | 142 | /** 143 | * Cyclical references should result in an exception and not a critical error. 144 | */ 145 | public function testCyclicalRef() { 146 | $this->expectException(RefNotFoundException::class); 147 | $this->expectExceptionCode(508); 148 | $sch = $this->schema('cycle'); 149 | 150 | $valid = $sch->validate(123); 151 | } 152 | 153 | /** 154 | * Tree-like schemas should be able to recursively reference themselves. 155 | */ 156 | public function testRecursiveTree() { 157 | $sch = $this->schema('category'); 158 | 159 | $valid = $sch->validate(['name' => 1, 'children' => [['name' => 11], ['name' => 12]]]); 160 | $this->assertSame(['name' => '1', 'children' => [['name' => '11'], ['name' => '12']]], $valid); 161 | } 162 | 163 | /** 164 | * References that cannot be found should throw an exception when validating. 165 | */ 166 | public function testRefNotFound() { 167 | $this->expectException(RefNotFoundException::class); 168 | $this->expectExceptionCode(404); 169 | $sch = $this->schema('nowhere'); 170 | $valid = $sch->validate('foo'); 171 | } 172 | 173 | /** 174 | * Exceptions from reference lookups should be re-thrown. 175 | */ 176 | public function testRefException() { 177 | $this->expectException(RefNotFoundException::class); 178 | $this->expectExceptionCode(400); 179 | $sch = $this->schema('local-ref'); 180 | $valid = $sch->validate('foo'); 181 | } 182 | 183 | /** 184 | * Filters work when added to the location of a reference. 185 | */ 186 | public function testRefFilter() { 187 | $sch = $this->schema('ref')->addFilter('#/schemas/value', function ($v) { 188 | return $v * 10; 189 | }); 190 | 191 | $valid = $sch->validate(10); 192 | $this->assertSame(100, $valid); 193 | } 194 | 195 | /** 196 | * Validators work when added to the location of a reference. 197 | */ 198 | public function testRefValidator() { 199 | $this->expectException(ValidationException::class); 200 | $this->expectExceptionCode(400); 201 | $sch = $this->schema('ref')->addValidator('#/schemas/value', function ($v) { 202 | return $v < 10; 203 | }); 204 | 205 | $valid = $sch->validate(11); 206 | } 207 | 208 | /** 209 | * Create a test schema at a given key. 210 | * 211 | * @param string $key The key of the test array. 212 | * @return Schema Returns a new schema. 213 | */ 214 | protected function schema(string $key): Schema { 215 | $sch = new Schema($this->sch[$key]); 216 | $sch->setRefLookup($this->lookup); 217 | 218 | return $sch; 219 | } 220 | 221 | /** 222 | * By default references should not be found. 223 | */ 224 | public function testDefaultRefNotFound() { 225 | $this->expectException(RefNotFoundException::class); 226 | $sch = new Schema(['$ref' => '#/foo']); 227 | 228 | $valid = $sch->validate(123); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /tests/ValidationErrorMessageTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Tests\Fixtures\TestValidation; 11 | use Garden\Schema\Validation; 12 | 13 | /** 14 | * Tests for the `Validation` class' error message formatting. 15 | */ 16 | class ValidationErrorMessageTest extends AbstractSchemaTest { 17 | 18 | /** 19 | * An empty validation object should return no error message. 20 | */ 21 | public function testNoErrorMessage() { 22 | $vld = new TestValidation(); 23 | 24 | $this->assertEmpty($vld->getFullMessage()); 25 | } 26 | 27 | /** 28 | * A main error message should be returned even if there are no errors. 29 | */ 30 | public function testMainErrorMessage() { 31 | $vld = $this->createErrors('Foo'); 32 | 33 | $this->assertSame('!Foo', $vld->getFullMessage()); 34 | } 35 | 36 | /** 37 | * Create some test data. 38 | * 39 | * @param string $main The main error message. 40 | * @param int $fieldlessErrorCount The number of fieldless errors. 41 | * @param int $fieldErrorCount The number of field errors. 42 | * @param int $fieldCount The number of fields. 43 | * @return TestValidation Returns a test validation object populated with data. 44 | */ 45 | private function createErrors(string $main, int $fieldlessErrorCount = 0, int $fieldErrorCount = 0, int $fieldCount = 1): TestValidation { 46 | $vld = new TestValidation(); 47 | 48 | if ($main) { 49 | $vld->setMainMessage($main); 50 | } 51 | 52 | for ($i = 1; $i <= $fieldlessErrorCount; $i++) { 53 | $vld->addError('', "error $i"); 54 | } 55 | 56 | for ($field = 1; $field <= $fieldCount; $field++) { 57 | for ($i = 1; $i <= $fieldErrorCount; $i++) { 58 | $vld->addError("Field $field", "error $i"); 59 | } 60 | } 61 | 62 | return $vld; 63 | } 64 | 65 | /** 66 | * Test one error on the empty field. 67 | */ 68 | public function testOneFieldlessError() { 69 | $vld = $this->createErrors('', 1); 70 | 71 | $this->assertSame('!error 1', $vld->getFullMessage()); 72 | } 73 | 74 | /** 75 | * Test two errors on an empty field. 76 | */ 77 | public function testTwoFieldlessErrors() { 78 | $vld = $this->createErrors('', 2); 79 | 80 | $this->assertSame("!error 1 !error 2", $vld->getFullMessage()); 81 | } 82 | 83 | /** 84 | * Test one error on a field. 85 | */ 86 | public function testOneFieldError() { 87 | $vld = $this->createErrors('', 0, 1); 88 | $this->assertSame('!error 1', $vld->getFullMessage()); 89 | } 90 | 91 | /** 92 | * Test two errors on a field. 93 | */ 94 | public function testTwoFieldErrors() { 95 | $vld = $this->createErrors('', 0, 2); 96 | $this->assertSame("[Field 1]: !error 1 !error 2", $vld->getFullMessage()); 97 | } 98 | 99 | /** 100 | * The main error should go above field errors. 101 | */ 102 | public function testMainAndFieldError() { 103 | $vld = $this->createErrors('Failed', 0, 1); 104 | 105 | $this->assertSame("!Failed !error 1", $vld->getFullMessage()); 106 | } 107 | 108 | /** 109 | * Fieldless errors should go above other errors. 110 | */ 111 | public function testFieldlessAboveFields() { 112 | $vld = new TestValidation(); 113 | 114 | $vld->addError('foo', 'bar'); 115 | $vld->addError('', 'baz'); 116 | 117 | $this->assertSame("!baz !bar", $vld->getFullMessage()); 118 | } 119 | 120 | /** 121 | * Validation's JSON should show a default success message. 122 | */ 123 | public function testNoErrorJSON() { 124 | $vld = new TestValidation(); 125 | $json = $vld->jsonSerialize(); 126 | $this->assertSame(['message' => '!Validation succeeded.', 'code' => 200, 'errors' => []], $json); 127 | } 128 | 129 | /** 130 | * The main validation message should come through in JSON. 131 | */ 132 | public function testMainMessageJSON() { 133 | $vld = $this->createErrors('Foo'); 134 | $json = $vld->jsonSerialize(); 135 | $this->assertSame(['message' => '!Foo', 'code' => 200, 'errors' => []], $json); 136 | } 137 | 138 | /** 139 | * Test one fieldless error. 140 | */ 141 | public function testOneFieldlessErrorJSON() { 142 | $vld = $this->createErrors('', 1); 143 | $json = $vld->jsonSerialize(); 144 | $this->assertEquals( 145 | [ 146 | 'message' => '!Validation failed.', 147 | 'code' => 400, 148 | 'errors' => [ 149 | ['error' => 'error 1', 'message' => '!error 1', 'status' => 400, 'field' => '', 'code' => 'error 1'] 150 | ] 151 | ], $json); 152 | } 153 | 154 | /** 155 | * Test a more complex error in JSON. 156 | */ 157 | public function testComplexErrorJSON() { 158 | $vld = $this->createErrors('Foo', 0, 2, 2); 159 | $vld->addError('Field X', 'error 1', ['code' => 433, 'messageCode' => 'foo']); 160 | $json = $vld->jsonSerialize(); 161 | $this->assertEquals(['message' => '!Foo', 'code' => 433, 'errors' => [ 162 | ['error' => 'error 1', 'message' => '!error 1', 'status' => 400, 'field' => 'Field 1', 'code' => 'error 1'], 163 | ['error' => 'error 2', 'message' => '!error 2', 'status' => 400, 'field' => 'Field 1', 'code' => 'error 2'], 164 | ['error' => 'error 1', 'message' => '!error 1', 'status' => 400, 'field' => 'Field 2', 'code' => 'error 1'], 165 | ['error' => 'error 2', 'message' => '!error 2', 'status' => 400, 'field' => 'Field 2', 'code' => 'error 2'], 166 | ['error' => 'error 1', 'code' => 433, 'message' => '!foo', 'status' => 400, 'field' => 'Field X'], 167 | ]], $json); 168 | } 169 | 170 | /** 171 | * Concatenated messages may add punctuation to error messages without it. 172 | * 173 | * @param string $error The error. 174 | * @param string $expected The expected concat message. 175 | * @dataProvider provideConcatPunctuationTests 176 | */ 177 | public function testConcatPunctuation(string $error, string $expected) { 178 | $vld = new Validation(); 179 | 180 | $vld->addError('', $error); 181 | $this->assertSame($expected, $vld->getConcatMessage('')); 182 | } 183 | 184 | /** 185 | * The JSON data from validation should validate against the **ValidationError** schema. 186 | * 187 | * @param Validation $vld The validation data to check. 188 | * @dataProvider provideValidationData 189 | */ 190 | public function testValidationJSONAgainstSchema(Validation $vld) { 191 | $sch = $this->loadOpenApiSchema('#/components/schemas/ValidationError'); 192 | $json = $vld->jsonSerialize(); 193 | 194 | $valid = $sch->validate($json); 195 | $this->assertSortedArrays($json, $valid); 196 | } 197 | 198 | /** 199 | * Provide some dummy validation data. 200 | * 201 | * @return array Returns a data provider array. 202 | */ 203 | public function provideValidationData(): array { 204 | $r = [ 205 | 'success' => [new Validation()], 206 | 'main message' => [$this->createErrors('Error')], 207 | 'one fieldless' => [$this->createErrors('', 1)], 208 | 'two fieldless' => [$this->createErrors('', 2)], 209 | 'fields' => [$this->createErrors('', 0, 2)], 210 | 'all' => [$this->createErrors('Error', 2, 2, 2)], 211 | ]; 212 | 213 | return $r; 214 | } 215 | 216 | /** 217 | * Provide some auto punctuation tests. 218 | * 219 | * @return array Returns a data provider. 220 | */ 221 | public function provideConcatPunctuationTests(): array { 222 | $r = [ 223 | ['foo', 'foo.'], 224 | ['foo.', 'foo.'], 225 | ['foo?', 'foo?'], 226 | ['foo!', 'foo!'], 227 | ]; 228 | 229 | return array_column($r, null, 0); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/ValidationField.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema; 9 | 10 | /** 11 | * A parameters class for field validation. 12 | * 13 | * This is an internal class and may change in the future. 14 | */ 15 | class ValidationField { 16 | /** 17 | * @var array|Schema 18 | */ 19 | private $field; 20 | 21 | /** 22 | * @var Validation 23 | */ 24 | private $validation; 25 | 26 | /** 27 | * @var string 28 | */ 29 | private $name; 30 | 31 | /** 32 | * @var array 33 | */ 34 | private $options; 35 | 36 | /** 37 | * @var string 38 | */ 39 | private $schemaPath; 40 | 41 | /** 42 | * Construct a new {@link ValidationField} object. 43 | * 44 | * @param Validation $validation The validation object that contains errors. 45 | * @param array|Schema $field The field definition. 46 | * @param string $name The path to the field. 47 | * @param string $schemaPath The path to the field within the parent schema. 48 | * 49 | * - **sparse**: Whether or not this is a sparse validation. 50 | * @param array $options Validation options. 51 | */ 52 | public function __construct(Validation $validation, $field, string $name, string $schemaPath, array $options = []) { 53 | $this->field = $field; 54 | $this->validation = $validation; 55 | $this->name = $name; 56 | $this->schemaPath = $schemaPath; 57 | $this->options = $options + ['sparse' => false]; 58 | } 59 | 60 | /** 61 | * Add a validation error. 62 | * 63 | * @param string $error The message code. 64 | * @param array $options An array of additional information to add to the error entry or a numeric error code. 65 | * @return $this 66 | * @see Validation::addError() 67 | */ 68 | public function addError(string $error, array $options = []) { 69 | $this->validation->addError($this->getName(), $error, $options); 70 | return $this; 71 | } 72 | 73 | /** 74 | * Add an invalid type error. 75 | * 76 | * @param mixed $value The erroneous value. 77 | * @param string $type The type that was checked. 78 | * @return $this 79 | */ 80 | public function addTypeError($value, $type = '') { 81 | $type = $type ?: $this->getType(); 82 | 83 | $this->validation->addError( 84 | $this->getName(), 85 | 'type', 86 | [ 87 | 'type' => $type, 88 | 'value' => is_scalar($value) ? $value : null, 89 | 'messageCode' => is_scalar($value) ? "{field}: {value} is not a valid $type." : "{field}: The value is not a valid $type." 90 | ] 91 | ); 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * Check whether or not this field is has errors. 98 | * 99 | * @return bool Returns true if the field has no errors, false otherwise. 100 | */ 101 | public function isValid() { 102 | return $this->getValidation()->isValidField($this->getName()); 103 | } 104 | 105 | /** 106 | * Merge a validation object to this one. 107 | * 108 | * @param Validation $validation The validation object to merge. 109 | * @return $this 110 | */ 111 | public function merge(Validation $validation) { 112 | $this->getValidation()->merge($validation, $this->getName()); 113 | return $this; 114 | } 115 | 116 | /** 117 | * Get the field. 118 | * 119 | * @return array|Schema Returns the field. 120 | */ 121 | public function getField() { 122 | return $this->field; 123 | } 124 | 125 | /** 126 | * Set the field. 127 | * 128 | * This method is only meant to be called from within the schema class. 129 | * 130 | * @param array|Schema $field The new field. 131 | * @return $this 132 | */ 133 | public function setField($field) { 134 | $this->field = $field; 135 | return $this; 136 | } 137 | 138 | /** 139 | * Get the validation. 140 | * 141 | * @return Validation Returns the validation. 142 | */ 143 | public function getValidation() { 144 | return $this->validation; 145 | } 146 | 147 | /** 148 | * Get the name. 149 | * 150 | * @return string Returns the name. 151 | */ 152 | public function getName() { 153 | return $this->name; 154 | } 155 | 156 | /** 157 | * Set the name. 158 | * 159 | * This method is only meant to be called from within the schema class. 160 | * 161 | * @param string $name The new name. 162 | * @return $this 163 | */ 164 | public function setName($name) { 165 | $this->name = ltrim($name, '/'); 166 | return $this; 167 | } 168 | 169 | /** 170 | * Check if allOf is available 171 | * 172 | * @return bool 173 | */ 174 | public function hasAllOf() { 175 | return isset($this->field['allOf']); 176 | } 177 | 178 | /** 179 | * Get allof tree 180 | * 181 | * @return array 182 | */ 183 | public function getAllOf() { 184 | return $this->field['allOf'] ?? []; 185 | } 186 | 187 | /** 188 | * Get the field type. 189 | * 190 | * @return string|string[] Returns a type string, array of type strings, or null if there isn't one. 191 | */ 192 | public function getType() { 193 | return $this->field['type'] ?? ''; 194 | } 195 | 196 | /** 197 | * Whether or not the field has a given type. 198 | * 199 | * @param string $type The single type to test. 200 | * @return bool Returns **true** if the field has the given type or **false** otherwise. 201 | */ 202 | public function hasType($type) { 203 | return in_array($type, (array)$this->getType()); 204 | } 205 | 206 | /** 207 | * Get a value fom the field. 208 | * 209 | * @param string $key The key to look at. 210 | * @param mixed $default The default to return if the key isn't found. 211 | * @return mixed Returns a value or the default. 212 | */ 213 | public function val($key, $default = null) { 214 | return $this->field[$key] ?? $default; 215 | } 216 | 217 | /** 218 | * Whether or not the field has a value. 219 | * 220 | * @param string $key The key to look at. 221 | * @return bool Returns **true** if the field has a key or **false** otherwise. 222 | */ 223 | public function hasVal($key) { 224 | return isset($this->field[$key]) || (is_array($this->field) && array_key_exists($key, $this->field)); 225 | } 226 | 227 | /** 228 | * Get the error count for this field. 229 | */ 230 | public function getErrorCount() { 231 | return $this->getValidation()->getErrorCount($this->getName()); 232 | } 233 | 234 | /** 235 | * Whether or not we are validating a request. 236 | * 237 | * @return bool Returns **true** of we are validating a request or **false** otherwise. 238 | */ 239 | public function isRequest(): bool { 240 | return $this->options['request'] ?? false; 241 | } 242 | 243 | /** 244 | * Whether or not we are validating a response. 245 | * 246 | * @return bool Returns **true** of we are validating a response or **false** otherwise. 247 | */ 248 | public function isResponse(): bool { 249 | return $this->options['response'] ?? false; 250 | } 251 | 252 | /** 253 | * Whether or not this is a sparse validation.. 254 | * 255 | * @return bool Returns **true** if this is a sparse validation or **false** otherwise. 256 | */ 257 | public function isSparse() { 258 | return $this->getOption('sparse', false); 259 | } 260 | 261 | /** 262 | * Gets the options array. 263 | * 264 | * @return array Returns an options array. 265 | */ 266 | public function getOptions(): array { 267 | return $this->options; 268 | } 269 | 270 | /** 271 | * Get an indivitual option. 272 | * 273 | * @param string $option The name of the option. 274 | * @param mixed $default The default value to return if the option doesn't exist. 275 | * @return mixed Returns the option or the default value. 276 | */ 277 | public function getOption(string $option, $default = null) { 278 | return $this->options[$option] ?? $default; 279 | } 280 | 281 | /** 282 | * Get the schemaPath. 283 | * 284 | * @return string Returns the schemaPath. 285 | */ 286 | public function getSchemaPath(): string { 287 | return $this->schemaPath; 288 | } 289 | 290 | /** 291 | * Set the schemaPath. 292 | * 293 | * @param string $schemaPath 294 | * @return $this 295 | */ 296 | public function setSchemaPath(string $schemaPath) { 297 | $this->schemaPath = ltrim($schemaPath, '/'); 298 | return $this; 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /tests/OperationsTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | 12 | /** 13 | * Tests for schema modification operations. 14 | */ 15 | class OperationsTest extends AbstractSchemaTest { 16 | 17 | /** 18 | * Test the schema title property. 19 | */ 20 | public function testTitle() { 21 | $sch = new Schema([]); 22 | 23 | $this->assertEmpty($sch->getTitle()); 24 | $sch->setTitle('foo'); 25 | $this->assertSame('foo', $sch->getTitle()); 26 | } 27 | 28 | 29 | /** 30 | * Test merging basic schemas. 31 | */ 32 | public function testBasicMerge() { 33 | $schemaOne = Schema::parse(['foo:s?']); 34 | $schemaTwo = Schema::parse(['bar:s']); 35 | 36 | $schemaOne->merge($schemaTwo); 37 | 38 | $expected = [ 39 | 'type' => 'object', 40 | 'properties' => [ 41 | 'foo' => ['type' => 'string'], 42 | 'bar' => ['type' => 'string', 'minLength' => 1] 43 | ], 44 | 'required' => ['bar'] 45 | ]; 46 | 47 | $this->assertEquals($expected, $schemaOne->getSchemaArray()); 48 | } 49 | 50 | /** 51 | * Test merging nested schemas. 52 | */ 53 | public function testNestedMerge() { 54 | $schemaOne = $this->getArrayOfObjectsSchema(); 55 | $schemaTwo = Schema::parse([ 56 | 'rows:a' => [ 57 | 'email:s' 58 | ] 59 | ]); 60 | 61 | $expected = [ 62 | 'type' => 'object', 63 | 'properties' => [ 64 | 'rows' => [ 65 | 'type' => 'array', 66 | 'items' => [ 67 | 'type' => 'object', 68 | 'properties' => [ 69 | 'id' => ['type' => 'integer'], 70 | 'name' => ['type' => 'string'], 71 | 'email' => ['type' => 'string', 'minLength' => 1] 72 | ], 73 | 'required' => ['id', 'email'] 74 | ] 75 | ] 76 | ], 77 | 'required' => ['rows'] 78 | ]; 79 | 80 | $schemaOne->merge($schemaTwo); 81 | 82 | $this->assertEquals($expected, $schemaOne->jsonSerialize()); 83 | } 84 | 85 | /** 86 | * Test schema adding. 87 | */ 88 | public function testAdd() { 89 | $sc1 = Schema::parse(['a:o?' => ['b?', 'c']]); 90 | $sc2 = Schema::parse(['a:o' => ['b:i', 'e'], 'd']); 91 | 92 | $expected = [ 93 | 'type' => 'object', 94 | 'properties' => [ 95 | 'a' => [ 96 | 'type' => 'object', 97 | 'properties' => [ 98 | 'b' => ['type' => 'integer'], 99 | 'c' => [] 100 | ], 101 | 'required' => ['c'] 102 | ] 103 | ] 104 | ]; 105 | 106 | $actual = $sc1->add($sc2)->getSchemaArray(); 107 | 108 | $this->assertEquals($expected, $actual); 109 | } 110 | 111 | /** 112 | * Test adding with adding properties. 113 | */ 114 | public function testAddWithProperties() { 115 | $sc1 = Schema::parse(['a:s?', 'b?', 'c?']); 116 | $sc2 = Schema::parse(['a:i', 'b:s?', 'c?' => 'Description', 'd']); 117 | 118 | $expected = [ 119 | 'type' => 'object', 120 | 'properties' => [ 121 | 'a' => ['type' => 'string'], 122 | 'b' => ['type' => 'string'], 123 | 'c' => ['description' => 'Description'], 124 | 'd' => [] 125 | ], 126 | 'required' => ['d'] 127 | ]; 128 | 129 | $actual = $sc1->add($sc2, true)->getSchemaArray(); 130 | 131 | $this->assertEquals($expected, $actual); 132 | } 133 | 134 | /** 135 | * Test adding with an enum. 136 | */ 137 | public function testAddEnum() { 138 | $sch1 = Schema::parse([':s' => ['enum' => ['a', 'b']]]); 139 | $sch2 = Schema::parse([':s' => ['enum' => ['a', 'c']]]); 140 | 141 | $expected = [ 142 | 'type' => 'string', 143 | 'enum' => ['a', 'b'] 144 | ]; 145 | 146 | $actual = $sch1->add($sch2)->getSchemaArray(); 147 | $this->assertEquals($expected, $actual); 148 | 149 | $actual2 = $sch1->add($sch2, true)->getSchemaArray(); 150 | $this->assertEquals($expected, $actual2); 151 | } 152 | 153 | /** 154 | * Test merging with an enum. 155 | */ 156 | public function testMergeEnum() { 157 | $sch1 = Schema::parse([':s' => ['enum' => ['a', 'b']]]); 158 | $sch2 = Schema::parse([':s' => ['enum' => ['a', 'c']]]); 159 | 160 | $expected = [ 161 | 'type' => 'string', 162 | 'enum' => ['a', 'b', 'c'] 163 | ]; 164 | 165 | $actual = $sch1->merge($sch2)->getSchemaArray(); 166 | $this->assertEquals($expected, $actual); 167 | } 168 | 169 | /** 170 | * Test turning a schema into a sparse schema. 171 | */ 172 | public function testWithSparse() { 173 | $sch1 = Schema::parse([ 174 | 'a:o' => ['b:s', 'c:i'], 175 | 'b:a' => ['b:s', 'c:i'] 176 | ]); 177 | $sch2 = $sch1->withSparse(); 178 | 179 | $data = []; 180 | $result = $sch2->validate($data); 181 | $this->assertSame($data, $result); 182 | 183 | $data2 = ['a' => ['c' => 1], 'b' => [['b' => 'foo']]]; 184 | $result2 = $sch2->validate($data2); 185 | $this->assertSame($data2, $result2); 186 | } 187 | 188 | /** 189 | * Test making sparse schemas with duplicate schemas. 190 | */ 191 | public function testWithSparseSchemaReuse() { 192 | $sch1 = Schema::parse(['id:i', 'name:s']); 193 | $sch2 = Schema::parse([ 194 | 'u1' => $sch1, 195 | 'u2' => $sch1 196 | ]); 197 | 198 | $sch3 = $sch2->withSparse(); 199 | 200 | $this->assertSame($sch3->getField('properties/u1'), $sch3->getField('properties/u2')); 201 | 202 | $data = [ 203 | 'u1' => ['id' => 1], 204 | 'u2' => ['name' => 'Frank'] 205 | ]; 206 | 207 | $valid = $sch3->validate($data); 208 | $this->assertEquals($data, $valid); 209 | } 210 | 211 | /** 212 | * Test a self referencing schema's JSON serialization. 213 | */ 214 | public function testRecursiveJsonSerialize() { 215 | $sch = new Schema([ 216 | 'type' => 'object', 217 | 'properties' => [ 218 | 'id' => ['type' => 'int'], 219 | ] 220 | ]); 221 | $sch->setField('properties/child', $sch); 222 | 223 | $data = $sch->jsonSerialize(); 224 | $this->assertEquals([ 225 | 'type' => 'object', 226 | 'properties' => [ 227 | 'id' => ['type' => 'int'], 228 | 'child' => [ 229 | '$ref' => '#/components/schemas/$no-id' 230 | ] 231 | ], 232 | ], $data); 233 | } 234 | 235 | /** 236 | * Test a self referencing schema's JSON serialization. 237 | */ 238 | public function testRecursiveJsonSerializeID() { 239 | $sch = new Schema([ 240 | 'type' => 'object', 241 | 'properties' => [ 242 | 'id' => ['type' => 'int'], 243 | ], 244 | 'id' => 'Test' 245 | ]); 246 | $sch->setField('properties/child', $sch); 247 | 248 | $data = $sch->jsonSerialize(); 249 | $this->assertEquals([ 250 | 'type' => 'object', 251 | 'properties' => [ 252 | 'id' => ['type' => 'int'], 253 | 'child' => [ 254 | '$ref' => '#/components/schemas/Test' 255 | ] 256 | ], 257 | 'id' => 'Test', 258 | ], $data); 259 | } 260 | 261 | /** 262 | * Test a recursive loop during JSON serialization. 263 | */ 264 | public function testRecursiveLoopJsonSerialize() { 265 | $sch1 = new Schema([ 266 | 'type' => 'object', 267 | 'id' => 'Test1', 268 | 'properties' => [ 269 | 'id' => ['type' => 'int'], 270 | 'child' => $sch2 = new Schema([ 271 | 'type' => 'array', 272 | 'id' => 'Test2', 273 | ]) 274 | ] 275 | ]); 276 | $sch2->setField('items', $sch1); 277 | 278 | $data = $sch1->jsonSerialize(); 279 | $this->assertEquals([ 280 | 'type' => 'object', 281 | 'id' => 'Test1', 282 | 'properties' => [ 283 | 'id' => ['type' => 'int'], 284 | 'child' => [ 285 | 'type' => 'array', 286 | 'id' => 'Test2', 287 | 'items' => ['$ref' => '#/components/schemas/Test1'], 288 | ], 289 | ], 290 | ], $data); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /tests/DiscriminatorTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\ArrayRefLookup; 11 | use Garden\Schema\ParseException; 12 | use Garden\Schema\Schema; 13 | use Garden\Schema\ValidationException; 14 | use PHPUnit\Framework\TestCase; 15 | 16 | /** 17 | * Tests the discriminator property. 18 | */ 19 | class DiscriminatorTest extends TestCase { 20 | 21 | /** 22 | * @var Schema 23 | */ 24 | private $schema; 25 | 26 | public function setUp(): void { 27 | parent::setUp(); 28 | 29 | $arr = [ 30 | 'components' => [ 31 | 'schemas' => [ 32 | 'Pet' => [ 33 | 'discriminator' => [ 34 | 'propertyName' => 'type', 35 | 'mapping' => [ 36 | 'Fido' => 'Dog', 37 | 'Mittens' => '#/components/schemas/Cat', 38 | ] 39 | ] 40 | ], 41 | 'Cat' => [ 42 | 'type' => 'object', 43 | 'properties' => [ 44 | 'type' => [ 45 | 'type' => 'string', 46 | ], 47 | 'likes' => [ 48 | 'type' => 'string', 49 | 'enum' => ['milk', 'purring'], 50 | ] 51 | ] 52 | ], 53 | 'Dog' => [ 54 | 'type' => 'object', 55 | 'properties' => [ 56 | 'type' => [ 57 | 'type' => 'string', 58 | ], 59 | 'isGoodBoy' => [ 60 | 'type' => 'boolean', 61 | 'default' => true, 62 | ] 63 | ] 64 | ], 65 | 'Bird' => [ 66 | 'oneOf' => [ 67 | [ 68 | '$ref' => '#/components/schemas/Penguin', 69 | ], 70 | [ 71 | '$ref' => '#/components/schemas/Parrot', 72 | ], 73 | [ 74 | '$ref' => '#/components/schemas/Pet', 75 | ], 76 | ], 77 | 'discriminator' => [ 78 | 'propertyName' => 'subtype', 79 | ] 80 | ], 81 | 'Penguin' => [ 82 | 'type' => 'object', 83 | 'properties' => [ 84 | 'type' => [ 85 | 'type' => 'string', 86 | ], 87 | 'subtype' => [ 88 | 'type' => 'string', 89 | ], 90 | 'movement' => [ 91 | 'type' => 'string', 92 | 'default' => 'swims', 93 | ], 94 | ], 95 | ], 96 | 'Parrot' => [ 97 | 'type' => 'object', 98 | 'properties' => [ 99 | 'type' => [ 100 | 'type' => 'string', 101 | ], 102 | 'subtype' => [ 103 | 'type' => 'string', 104 | ], 105 | 'movement' => [ 106 | 'type' => 'string', 107 | 'default' => 'flies', 108 | ], 109 | ], 110 | ], 111 | 'Invalid1' => [ 112 | 'discriminator' => [ 113 | 'propertyName' => [1, 2, 3], 114 | 'mapping' => [ 115 | 'Fido' => 'Dog', 116 | 'Mittens' => '#/components/schemas/Cat', 117 | ] 118 | ] 119 | ], 120 | ] 121 | ] 122 | ]; 123 | 124 | $lookup = new ArrayRefLookup($arr); 125 | 126 | $this->schema = new Schema(['$ref' => '#/components/schemas/Pet'], $lookup); 127 | } 128 | 129 | /** 130 | * Test a discriminator that maps to a reference. 131 | */ 132 | public function testDiscriminatorRefMapping() { 133 | $valid = $this->schema->validate(['type' => 'Mittens', 'likes' => 'milk', 'isGoodBoy' => true]); 134 | 135 | $this->assertSame(['type' => 'Mittens', 'likes' => 'milk'], $valid); 136 | } 137 | 138 | /** 139 | * Test a discriminator that maps to an alias. 140 | */ 141 | public function testDiscriminatorAliasMapping() { 142 | $valid = $this->schema->validate(['type' => 'Fido']); 143 | 144 | $this->assertSame(['type' => 'Fido', 'isGoodBoy' => true], $valid); 145 | } 146 | 147 | /** 148 | * Test a discriminator default mapping. 149 | */ 150 | public function testDiscriminatorDefaultMapping() { 151 | $valid = $this->schema->validate(['type' => 'Cat', 'likes' => 'milk', 'isGoodBoy' => true]); 152 | 153 | $this->assertSame(['type' => 'Cat', 'likes' => 'milk'], $valid); 154 | } 155 | 156 | /** 157 | * A discriminator value that is not found should make validation fail. 158 | */ 159 | public function testDiscriminatorTypeNotFound() { 160 | $this->expectException(ValidationException::class); 161 | $this->expectExceptionMessage("\"Foo\" is not a valid option."); 162 | $valid = $this->schema->validate(['type' => 'Foo']); 163 | } 164 | 165 | /** 166 | * A discriminator should fail if it refers to its own schema, creating a circular reference. 167 | */ 168 | public function testDiscriminatorCyclicalRef() { 169 | $this->expectException(ValidationException::class); 170 | $this->expectExceptionMessage("\"Pet\" is not a valid option."); 171 | $valid = $this->schema->validate(['type' => 'Pet']); 172 | } 173 | 174 | /** 175 | * A schema found through a discriminator should itself be allowed to have a recursive discriminator. 176 | */ 177 | public function testDiscriminatorRecursion() { 178 | $valid = $this->schema->validate(['type' => 'Bird', 'subtype' => 'Penguin']); 179 | 180 | $this->assertSame(['type' => 'Bird', 'subtype' => 'Penguin', 'movement' => 'swims'], $valid); 181 | } 182 | 183 | /** 184 | * A discriminators should fail if they discriminate to themselves. 185 | */ 186 | public function testDiscriminatorInfiniteRecursion() { 187 | $this->expectException(ValidationException::class); 188 | $this->expectExceptionMessage("\"Pet\" is not a valid option."); 189 | $valid = $this->schema->validate(['type' => 'Bird', 'subtype' => 'Pet']); 190 | } 191 | 192 | /** 193 | * A discriminators should fail if they discriminate to themselves. 194 | */ 195 | public function testDiscriminatorTypeError() { 196 | $this->expectException(ValidationException::class); 197 | $this->expectExceptionMessage("The value is not a valid string."); 198 | $valid = $this->schema->validate(['type' => ['hey!!!']]); 199 | } 200 | 201 | /** 202 | * A discriminators should fail if the discriminate to themselves. 203 | */ 204 | public function testEmptyDiscriminator() { 205 | $this->expectException(ValidationException::class); 206 | $this->expectExceptionMessage("The value is invalid. type is required."); 207 | $valid = $this->schema->validate([]); 208 | } 209 | 210 | /** 211 | * A discriminators should fail if they discriminate to themselves. 212 | */ 213 | public function testDiscriminatorNonObject() { 214 | $this->expectException(ValidationException::class); 215 | $this->expectExceptionMessage("\"foo\" is not a valid object."); 216 | $valid = $this->schema->validate('foo'); 217 | } 218 | 219 | /** 220 | * The property name for a discriminator must be a string. 221 | */ 222 | public function testInvalidDiscriminator1() { 223 | $this->expectException(ParseException::class); 224 | $valid = $this->schema->validate(['type' => 'Invalid1']); 225 | } 226 | 227 | /** 228 | * A discriminator has to respect the `oneOf` validation. 229 | */ 230 | public function testOneOfRef() { 231 | $this->expectException(ValidationException::class); 232 | $this->expectExceptionMessage('"Dog" is not a valid option.'); 233 | $valid = $this->schema->validate(['type' => 'Bird', 'subtype' => 'Dog']); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /tests/ParseTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\ParseException; 11 | use Garden\Schema\Schema; 12 | use Garden\Schema\Tests\Fixtures\ExtendedSchema; 13 | 14 | /** 15 | * Test schema parsing. 16 | */ 17 | class ParseTest extends AbstractSchemaTest { 18 | /** 19 | * Test the basic atomic types in a schema. 20 | */ 21 | public function testAtomicTypes() { 22 | $schema = $this->getAtomicSchema(); 23 | 24 | $expected = [ 25 | 'type' => 'object', 26 | 'properties' => [ 27 | 'id' => ['type' => 'integer'], 28 | 'name' => ['type' => 'string', 'minLength' => 1, 'description' => 'The name of the object.'], 29 | 'description' => ['type' => 'string'], 30 | 'timestamp' => ['type' => 'integer', 'format' => 'timestamp'], 31 | 'date' => ['type' => 'string', 'format' => 'date-time'], 32 | 'amount' => ['type' => 'number'], 33 | 'enabled' => ['type' => 'boolean'], 34 | ], 35 | 'required' => ['id', 'name'] 36 | ]; 37 | 38 | $this->assertEquals($expected, $schema->getSchemaArray()); 39 | } 40 | 41 | /** 42 | * Test a basic nested object. 43 | */ 44 | public function testBasicNested() { 45 | $schema = Schema::parse([ 46 | 'obj:o' => [ 47 | 'id:i', 48 | 'name:s?' 49 | ] 50 | ]); 51 | 52 | $expected = [ 53 | 'type' => 'object', 54 | 'properties' => [ 55 | 'obj' => [ 56 | 'type' => 'object', 57 | 'properties' => [ 58 | 'id' => ['type' => 'integer'], 59 | 'name' => ['type' => 'string'] 60 | ], 61 | 'required' => ['id'] 62 | ] 63 | ], 64 | 'required' => ['obj'] 65 | ]; 66 | 67 | $actual = $schema->jsonSerialize(); 68 | $this->assertEquals($expected, $actual); 69 | } 70 | 71 | /** 72 | * Test to see if a nested schema can be used to create an identical nested schema. 73 | */ 74 | public function testNestedLongForm() { 75 | $schema = $this->getNestedSchema(); 76 | 77 | // Make sure the long form can be used to create the schema. 78 | $schema2 = new Schema($schema->jsonSerialize()); 79 | $this->assertEquals($schema->jsonSerialize(), $schema2->jsonSerialize()); 80 | } 81 | 82 | /** 83 | * Test a double nested schema. 84 | */ 85 | public function testDoubleNested() { 86 | $schema = Schema::parse([ 87 | 'obj:o' => [ 88 | 'obj:o?' => [ 89 | 'id:i' 90 | ] 91 | ] 92 | ]); 93 | 94 | $expected = [ 95 | 'type' => 'object', 96 | 'properties' => [ 97 | 'obj' => [ 98 | 'type' => 'object', 99 | 'properties' => [ 100 | 'obj' => [ 101 | 'type' => 'object', 102 | 'properties' => [ 103 | 'id' => [ 104 | 'type' => 'integer' 105 | ] 106 | ], 107 | 'required' => ['id'] 108 | ] 109 | ] 110 | ] 111 | ], 112 | 'required' => ['obj'] 113 | ]; 114 | 115 | $this->assertEquals($expected, $schema->jsonSerialize()); 116 | } 117 | 118 | /** 119 | * Test single root schemas. 120 | * 121 | * @param string $short The short type to test. 122 | * @param string $type The type to test. 123 | * @dataProvider provideNonNullTypesAndData 124 | */ 125 | public function testRootSchemas($short, $type) { 126 | $schema = Schema::parse([":$short" => 'desc']); 127 | 128 | $expected = ['type' => $type, 'description' => 'desc']; 129 | if ($type === 'datetime') { 130 | $expected['type'] = 'string'; 131 | $expected['format'] = 'date-time'; 132 | } elseif ($type === 'timestamp') { 133 | $expected['type'] = 'integer'; 134 | $expected['format'] = 'timestamp'; 135 | } 136 | 137 | $this->assertEquals($expected, $schema->getSchemaArray()); 138 | } 139 | 140 | /** 141 | * Test defining the root with a schema array. 142 | */ 143 | public function testDefineRoot() { 144 | $schema = Schema::parse([ 145 | ':a' => [ 146 | 'userID:i', 147 | 'name:s', 148 | 'email:s' 149 | ] 150 | ]); 151 | 152 | $expected = [ 153 | 'type' => 'array', 154 | 'items' => [ 155 | 'type' => 'object', 156 | 'properties' => [ 157 | 'userID' => ['type' => 'integer'], 158 | 'name' => ['type' => 'string', 'minLength' => 1], 159 | 'email' => ['type' => 'string', 'minLength' => 1] 160 | ], 161 | 'required' => ['userID', 'name', 'email'] 162 | ] 163 | ]; 164 | 165 | $this->assertEquals($expected, $schema->jsonSerialize()); 166 | } 167 | 168 | /** 169 | * Verify the current class is returned from parse calls in Schema subclasses. 170 | */ 171 | public function testSubclassing() { 172 | $subclass = ExtendedSchema::parse([]); 173 | $this->assertInstanceOf(ExtendedSchema::class, $subclass); 174 | } 175 | 176 | /** 177 | * Test the ability to pass constructor arguments using the parse method. 178 | */ 179 | public function testConstructorParameters() { 180 | $subclass = ExtendedSchema::parse([], 'DiscussionController', 'index', 'out'); 181 | $this->assertEquals('DiscussionController', $subclass->controller); 182 | $this->assertEquals('index', $subclass->method); 183 | $this->assertEquals('out', $subclass->type); 184 | } 185 | 186 | /** 187 | * Test JSON schema format to type conversion (and back). 188 | * 189 | * @param array $arr The schema array. 190 | * @param array $json A expected JSON schema array. 191 | * @dataProvider provideFormatToTypeConversionTests 192 | */ 193 | public function testTypeToFormatConversion($arr, $json) { 194 | $schema = new Schema($arr); 195 | $this->assertEquals($json, $schema->jsonSerialize()); 196 | } 197 | 198 | /** 199 | * Provide JSON schema formats that are schema types. 200 | * 201 | * @return array Returns a data provider array. 202 | */ 203 | public function provideFormatToTypeConversionTests() { 204 | $r = [ 205 | 'datetime' => [ 206 | ['type' => 'datetime'], 207 | ['type' => 'string', 'format' => 'date-time'], 208 | ], 209 | 'timestamp' => [ 210 | ['type' => 'timestamp'], 211 | ['type' => 'integer', 'format' => 'timestamp'], 212 | ], 213 | 'datetime|null' => [ 214 | ['type' => ['datetime', 'null']], 215 | ['type' => ['string', 'null'], 'format' => 'date-time'], 216 | ], 217 | ]; 218 | 219 | $result = $r; 220 | foreach ($r as $key => $value) { 221 | $result["nested $key"] = [ 222 | ['type' => 'object', 'properties' => ['prop' => $value[0]]], 223 | ['type' => 'object', 'properties' => ['prop' => $value[1]]], 224 | ]; 225 | } 226 | 227 | return $result; 228 | } 229 | 230 | /** 231 | * Test that field style. 232 | * 233 | * @param string $style The field style. 234 | * @param string $delimiter The array delimiter. 235 | * @dataProvider provideFieldStyles 236 | */ 237 | public function testFieldStyle($style, $delimiter) { 238 | $sch = Schema::parse(['' => [ 239 | 'type' => 'array', 240 | 'style' => $style, 241 | 'items' => [ 242 | 'type' => 'integer' 243 | ] 244 | ]]); 245 | 246 | $arr = [1, 2, 3]; 247 | 248 | $valid = $sch->validate(implode($delimiter, $arr)); 249 | $this->assertEquals($arr, $valid); 250 | } 251 | 252 | /** 253 | * Provide test data for {@link testFieldStyle()}. 254 | * 255 | * @return array Returns a data provider. 256 | */ 257 | public function provideFieldStyles() { 258 | $r = [ 259 | 'form' => ['form', ','], 260 | 'spaceDelimited' => ['spaceDelimited', ' '], 261 | 'pipeDelimited' => ['pipeDelimited', '|'] 262 | ]; 263 | 264 | return $r; 265 | } 266 | 267 | /** 268 | * Test validating a custom filter. 269 | */ 270 | public function testCustomFilter() { 271 | $sch = Schema::parse(['foo:s', 'bar:i']); 272 | $sch->addFilter('properties/foo', function ($v) { 273 | return $v.'!'; 274 | }); 275 | 276 | $valid = $sch->validate(['foo' => 'bar', 'bar' => 2]); 277 | 278 | $this->assertEquals(['foo' => 'bar!', 'bar' => 2], $valid); 279 | } 280 | 281 | /** 282 | * Unknown types should throw an exception when parsing. 283 | */ 284 | public function testInvalidTypeError() { 285 | $this->expectException(ParseException::class); 286 | $sch = Schema::parse(['foo:dd']); 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /tests/AbstractSchemaTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\ArrayRefLookup; 11 | use PHPUnit\Framework\Error\Error; 12 | use PHPUnit\Framework\Error\Notice; 13 | use PHPUnit\Framework\TestCase; 14 | use Garden\Schema\Schema; 15 | use Garden\Schema\Validation; 16 | use PHPUnit\Framework\Warning; 17 | 18 | /** 19 | * Base class for schema tests. 20 | */ 21 | abstract class AbstractSchemaTest extends TestCase { 22 | private $expectedErrors; 23 | 24 | /** 25 | * Clear out the errors array. 26 | */ 27 | protected function setUp(): void { 28 | $this->expectedErrors = []; 29 | set_error_handler([$this, "errorHandler"]); 30 | } 31 | 32 | /** 33 | * Track errors that occur during testing. 34 | * 35 | * The handler allows test methods to explicitly expect errors without failing. If an error is not expected then 36 | * it will be thrown as usual. 37 | * 38 | * @param int $number The number of the error. 39 | * @param string $message The error message. 40 | * @param string $file The file the error occurred in. 41 | * @param int $line The line the error occurred on. 42 | * @throws \Throwable Throws an exception when the error was not expected. 43 | */ 44 | public function errorHandler($number, $message, $file, $line) { 45 | // Look for an expected error. 46 | foreach ($this->expectedErrors as $i => $row) { 47 | list($no, $str, $unset) = $row; 48 | 49 | if (($number === $no || $no === null) && ($message === $str || empty($str))) { 50 | if ($unset) { 51 | unset($this->expectedErrors[$i]); 52 | } 53 | return; 54 | } 55 | } 56 | 57 | switch ($number) { 58 | case E_NOTICE: 59 | case E_USER_NOTICE: 60 | case E_DEPRECATED: 61 | case E_USER_DEPRECATED: 62 | throw new Notice($message, $number, $file, $line); 63 | case E_WARNING: 64 | case E_USER_WARNING: 65 | throw new Warning($message, $number); 66 | case E_ERROR: 67 | case E_USER_ERROR: 68 | throw new Error($message, $number, $file, $line); 69 | default: 70 | // No error was found so throw an exception. 71 | throw new \ErrorException($message, $number, $number, $file, $line); 72 | } 73 | } 74 | 75 | /** 76 | * Assert than an error has occurred. 77 | * 78 | * @param string $errstr The desired error string. 79 | * @param int $errno The desired error number. 80 | * @param bool $unset Whether to unset the error when it is encountered. 81 | */ 82 | public function expectErrorToOccur(string $errstr, int $errno, bool $unset = true) { 83 | $this->expectedErrors[] = [$errno, $errstr, $unset]; 84 | } 85 | 86 | /** 87 | * Assert than an error has occurred. 88 | * 89 | * @param int $errno The desired error number. 90 | * @param bool $unset Whether to unset the error when it is encountered. 91 | */ 92 | public function expectErrorNumberToOccur(int $errno, bool $unset = true) { 93 | $this->expectErrorToOccur('', $errno, $unset); 94 | } 95 | 96 | 97 | /** 98 | * Provides all of the schema types. 99 | * 100 | * @return array Returns an array of types suitable to pass to a test method. 101 | */ 102 | public function provideTypesAndData() { 103 | $result = [ 104 | 'array' => ['a', 'array', [1, 2, 3]], 105 | 'object' => ['o', 'object', ['foo' => 'bar']], 106 | 'integer' => ['i', 'integer', 123], 107 | 'string' => ['s', 'string', 'hello'], 108 | 'number' => ['f', 'number', 12.3], 109 | 'boolean' => ['b', 'boolean', true], 110 | 'timestamp' => ['ts', 'timestamp', time()], 111 | 'datetime' => ['dt', 'datetime', new \DateTimeImmutable()], 112 | 'null' => ['n', 'null', null], 113 | ]; 114 | return $result; 115 | } 116 | 117 | /** 118 | * Provide just the non-null types and data. 119 | * 120 | * @return array Returns a data provider array. 121 | */ 122 | public function provideNonNullTypesAndData() { 123 | $r = $this->provideTypesAndData(); 124 | unset($r['null']); 125 | return $r; 126 | } 127 | 128 | /** 129 | * Provides schema types without null. 130 | * 131 | * @return array Returns a data provider array. 132 | */ 133 | public function provideTypesAndDataNotNull(): array { 134 | $result = $this->provideTypesAndData(); 135 | unset($result['null']); 136 | 137 | return $result; 138 | } 139 | 140 | /** 141 | * Provide a variety of invalid data for the supported types. 142 | * 143 | * @return array Returns an data set with rows in the form [short type, value]. 144 | */ 145 | public function provideInvalidData() { 146 | $result = [ 147 | ['a', false], 148 | ['a', 123], 149 | ['a', 'foo'], 150 | ['a', ['bar' => 'baz']], 151 | ['o', false], 152 | ['o', 123], 153 | ['o', 'foo'], 154 | ['o', [1, 2, 3]], 155 | ['i', false], 156 | ['i', 'foo'], 157 | ['i', [1, 2, 3]], 158 | ['s', false], 159 | ['s', [1, 2, 3]], 160 | ['f', false], 161 | ['f', 'foo'], 162 | ['f', [1, 2, 3]], 163 | ['b', 123], 164 | ['b', 'foo'], 165 | ['b', [1, 2, 3]], 166 | ['ts', false], 167 | ['ts', 'foo'], 168 | ['ts', [1, 2, 3]], 169 | ['dt', (string)time()], 170 | ['dt', 'foo'], 171 | ['dt', [1, 2, 3]] 172 | ]; 173 | 174 | return $result; 175 | } 176 | 177 | /** 178 | * Get a schema of atomic types. 179 | * 180 | * @return Schema Returns the schema of atomic types. 181 | */ 182 | public function getAtomicSchema() { 183 | $schema = Schema::parse([ 184 | 'id:i', 185 | 'name:s' => 'The name of the object.', 186 | 'description:s?', 187 | 'timestamp:ts?', 188 | 'date?' => ['type' => 'string', 'format' => 'date-time'], 189 | 'amount:f?', 190 | 'enabled:b?', 191 | ]); 192 | 193 | return $schema; 194 | } 195 | 196 | /** 197 | * Get a basic nested schema for testing. 198 | * 199 | * @return Schema Returns a new schema for testing. 200 | */ 201 | public function getNestedSchema() { 202 | $schema = Schema::parse([ 203 | 'id:i', 204 | 'name:s', 205 | 'addr:o' => [ 206 | 'street:s?', 207 | 'city:s', 208 | 'zip:i?' 209 | ] 210 | ]); 211 | 212 | return $schema; 213 | } 214 | 215 | /** 216 | * Get a schema that consists of an array of objects. 217 | * 218 | * @return Schema Returns the schema. 219 | */ 220 | public function getArrayOfObjectsSchema() { 221 | $schema = Schema::parse([ 222 | 'rows:a' => [ 223 | 'id:i', 224 | 'name:s?' 225 | ] 226 | ]); 227 | 228 | return $schema; 229 | } 230 | 231 | /** 232 | * Assert that a validation object has an error code for a field. 233 | * 234 | * @param Validation $validation The validation object to inspect. 235 | * @param string $field The field to look for. 236 | * @param string $error The error code that must be present. 237 | */ 238 | public function assertFieldHasError(Validation $validation, $field, $error) { 239 | $name = $field ?: 'value'; 240 | 241 | if ($validation->isValidField($field)) { 242 | $this->fail("The $name does not have any errors."); 243 | return; 244 | } 245 | 246 | $codes = []; 247 | foreach ($validation->getFieldErrors($field) as $row) { 248 | if ($error === $row['code']) { 249 | $this->assertEquals($error, $row['code']); // Need at least one assertion. 250 | return; 251 | } 252 | $codes[] = $row['code']; 253 | } 254 | 255 | $has = implode(', ', $codes); 256 | $this->fail("The $name does not have the $error error (has $has)."); 257 | } 258 | 259 | /** 260 | * Load a schema from the project's open-api.json. 261 | * 262 | * @param string $ref The reference to the specific schema. 263 | * @return Schema Returns a new schema pointing to the proper place. 264 | * @throws \Exception Throws an exception if the open-api.json could not be decoded. 265 | */ 266 | protected function loadOpenApiSchema(string $ref): Schema { 267 | $data = json_decode(file_get_contents(__DIR__.'/../open-api.json'), true); 268 | if ($data === null) { 269 | throw new \Exception("The open-api.json could not be decoded.", 500); 270 | } 271 | 272 | $sch = new Schema(['$ref' => $ref], new ArrayRefLookup($data)); 273 | 274 | return $sch; 275 | } 276 | 277 | /** 278 | * Compare two key-sorted arrays. 279 | * 280 | * @param array $expected The expected result. 281 | * @param array $actual The actual result. 282 | * @param string $message An error message. 283 | */ 284 | public function assertSortedArrays(array $expected, array $actual, $message = '') { 285 | $this->sortArrayKeys($expected); 286 | $this->sortArrayKeys($actual); 287 | 288 | $this->assertEquals($expected, $actual, $message); 289 | } 290 | 291 | /** 292 | * Recursively sort the keys in an array. 293 | * 294 | * @param array $arr The array to check. 295 | */ 296 | protected function sortArrayKeys(array &$arr) { 297 | ksort($arr); 298 | foreach ($arr as &$value) { 299 | if (is_array($value)) { 300 | $this->sortArrayKeys($value); 301 | } 302 | } 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /tests/StringValidationTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use DateTime; 11 | use Garden\Schema\Schema; 12 | use Garden\Schema\Validation; 13 | use Garden\Schema\ValidationException; 14 | 15 | /** 16 | * Test string validation properties. 17 | */ 18 | class StringValidationTest extends AbstractSchemaTest { 19 | /** 20 | * Test string min length constraints. 21 | * 22 | * @param string $str The string to test. 23 | * @param string $code The expected error code, if any. 24 | * @param int $minLength The min length to test. 25 | * @param int $flags Flags to set on the schema. 26 | * 27 | * @dataProvider provideMinLengthTests 28 | */ 29 | public function testMinLength($str, $code, $minLength = 3, int $flags = null) { 30 | $schema = Schema::parse(['str:s' => ['minLength' => $minLength]]); 31 | if ($flags) { 32 | $schema->setFlags($flags); 33 | } 34 | 35 | try { 36 | $schema->validate(['str' => $str]); 37 | 38 | if (!empty($code)) { 39 | $this->fail("'$str' shouldn't validate against a min length of $minLength."); 40 | } else { 41 | // Everything validated correctly. 42 | $this->assertTrue(true); 43 | } 44 | } catch (ValidationException $ex) { 45 | $this->assertFieldHasError($ex->getValidation(), 'str', $code); 46 | } 47 | } 48 | 49 | /** 50 | * Provide test data for {@link testMinLength()}. 51 | * 52 | * @return array Returns a data provider array. 53 | */ 54 | public function provideMinLengthTests() { 55 | $r = [ 56 | 'empty' => ['', 'required'], // Empty string on required field now shows "required" error 57 | 'ab' => ['ab', 'minLength'], 58 | 'abc' => ['abc', ''], 59 | 'abcd' => ['abcd', ''], 60 | 'empty 1' => ['', 'required', 1], // Empty string with minLength=1 now shows "required" error 61 | 'empty 0' => ['', '', 0], 62 | 'unicode as bytes success' => ['😱', 'minLength', 4], 63 | 'unicode as unicode fail' => ['😱', 'minLength', 2, Schema::VALIDATE_STRING_LENGTH_AS_UNICODE], 64 | 'unicode as unicode success' => ['😱', '', 1, Schema::VALIDATE_STRING_LENGTH_AS_UNICODE], 65 | 66 | ]; 67 | 68 | return $r; 69 | } 70 | 71 | /** 72 | * Test string max length constraints. 73 | * 74 | * @param string $str The string to test. 75 | * @param string $code The expected error code, if any. 76 | * @param int $maxLength The max length to test. 77 | * 78 | * @dataProvider provideMaxLengthTests 79 | */ 80 | public function testMaxLength($str, string $code = '', int $maxLength = 3) { 81 | $schema = Schema::parse(['str:s?' => [ 82 | 'maxLength' => $maxLength, 83 | ]]); 84 | 85 | try { 86 | $schema->validate(['str' => $str]); 87 | 88 | if (!empty($code)) { 89 | $this->fail("'$str' shouldn't validate against a max length of $maxLength."); 90 | } else { 91 | // Everything validated correctly. 92 | $this->assertTrue(true); 93 | } 94 | } catch (ValidationException $ex) { 95 | $this->assertFieldHasError($ex->getValidation(), 'str', $code); 96 | } 97 | } 98 | 99 | /** 100 | * Provide test data for {@link testMaxLength()}. 101 | * 102 | * @return array Returns a data provider array. 103 | */ 104 | public function provideMaxLengthTests() { 105 | $r = [ 106 | 'empty' => [''], 107 | 'ab' => ['ab'], 108 | 'abc' => ['abc'], 109 | 'abcd' => ['abcd', 'maxLength'], 110 | ]; 111 | 112 | return $r; 113 | } 114 | 115 | /** 116 | * Test byte length validation. 117 | * 118 | * @param array $value 119 | * @param string|array|null $exceptionMessages Null, an expected exception message, or multiple expected exception messages. 120 | * @param bool $forceByteLength Set this to true to force all maxLengths to be byte length. 121 | * 122 | * @dataProvider provideByteLengths 123 | */ 124 | public function testByteLengthValidation(array $value, $exceptionMessages = null, bool $forceByteLength = false) { 125 | $schema = Schema::parse([ 126 | 'justLength:s?' => [ 127 | 'maxLength' => 4, 128 | ], 129 | 'justByteLength:s?' => [ 130 | 'maxByteLength' => 8, 131 | ], 132 | 'mixedLengths:s?' => [ 133 | 'maxLength' => 4, 134 | 'maxByteLength' => 6 135 | ], 136 | ]); 137 | if ($forceByteLength) { 138 | $schema->setFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE, false); 139 | } 140 | 141 | try { 142 | $schema->validate($value); 143 | // We were expecting success. 144 | $this->assertTrue(true); 145 | } catch (ValidationException $e) { 146 | if ($exceptionMessages !== null) { 147 | $actual = $e->getMessage(); 148 | $exceptionMessages = is_array($exceptionMessages) ? $exceptionMessages : [$exceptionMessages]; 149 | foreach ($exceptionMessages as $expected) { 150 | $this->assertStringContainsString($expected, $actual); 151 | } 152 | } else { 153 | throw $e; 154 | } 155 | } 156 | } 157 | 158 | /** 159 | * @return array 160 | */ 161 | public function provideByteLengths() { 162 | return [ 163 | 'maxLength - short' => [['justLength' => '😱']], 164 | 'maxLength - equal' => [['justLength' => '😱😱😱😱']], 165 | 'maxLength - long' => [['justLength' => '😱😱😱😱😱'], '1 character too long'], 166 | 'byteLength - short' => [['justByteLength' => '😱']], 167 | 'byteLength - equal' => [['justByteLength' => '😱😱']], 168 | 'byteLength - long' => [['justByteLength' => '😱😱a'], '1 byte too long'], 169 | 'mixedLengths - short' => [['mixedLengths' => '😱']], 170 | 'mixedLengths - equal' => [['mixedLengths' => '😱aa']], 171 | 'mixedLengths - long bytes' => [['mixedLengths' => '😱😱'], '2 bytes too long'], 172 | 'mixedLengths - long chars' => [['mixedLengths' => 'aaaaa'], '1 character too long'], 173 | 'mixedLengths - long chars - long bytes' => [['mixedLengths' => '😱😱😱😱😱'], ["1 character too long", "14 bytes too long."]], 174 | 'byteLength flag - short' => [['justLength' => '😱'], null, true], 175 | 'byteLength flag - long' => [['justLength' => '😱😱😱😱'], '12 bytes too long', true], 176 | 'byteLength property is preferred over byte length flag' => [['mixedLengths' => '😱😱'], '2 bytes too long', true] 177 | ]; 178 | } 179 | 180 | /** 181 | * Test string pattern constraints. 182 | * 183 | * @param string $str The string to test. 184 | * @param string $code The expected error code, if any. 185 | * @param string $pattern The pattern to test. 186 | * @dataProvider providePatternTests 187 | */ 188 | public function testPattern($str, $code = '', $pattern = '^[a-z]o+$') { 189 | $schema = Schema::parse(['str:s?' => ['pattern' => $pattern]]); 190 | 191 | try { 192 | $schema->validate(['str' => $str]); 193 | 194 | if (!empty($code)) { 195 | $this->fail("'$str' shouldn't validate against a pattern of $pattern."); 196 | } else { 197 | $this->assertMatchesRegularExpression("/{$pattern}/", $str); 198 | } 199 | } catch (ValidationException $ex) { 200 | $this->assertFieldHasError($ex->getValidation(), 'str', $code); 201 | } 202 | } 203 | 204 | /** 205 | * Provide test data for {@link testPattern()}. 206 | * 207 | * @return array Returns a data provider array. 208 | */ 209 | public function providePatternTests() { 210 | $r = [ 211 | 'empty' => ['', 'pattern'], 212 | 'fo' => ['fo', ''], 213 | 'foo' => ['foooooooooo', ''], 214 | 'abcd' => ['abcd', 'pattern'], 215 | ]; 216 | 217 | return $r; 218 | } 219 | 220 | /** 221 | * Test the enum constraint. 222 | */ 223 | public function testEnum() { 224 | $this->expectException(ValidationException::class); 225 | $this->expectExceptionMessage("value must be one of: one, two, three, null."); 226 | $this->expectExceptionCode(400); 227 | $enum = ['one', 'two', 'three', null]; 228 | $schema = Schema::parse([':s|n' => ['enum' => $enum]]); 229 | 230 | foreach ($enum as $str) { 231 | $this->assertTrue($schema->isValid($str)); 232 | } 233 | 234 | $schema->validate('four'); 235 | } 236 | 237 | /** 238 | * Test a required empty string with a min length of 0. 239 | */ 240 | public function testRequiredEmptyString() { 241 | $schema = Schema::parse([ 242 | 'col:s' => ['minLength' => 0] 243 | ]); 244 | 245 | $emptyData = ['col' => '']; 246 | $valid = $schema->validate($emptyData); 247 | $this->assertEmpty($valid['col']); 248 | $this->assertIsString($valid['col']); 249 | 250 | $nullData = ['col' => null]; 251 | $isValid = $schema->isValid($nullData); 252 | $this->assertFalse($isValid); 253 | 254 | $missingData = []; 255 | $isValid = $schema->isValid($missingData); 256 | $this->assertFalse($isValid); 257 | } 258 | 259 | /** 260 | * Test that verifies that optional date-time field accepts empty string("") as a valid value. 261 | */ 262 | public function testEmptyOptionalDateTime() { 263 | $schema = Schema::parse([ 264 | 'field?' => [ 265 | 'type' => 'string', 266 | 'format' => 'date-time', 267 | 'allowNull' => true 268 | ] 269 | ]); 270 | $value['field'] = ''; 271 | $validation = $schema->validate($value); 272 | $this->assertEquals(['field' => ''], $validation); 273 | } 274 | 275 | /** 276 | * Test different date/time parsing. 277 | * 278 | * @param mixed $value The value to parse. 279 | * @param string $expected The expected datetime. 280 | * @dataProvider provideDateTimeFormatTests 281 | */ 282 | public function testDateTimeFormat($value, $expected) { 283 | $schema = Schema::parse([':s' => ['format' => 'date-time']]); 284 | 285 | $valid = $schema->validate($value); 286 | $this->assertEquals($expected, $valid); 287 | } 288 | 289 | /** 290 | * Provide date strings in various formats. 291 | * 292 | * @return array Returns a data provider array. 293 | */ 294 | public function provideDateTimeFormatTests() { 295 | $dt = new \DateTimeImmutable('1:23pm'); 296 | 297 | $r = [ 298 | $dt->format(DateTime::ATOM), 299 | $dt->format(DateTime::COOKIE), 300 | $dt->format(DateTime::RFC822), 301 | $dt->format(DateTime::RFC850), 302 | $dt->format(DateTime::RFC850), 303 | $dt->format(DateTime::W3C), 304 | ]; 305 | 306 | $r = array_map(function ($v) use ($dt) { 307 | return [$v, $dt]; 308 | }, $r); 309 | $r = array_column($r, null, 0); 310 | return $r; 311 | } 312 | 313 | /** 314 | * Test the email string format. 315 | */ 316 | public function testEmailFormat() { 317 | $schema = Schema::parse([':s' => ['format' => 'email']]); 318 | 319 | $this->assertTrue($schema->isValid('todd@example.com')); 320 | $this->assertTrue($schema->isValid('todd+foo@example.com')); 321 | $this->assertFalse($schema->isValid('todd@example')); 322 | } 323 | 324 | /** 325 | * Test the IPv4 format. 326 | */ 327 | public function testIPv4Format() { 328 | $schema = Schema::parse([':s' => ['format' => 'ipv4']]); 329 | 330 | $this->assertTrue($schema->isValid('127.0.0.1')); 331 | $this->assertTrue($schema->isValid('192.168.5.5')); 332 | $this->assertFalse($schema->isValid('todd@example')); 333 | } 334 | 335 | /** 336 | * Test the IPv6 format. 337 | */ 338 | public function testIPv6Format() { 339 | $schema = Schema::parse([':s' => ['format' => 'ipv6']]); 340 | 341 | $this->assertTrue($schema->isValid('2001:0db8:0a0b:12f0:0000:0000:0000:0001')); 342 | $this->assertTrue($schema->isValid('2001:db8::1')); 343 | $this->assertFalse($schema->isValid('127.0.0.1')); 344 | } 345 | 346 | /** 347 | * Test the IPv6 format. 348 | */ 349 | public function testIPFormat() { 350 | $schema = Schema::parse([':s' => ['format' => 'ip']]); 351 | 352 | $this->assertTrue($schema->isValid('2001:0db8:0a0b:12f0:0000:0000:0000:0001')); 353 | $this->assertTrue($schema->isValid('2001:db8::1')); 354 | $this->assertTrue($schema->isValid('127.0.0.1')); 355 | $this->assertFalse($schema->isValid('todd@example')); 356 | } 357 | 358 | /** 359 | * Test the IPv6 format. 360 | * 361 | * @param string $uri A URI. 362 | * @param bool $valid Whether the URI should be valid or invalid. 363 | * @dataProvider provideUris 364 | */ 365 | public function testUriFormat($uri, $valid = true) { 366 | $schema = Schema::parse([':s' => ['format' => 'uri']]); 367 | 368 | if ($valid) { 369 | $this->assertTrue($schema->isValid($uri)); 370 | } else { 371 | $this->assertFalse($schema->isValid($uri)); 372 | } 373 | } 374 | 375 | /** 376 | * Provide test data for {@link testUriFormat()}. 377 | * 378 | * @return array Returns a data provider. 379 | */ 380 | public function provideUris() { 381 | $r = [ 382 | ['ftp://ftp.is.co.za/rfc/rfc1808.txt1'], 383 | ['http://www.ietf.org/rfc/rfc2396.txt'], 384 | ['ldap://[2001:db8::7]/c=GB?objectClass?one'], 385 | ['mailto:John.Doe@example.com'], 386 | ['news:comp.infosystems.www.servers.unix'], 387 | ['telnet://192.0.2.16:80/'], 388 | ['aaa', false] 389 | ]; 390 | 391 | return array_column($r, null, 0); 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /tests/NestedSchemaTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | use Garden\Schema\ValidationException; 12 | use Garden\Schema\ValidationField; 13 | use Garden\Schema\Tests\Fixtures\SchemaValidationFail; 14 | 15 | /** 16 | * Tests for nested object schemas. 17 | */ 18 | class NestedSchemaTest extends AbstractSchemaTest { 19 | /** 20 | * Test nested schema validation with valid data. 21 | */ 22 | public function testNestedValid() { 23 | $schema = $this->getNestedSchema(); 24 | 25 | $validData = [ 26 | 'id' => 123, 27 | 'name' => 'Todd', 28 | 'addr' => [ 29 | 'street' => '414 rue McGill', 30 | 'city' => 'Montreal', 31 | ] 32 | ]; 33 | 34 | $isValid = $schema->isValid($validData); 35 | $this->assertTrue($isValid); 36 | } 37 | 38 | /** 39 | * Test a nested schema with som invalid data. 40 | */ 41 | public function testNestedInvalid() { 42 | $schema = $this->getNestedSchema(); 43 | 44 | $invalidData = [ 45 | 'id' => 123, 46 | 'name' => 'Toddo', 47 | 'addr' => [ 48 | 'zip' => 'H2Y 2G1' 49 | ] 50 | ]; 51 | 52 | try { 53 | $schema->validate($invalidData); 54 | $this->fail("The data should not be valid."); 55 | } catch (ValidationException $ex) { 56 | $validation = $ex->getValidation(); 57 | $this->assertFalse($validation->isValidField('addr/city'), "addr.street should be invalid."); 58 | $this->assertFalse($validation->isValidField('addr/zip'), "addr.zip should be invalid."); 59 | } 60 | } 61 | 62 | /** 63 | * Verify field names in nested schema error messages appear as expected. 64 | */ 65 | public function testNestedInvalidMessage() { 66 | $this->expectException(ValidationException::class); 67 | $this->expectExceptionMessage("false is not a valid integer."); 68 | $sch = $this->getNestedSchema(); 69 | $result = $sch->validate([ 70 | 'id' => 1, 71 | 'name' => 'Vanilla', 72 | 'addr' => [ 73 | 'stree' => '123 Fake St.', 74 | 'city' => 'Nowhere', 75 | 'zip' => false 76 | ] 77 | ]); 78 | } 79 | 80 | /** 81 | * Test a variety of array item validation scenarios. 82 | */ 83 | public function testArrayItemsType() { 84 | $schema = Schema::parse(['arr:a' => 'i']); 85 | 86 | $validData = ['arr' => [1, '2', 3]]; 87 | $this->assertTrue($schema->isValid($validData)); 88 | 89 | $invalidData = ['arr' => [1, 'foo', 'bar']]; 90 | $this->assertFalse($schema->isValid($invalidData)); 91 | 92 | // Try a custom validator for the items. 93 | $schema->addValidator('properties/arr/items', function ($value, ValidationField $field) { 94 | if ($value > 2) { 95 | $field->addError('{field} must be less than 2.'); 96 | } 97 | }); 98 | try { 99 | $schema->validate($validData); 100 | $this->fail("The data should not validate."); 101 | } catch (ValidationException $ex) { 102 | $validation = $ex->getValidation(); 103 | $this->assertFalse($validation->isValidField('arr/2')); 104 | $this->assertEquals('arr/2 must be less than 2.', $validation->getMessage()); 105 | } 106 | } 107 | 108 | /** 109 | * Test a schema of an array of objects. 110 | */ 111 | public function testArrayOfObjectsSchema() { 112 | $schema = $this->getArrayOfObjectsSchema(); 113 | 114 | $expected = [ 115 | 'type' => 'object', 116 | 'properties' => [ 117 | 'rows' => [ 118 | 'type' => 'array', 119 | 'items' => [ 120 | 'type' => 'object', 121 | 'properties' => [ 122 | 'id' => ['type' => 'integer'], 123 | 'name' => ['type' => 'string'] 124 | ], 125 | 'required' => ['id'] 126 | ] 127 | ] 128 | ], 129 | 'required' => ['rows'] 130 | ]; 131 | 132 | $actual = $schema->jsonSerialize(); 133 | $this->assertEquals($expected, $actual); 134 | } 135 | 136 | /** 137 | * Test an array of objects to make sure it's valid. 138 | */ 139 | public function testArrayOfObjectsValid() { 140 | $schema = $this->getArrayOfObjectsSchema(); 141 | 142 | $data = [ 143 | 'rows' => [ 144 | ['id' => 1, 'name' => 'Todd'], 145 | ['id' => 2], 146 | ['id' => '23', 'name' => 123] 147 | ] 148 | ]; 149 | 150 | $valid = $schema->validate($data); 151 | 152 | $this->assertIsInt($valid['rows'][2]['id']); 153 | $this->assertIsString($valid['rows'][2]['name']); 154 | } 155 | 156 | /** 157 | * Test an array of objects that are invalid and make sure the errors are correct. 158 | */ 159 | public function testArrayOfObjectsInvalid() { 160 | $schema = $this->getArrayOfObjectsSchema(); 161 | 162 | try { 163 | $missingData = []; 164 | $schema->validate($missingData); 165 | $this->fail('$missingData should not be valid.'); 166 | } catch (ValidationException $ex) { 167 | $this->assertFalse($ex->getValidation()->isValidField('rows')); 168 | } 169 | 170 | try { 171 | $notArrayData = ['rows' => 123]; 172 | $schema->validate($notArrayData); 173 | $this->fail('$notArrayData should not be valid.'); 174 | } catch (ValidationException $ex) { 175 | $this->assertFalse($ex->getValidation()->isValidField('rows')); 176 | } 177 | 178 | try { 179 | $nullItemData = ['rows' => [null]]; 180 | $schema->validate($nullItemData); 181 | } catch (ValidationException $ex) { 182 | $this->assertFalse($ex->getValidation()->isValidField('rows/0')); 183 | } 184 | 185 | try { 186 | $invalidRowsData = ['rows' => [ 187 | ['id' => 'foo'], 188 | ['id' => 123], 189 | ['name' => 'Todd'] 190 | ]]; 191 | $schema->validate($invalidRowsData); 192 | } catch (ValidationException $ex) { 193 | $v4 = $ex->getValidation(); 194 | $this->assertFalse($v4->isValidField('rows/0/id')); 195 | $this->assertTrue($v4->isValidField('rows/1/id')); 196 | $this->assertFalse($v4->isValidField('rows/2/id')); 197 | } 198 | } 199 | 200 | /** 201 | * Test throwing an exception when removing unexpected parameters from validated data. 202 | */ 203 | public function testValidateException() { 204 | $this->expectException(ValidationException::class); 205 | $this->doValidationBehavior(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION); 206 | } 207 | 208 | /** 209 | * Call validate on an instance of Schema where the data contains unexpected parameters. 210 | * 211 | * @param int $validationBehavior One of the **Schema::VALIDATE_*** constants. 212 | */ 213 | protected function doValidationBehavior($validationBehavior) { 214 | $schema = Schema::parse([ 215 | 'groupID:i' => 'The ID of the group.', 216 | 'name:s' => 'The name of the group.', 217 | 'description:s' => 'A description of the group.', 218 | 'member:o' => [ 219 | 'email:s' => 'The ID of the new member.' 220 | ] 221 | ]); 222 | $schema->setFlags($validationBehavior); 223 | 224 | $data = [ 225 | 'groupID' => 123, 226 | 'name' => 'Group Foo', 227 | 'description' => 'A group for testing.', 228 | 'member' => [ 229 | 'email' => 'user@example.com', 230 | 'role' => 'Leader', 231 | ] 232 | ]; 233 | 234 | $valid = $schema->validate($data); 235 | $this->assertArrayNotHasKey('role', $valid['member']); 236 | } 237 | 238 | /** 239 | * Test triggering a notice when removing unexpected parameters from validated data. 240 | */ 241 | public function testValidateNotice() { 242 | $this->expectNotice(); 243 | $this->doValidationBehavior(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE); 244 | } 245 | 246 | /** 247 | * Test silently removing unexpected parameters from validated data. 248 | */ 249 | public function testValidateRemove() { 250 | $this->doValidationBehavior(0); 251 | } 252 | 253 | /** 254 | * The schema fields should be case-insensitive and fix the case of incorrect keys. 255 | */ 256 | public function testCaseInsensitivity() { 257 | $schema = Schema::parse([ 258 | 'obj:o' => [ 259 | 'id:i', 260 | 'name:s?' 261 | ] 262 | ]); 263 | 264 | $data = [ 265 | 'Obj' => [ 266 | 'ID' => 123, 267 | 'namE' => 'Frank' 268 | ] 269 | ]; 270 | 271 | $valid = $schema->validate($data); 272 | 273 | $expected = [ 274 | 'obj' => [ 275 | 'id' => 123, 276 | 'name' => 'Frank' 277 | ] 278 | ]; 279 | 280 | $this->assertEquals($expected, $valid); 281 | } 282 | 283 | /** 284 | * Test passing a schema instance as details for a parameter. 285 | */ 286 | public function testSchemaAsParameter() { 287 | $userSchema = Schema::parse([ 288 | 'userID:i', 289 | 'name:s', 290 | 'email:s' 291 | ]); 292 | 293 | $schema = Schema::parse([ 294 | 'name:s' => 'The title of the discussion.', 295 | 'body:s' => 'The body of the discussion.', 296 | 'insertUser' => $userSchema, 297 | 'updateUser?' => $userSchema 298 | ]); 299 | 300 | $expected = [ 301 | 'type' => 'object', 302 | 'properties' => [ 303 | 'name' => ['type' => 'string', 'description' => 'The title of the discussion.', 'minLength' => 1], 304 | 'body' => ['type' => 'string', 'description' => 'The body of the discussion.', 'minLength' => 1], 305 | 'insertUser' => [ 306 | 'type' => 'object', 307 | 'properties' => [ 308 | 'userID' => ['type' => 'integer'], 309 | 'name' => ['type' => 'string', 'minLength' => 1], 310 | 'email' => ['type' => 'string', 'minLength' => 1] 311 | ], 312 | 'required' => ['userID', 'name', 'email'] 313 | ], 314 | 'updateUser' => [ 315 | 'type' => 'object', 316 | 'properties' => [ 317 | 'userID' => ['type' => 'integer'], 318 | 'name' => ['type' => 'string', 'minLength' => 1], 319 | 'email' => ['type' => 'string', 'minLength' => 1] 320 | ], 321 | 'required' => ['userID', 'name', 'email'] 322 | ] 323 | ], 324 | 'required' => ['name', 'body', 'insertUser'] 325 | ]; 326 | 327 | $this->assertEquals($expected, $schema->jsonSerialize()); 328 | } 329 | 330 | /** 331 | * Nested schemas should properly validate by calling the nested schema's validation. 332 | */ 333 | public function testNestedSchemaValidation() { 334 | $userSchema = Schema::parse([ 335 | 'name:s', 336 | 'email:s?' 337 | ]); 338 | 339 | $schema = Schema::parse([':a' => $userSchema]); 340 | 341 | $clean = $schema->validate([ 342 | ['name' => 'Todd', 'wut' => 'foo'], 343 | ['name' => 'Ryan', 'email' => 'ryan@example.com'] 344 | ]); 345 | $this->assertArrayNotHasKey('wut', $clean[0]); 346 | 347 | try { 348 | $schema->validate([ 349 | ['email' => 'foo@bar.com'], 350 | ['name' => Schema::parse([])] 351 | ]); 352 | $this->fail("The data is not supposed to validate."); 353 | } catch (ValidationException $ex) { 354 | $errors = $ex->getValidation()->getErrors(); 355 | $this->assertCount(2, $errors); 356 | $this->assertEquals('0/name is required.', $errors[0]['message']); 357 | $this->assertEquals('1/name: The value is not a valid string.', $errors[1]['message']); 358 | } 359 | } 360 | 361 | /** 362 | * Objects that implement array access should be able to be validated. 363 | */ 364 | public function testArrayAccessValidation() { 365 | $schema = Schema::parse([ 366 | 'name:s', 367 | 'arr:a' => [ 368 | 'id:i', 369 | 'foo:s' 370 | ] 371 | ]); 372 | 373 | $data = new \ArrayObject([ 374 | 'name' => 'bur', 375 | 'arr' => new \ArrayObject([ 376 | new \ArrayObject(['id' => 1, 'foo' => 'bar']), 377 | new \ArrayObject(['id' => 2, 'foo' => 'baz']), 378 | ]) 379 | ]); 380 | 381 | $valid = $schema->validate($data); 382 | 383 | $expected = new \ArrayObject([ 384 | 'name' => 'bur', 385 | 'arr' => [ 386 | new \ArrayObject(['id' => 1, 'foo' => 'bar']), 387 | new \ArrayObject(['id' => 2, 'foo' => 'baz']), 388 | ] 389 | ]); 390 | 391 | $this->assertEquals($expected, $valid); 392 | } 393 | 394 | /** 395 | * Schemas should be able to recursively point to themselves. 396 | */ 397 | public function testRecursive() { 398 | $sch = Schema::parse([ 399 | 'name:s', 400 | 'children:a?' 401 | ]); 402 | 403 | $sch->setField('properties/children/items', $sch); 404 | $sch->validate(['name' => 'boo']); 405 | 406 | $data = [ 407 | 'name' => 'grand', 408 | 'children' => [ 409 | [ 410 | 'name' => 'parent', 411 | 'children' => [ 412 | ['name' => 'child'] 413 | ] 414 | ] 415 | ] 416 | ]; 417 | 418 | $valid2 = $sch->validate($data); 419 | $this->assertEquals($data, $valid2); 420 | } 421 | 422 | /** 423 | * Test that a schema that throws an exception in Schema::validate() will have a proper error message. 424 | */ 425 | public function testNestedSchemaValidationFailureException() { 426 | $schema = Schema::parse([ 427 | 'sub-schema-fail' => new SchemaValidationFail() 428 | ]); 429 | 430 | $this->expectExceptionMessage('sub-schema-fail is always invalid.'); 431 | $schema->validate(['sub-schema-fail' => null]); 432 | } 433 | 434 | /** 435 | * Test that parse preserves subschema instances. 436 | * 437 | * @return void 438 | */ 439 | public function testPreserveSchemasOnParse(): void { 440 | $childArrSchema = new Schema([ 441 | "type" => "array", 442 | "items" => [ 443 | "type" => "string", 444 | ] 445 | ]); 446 | 447 | $objSchema = Schema::parse([ 448 | "name:s", 449 | "description:s?", 450 | "tags?" => $childArrSchema, 451 | ]); 452 | 453 | $this->assertInstanceOf(Schema::class, $objSchema->getSchemaArray()["properties"]["tags"]); 454 | $this->assertEquals($childArrSchema, $objSchema->getSchemaArray()["properties"]["tags"]); 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /tests/BasicSchemaTest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema\Tests; 9 | 10 | use Garden\Schema\Schema; 11 | use Garden\Schema\Tests\Fixtures\CustomArray; 12 | use Garden\Schema\Tests\Fixtures\CustomArrayObject; 13 | use Garden\Schema\Tests\Fixtures\TestValidation; 14 | use Garden\Schema\Validation; 15 | use Garden\Schema\ValidationException; 16 | 17 | /** 18 | * Tess for the {@link Schema} object. 19 | */ 20 | class BasicSchemaTest extends AbstractSchemaTest { 21 | /** 22 | * An empty schema should validate to anything. 23 | */ 24 | public function testEmptySchema() { 25 | $schema = Schema::parse([]); 26 | 27 | $val = [123]; 28 | $r = $schema->validate($val); 29 | $this->assertSame($val, $r); 30 | 31 | $val = true; 32 | $r = $schema->validate($val); 33 | $this->assertSame($val, $r); 34 | } 35 | 36 | /** 37 | * An object with no types should validate, but still require values. 38 | */ 39 | public function testEmptyTypes() { 40 | $schema = Schema::parse(['a', 'b' => 'Yup', 'c' => []]); 41 | 42 | $data = ['a' => [], 'b' => 1111, 'c' => 'hey!!!']; 43 | $valid = $schema->validate($data); 44 | $this->assertSame($data, $valid); 45 | 46 | try { 47 | $schema->validate([]); 48 | $this->fail('The data should not be valid.'); 49 | } catch (ValidationException $ex) { 50 | $errors = $ex->getValidation()->getErrors(); 51 | foreach ($errors as $error) { 52 | $this->assertSame('required', $error['error']); 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * Test some basic validation. 59 | */ 60 | public function testAtomicValidation() { 61 | $schema = $this->getAtomicSchema(); 62 | $data = [ 63 | 'id' => 123, 64 | 'name' => 'foo', 65 | 'timestamp' => '13 oct 1975', 66 | 'amount' => '99.50', 67 | 'enabled' => 'yes' 68 | ]; 69 | 70 | $valid = $schema->validate($data); 71 | 72 | $expected = $data; 73 | $expected['timestamp'] = strtotime($data['timestamp']); 74 | $expected['enabled'] = true; 75 | 76 | $this->assertEquals($expected, $valid); 77 | } 78 | 79 | /** 80 | * Test some data that doesn't need to be be coerced (except one string). 81 | */ 82 | public function testAtomicValidation2() { 83 | $schema = $this->getAtomicSchema(); 84 | $data = [ 85 | 'id' => 123, 86 | 'name' => 'foo', 87 | 'description' => 456, 88 | 'timestamp' => time(), 89 | 'date' => new \DateTime(), 90 | 'amount' => 5.99, 91 | 'enabled' => true 92 | ]; 93 | 94 | $validated = $schema->validate($data); 95 | $this->assertEquals($data, $validated); 96 | } 97 | 98 | /** 99 | * Test boolean data validation. 100 | * 101 | * @param mixed $input The input data. 102 | * @param bool $expected The expected boolean value. 103 | * @dataProvider provideBooleanData 104 | */ 105 | public function testBooleanSchema($input, $expected) { 106 | $schema = Schema::parse([':b']); 107 | $valid = $schema->validate($input); 108 | $this->assertSame($expected, $valid); 109 | } 110 | 111 | /** 112 | * Test different date/time parsing. 113 | * 114 | * @param mixed $value The value to parse. 115 | * @param \DateTimeInterface $expected The expected datetime. 116 | * @dataProvider provideDateTimes 117 | */ 118 | public function testDateTimeFormats($value, \DateTimeInterface $expected) { 119 | $schema = Schema::parse([':dt']); 120 | 121 | $valid = $schema->validate($value); 122 | $this->assertInstanceOf(\DateTimeInterface::class, $valid); 123 | $this->assertEquals($expected->getTimestamp(), $valid->getTimestamp()); 124 | } 125 | 126 | /** 127 | * Provide date/time test data. 128 | * 129 | * @return array Returns a data provider. 130 | */ 131 | public function provideDateTimes() { 132 | $dt = new \DateTimeImmutable('1975-11-11T12:31'); 133 | 134 | $r = [ 135 | 'string' => [$dt->format('c'), $dt], 136 | 'timestamp' => [$dt->getTimestamp(), $dt] 137 | ]; 138 | 139 | return $r; 140 | } 141 | 142 | /** 143 | * Test an array type. 144 | */ 145 | public function testArrayType() { 146 | $schema = Schema::parse([':a']); 147 | 148 | $expectedSchema = [ 149 | 'type' => 'array' 150 | ]; 151 | 152 | // Basic array without a type. 153 | $this->assertEquals($expectedSchema, $schema->jsonSerialize()); 154 | 155 | $data = [1, 2, 3]; 156 | $this->assertTrue($schema->isValid($data)); 157 | $data = []; 158 | $this->assertTrue($schema->isValid($data)); 159 | 160 | // Array with a description and not a type. 161 | $expectedSchema['description'] = 'Hello world!'; 162 | $schema = Schema::parse([':a' => 'Hello world!']); 163 | $this->assertEquals($expectedSchema, $schema->jsonSerialize()); 164 | 165 | // Array with an items type. 166 | unset($expectedSchema['description']); 167 | $expectedSchema['items']['type'] = 'integer'; 168 | 169 | $schema = Schema::parse([':a' => 'i']); 170 | $this->assertEquals($expectedSchema, $schema->jsonSerialize()); 171 | 172 | // Test the longer syntax. 173 | $expectedSchema['description'] = 'Hello world!'; 174 | $schema = Schema::parse([':a' => [ 175 | 'description' => 'Hello world!', 176 | 'items' => ['type' => 'integer'] 177 | ]]); 178 | $this->assertEquals($expectedSchema, $schema->jsonSerialize()); 179 | } 180 | 181 | /** 182 | * Test that the schema long form can be used to create a schema. 183 | */ 184 | public function testLongCreate() { 185 | $schema = $this->getAtomicSchema(); 186 | $schema2 = new Schema($schema->jsonSerialize()); 187 | 188 | $this->assertEquals($schema->jsonSerialize(), $schema2->jsonSerialize()); 189 | } 190 | 191 | /** 192 | * Test data that is not required, but provided as empty. 193 | * 194 | * @param string $shortType The short data type. 195 | * @dataProvider provideNonNullTypesAndData 196 | */ 197 | public function testNotRequired($shortType) { 198 | $schema = Schema::parse([ 199 | "col:$shortType?" 200 | ]); 201 | 202 | $missingData = []; 203 | $isValid = $schema->isValid($missingData); 204 | $this->assertTrue($isValid); 205 | $this->assertArrayNotHasKey('col', $missingData); 206 | } 207 | 208 | /** 209 | * Test data that is not required, but provided as empty. 210 | * 211 | * @param string $shortType The short data type. 212 | * @dataProvider provideTypesAndDataNotNull 213 | */ 214 | public function testRequiredEmpty($shortType) { 215 | if ($shortType === 'b') { 216 | $this->doTestRequiredEmptyBool(); 217 | return; 218 | } 219 | 220 | $schema = Schema::parse([ 221 | "col:$shortType" 222 | ]); 223 | 224 | $emptyData = ['col' => '']; 225 | $isValid = $schema->isValid($emptyData); 226 | $this->assertFalse($isValid); 227 | 228 | $nullData = ['col' => null]; 229 | $isValid = $schema->isValid($nullData); 230 | $this->assertFalse($isValid); 231 | } 232 | 233 | /** 234 | * Test empty boolean values. 235 | * 236 | * In general, bools should be cast to false if they are passed, but falsey. 237 | */ 238 | protected function doTestRequiredEmptyBool() { 239 | $schema = Schema::parse([ 240 | 'col:b' 241 | ]); 242 | /* @var Validation $validation */ 243 | $emptyData = ['col' => '']; 244 | $valid = $schema->validate($emptyData); 245 | $this->assertFalse($valid['col']); 246 | 247 | $nullData = ['col' => null]; 248 | $isValid = $schema->isValid($nullData); 249 | $this->assertFalse($isValid); 250 | 251 | $missingData = []; 252 | try { 253 | $schema->validate($missingData); 254 | } catch (ValidationException $ex) { 255 | $this->assertFalse($ex->getValidation()->isValidField('col')); 256 | } 257 | } 258 | 259 | /** 260 | * Test {@link Schema::requireOneOf()}. 261 | */ 262 | public function testRequireOneOf() { 263 | $schema = $this 264 | ->getAtomicSchema() 265 | ->requireOneOf(['description', 'enabled']); 266 | 267 | $valid1 = ['id' => 123, 'name' => 'Foo', 'description' => 'Hello']; 268 | $this->assertTrue($schema->isValid($valid1)); 269 | 270 | $valid2 = ['id' => 123, 'name' => 'Foo', 'enabled' => true]; 271 | $this->assertTrue($schema->isValid($valid2)); 272 | 273 | $invalid1 = ['id' => 123, 'name' => 'Foo']; 274 | $this->assertFalse($schema->isValid($invalid1)); 275 | 276 | // Test requiring one of nested. 277 | $schema = $this 278 | ->getAtomicSchema() 279 | ->requireOneOf(['description', ['amount', 'enabled']]); 280 | 281 | $this->assertTrue($schema->isValid($valid1)); 282 | 283 | $valid3 = ['id' => 123, 'name' => 'Foo', 'amount' => 99, 'enabled' => true]; 284 | $this->assertTrue($schema->isValid($valid3)); 285 | 286 | $this->assertFalse($schema->isValid($invalid1)); 287 | 288 | $invalid2 = ['id' => 123, 'name' => 'Foo', 'enabled' => true]; 289 | $this->assertFalse($schema->isValid($invalid2)); 290 | 291 | // Test requiring 2 of. 292 | $schema = $this 293 | ->getAtomicSchema() 294 | ->requireOneOf(['description', 'amount', 'enabled'], '', 2); 295 | 296 | $valid4 = ['id' => 123, 'name' => 'Foo', 'description' => 'Hello', 'enabled' => true]; 297 | $this->assertTrue($schema->isValid($valid4)); 298 | } 299 | 300 | /** 301 | * Require one of on an empty array should fail. 302 | */ 303 | public function testRequireOneOfEmpty() { 304 | $this->expectException(\Garden\Schema\ValidationException::class); 305 | $schema = Schema::parse(['a:i?', 'b:i?', 'c:i?'])->requireOneOf(['a', 'b', 'c'], '', 2); 306 | 307 | $r = $schema->validate([]); 308 | } 309 | 310 | /** 311 | * Require one of should not fire during sparse validation. 312 | */ 313 | public function testRequireOneOfSparse() { 314 | $schema = Schema::parse(['a:i?', 'b:i?', 'c:i?'])->requireOneOf(['a', 'b', 'c'], '', 2); 315 | 316 | $data = []; 317 | $this->expectErrorNumberToOccur(E_USER_DEPRECATED); 318 | $result = $schema->validate($data, true); 319 | $this->assertSame($data, $result); 320 | 321 | $data2 = ['a' => 1]; 322 | $result2 = $schema->validate($data2, ['sparse' => true]); 323 | $this->assertSame($data2, $result2); 324 | } 325 | 326 | /** 327 | * Test a variety of invalid values. 328 | * 329 | * @param string $type The type short code. 330 | * @param mixed $value A value that should be invalid for the type. 331 | * @dataProvider provideInvalidData 332 | */ 333 | public function testInvalidValues($type, $value) { 334 | $schema = Schema::parse([ 335 | "col:$type?" 336 | ]); 337 | $strVal = json_encode($value); 338 | 339 | $invalidData = ['col' => $value]; 340 | try { 341 | $schema->validate($invalidData); 342 | $this->fail("isValid: type $type with value $strVal should not be valid."); 343 | } catch (ValidationException $ex) { 344 | $validation = $ex->getValidation(); 345 | $this->assertFalse($validation->isValidField('col'), "fieldValid: type $type with value $strVal should not be valid."); 346 | } 347 | } 348 | 349 | /** 350 | * Provide a variety of valid boolean data. 351 | * 352 | * @return array Returns an array of boolean data. 353 | */ 354 | public function provideBooleanData() { 355 | return [ 356 | 'false' => [false, false], 357 | 'false str' => ['false', false], 358 | '0' => [0, false], 359 | '0 str' => ['0', false], 360 | 'off' => ['off', false], 361 | 'no' => ['no', false], 362 | 363 | 'true' => [true, true], 364 | 'true str' => ['true', true], 365 | '1' => [1, true], 366 | '1 str' => ['1', true], 367 | 'on' => ['on', true], 368 | 'yes' => ['yes', true] 369 | ]; 370 | } 371 | 372 | /** 373 | * Call validate on an instance of Schema where the data contains unexpected parameters. 374 | * 375 | * @param int $validationBehavior One of the **Schema::FLAG_*** constants. 376 | */ 377 | protected function doValidationBehavior($validationBehavior) { 378 | $schema = Schema::parse([ 379 | 'userID:i' => 'The ID of the user.', 380 | 'name:s' => 'The username of the user.', 381 | 'email:s' => 'The email of the user.', 382 | ]); 383 | $schema->setFlags($validationBehavior); 384 | 385 | $data = [ 386 | 'userID' => 123, 387 | 'name' => 'foo', 388 | 'email' => 'user@example.com', 389 | 'admin' => true, 390 | 'role' => 'Administrator' 391 | ]; 392 | 393 | $valid = $schema->validate($data); 394 | $this->assertArrayNotHasKey('admin', $valid); 395 | $this->assertArrayNotHasKey('role', $valid); 396 | } 397 | 398 | /** 399 | * Test throwing an exception when removing unexpected parameters from validated data. 400 | */ 401 | public function testValidateException() { 402 | $this->expectException(\Garden\Schema\ValidationException::class); 403 | $this->expectExceptionCode(400); 404 | $this->expectExceptionMessage("Unexpected properties: admin, role."); 405 | try { 406 | $this->doValidationBehavior(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION); 407 | } catch (\Exception $ex) { 408 | $msg = $ex->getMessage(); 409 | throw $ex; 410 | } 411 | } 412 | 413 | /** 414 | * Test triggering a notice when removing unexpected parameters from validated data. 415 | */ 416 | public function testValidateNotice() { 417 | $this->expectNotice(); 418 | $this->doValidationBehavior(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE); 419 | } 420 | 421 | /** 422 | * Test silently removing unexpected parameters from validated data. 423 | */ 424 | public function testValidateRemove() { 425 | $this->doValidationBehavior(0); 426 | } 427 | 428 | /** 429 | * Test a custom validation class. 430 | */ 431 | public function testDifferentValidationClass() { 432 | $schema = Schema::parse([':i']); 433 | $schema->setValidationFactory(TestValidation::createFactory()); 434 | 435 | try { 436 | $schema->validate('aaa'); 437 | } catch (ValidationException $ex) { 438 | $this->assertSame('![value]: "aaa" is not a valid integer.', $ex->getMessage()); 439 | } 440 | } 441 | 442 | /** 443 | * Test allow null. 444 | * 445 | * @param string $short The short type. 446 | * @param string $long The long type. 447 | * @param mixed $sample As sample value. 448 | * @dataProvider provideNonNullTypesAndData 449 | */ 450 | public function testAllowNull($short, $long, $sample) { 451 | $schema = Schema::parse([":$short|n"]); 452 | 453 | $null = $schema->validate(null); 454 | $this->assertNull($null); 455 | 456 | $clean = $schema->validate($sample); 457 | $this->assertSame($sample, $clean); 458 | } 459 | 460 | /** 461 | * Test default values. 462 | */ 463 | public function testDefault() { 464 | $schema = Schema::parse([ 465 | 'prop:s' => ['default' => 'foo'] 466 | ]); 467 | 468 | $valid = $schema->validate([]); 469 | $this->assertSame(['prop' => 'foo'], $valid); 470 | 471 | $valid = $schema->validate([], ['sparse' => true]); 472 | $this->assertSame([], $valid); 473 | } 474 | 475 | /** 476 | * Default values for non-required fields. 477 | */ 478 | public function testDefaultNotRequired() { 479 | $schema = Schema::parse([ 480 | 'prop:s?' => ['default' => 'foo'] 481 | ]); 482 | 483 | $valid = $schema->validate([]); 484 | $this->assertSame(['prop' => 'foo'], $valid); 485 | 486 | $valid = $schema->validate([], ['sparse' => true]); 487 | $this->assertSame([], $valid); 488 | } 489 | 490 | public function testBoolFalse() { 491 | $schema = Schema::parse(['bool:b']); 492 | 493 | $valid = $schema->validate(['bool' => false]); 494 | $this->assertFalse($valid['bool']); 495 | } 496 | 497 | /** 498 | * Objects that implement **ArrayAccess** should be returned as valid copies. 499 | * 500 | * @param string $class The name of the class to test. 501 | * @dataProvider provideArrayObjectClasses 502 | */ 503 | public function testArrayObjectResult($class) { 504 | $schema = Schema::parse([':o']); 505 | 506 | $fn = function () use ($class) { 507 | $r = new $class(); 508 | $r['a'] = 1; 509 | $r['b'] = 2; 510 | 511 | return $r; 512 | }; 513 | 514 | $expected = $fn(); 515 | $valid = $schema->validate($expected); 516 | 517 | $this->assertInstanceOf($class, $valid); 518 | /* @var \ArrayObject $valid */ 519 | $this->assertNotSame($expected, $valid); 520 | $this->assertEquals($expected->getArrayCopy(), $valid->getArrayCopy()); 521 | 522 | } 523 | 524 | /** 525 | * Objects that implement **ArrayAccess** should be returned as valid copies. 526 | * 527 | * @param string $class The name of the class to test. 528 | * @dataProvider provideArrayObjectClasses 529 | */ 530 | public function testArrayObjectResultWithProperties($class) { 531 | $schema = Schema::parse(['a:i', 'b:s']); 532 | 533 | $fn = function () use ($class) { 534 | $r = new $class(); 535 | $r['a'] = 1; 536 | $r['b'] = 'foo'; 537 | 538 | return $r; 539 | }; 540 | 541 | $expected = $fn(); 542 | $valid = $schema->validate($expected); 543 | 544 | $this->assertInstanceOf($class, $valid); 545 | /* @var \ArrayObject $valid */ 546 | $this->assertNotSame($expected, $valid); 547 | $this->assertEquals($expected->getArrayCopy(), $valid->getArrayCopy()); 548 | } 549 | 550 | /** 551 | * Provide sample array access classes. 552 | * 553 | * @return array Returns a data provider array. 554 | */ 555 | public function provideArrayObjectClasses() { 556 | $r = [ 557 | [\ArrayObject::class], 558 | [CustomArrayObject::class], 559 | [CustomArray::class] 560 | ]; 561 | 562 | return array_column($r, null, 0); 563 | } 564 | 565 | /** 566 | * Old style **allowNull** fields should be converted into a union type including null. 567 | */ 568 | public function testAllowNullBC() { 569 | $sch = Schema::parse([ 570 | 'photo:s' => [ 571 | 'allowNull' => true, 572 | 'minLength' => 0, 573 | 'description' => 'Raw photo field value from the user record.' 574 | ] 575 | ]); 576 | 577 | $this->assertArrayNotHasKey('allowNull', $sch->getField('properties/photo')); 578 | $this->assertEquals(true, $sch->getField('properties/photo/nullable')); 579 | } 580 | 581 | /** 582 | * Test validation on mixed empty string null types. 583 | */ 584 | public function testStringOrNull() { 585 | $sch = new Schema([ 586 | 'type' => 'string', 587 | 'nullable' => true, 588 | ]); 589 | 590 | $r = $sch->validate(''); 591 | $this->assertSame('', $r); 592 | 593 | $r = $sch->validate(null); 594 | $this->assertSame(null, $r); 595 | } 596 | } 597 | -------------------------------------------------------------------------------- /src/Validation.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2018 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Schema; 9 | 10 | /** 11 | * A class for collecting validation errors. 12 | */ 13 | class Validation implements \JsonSerializable { 14 | /** 15 | * @var array 16 | */ 17 | private $errors = []; 18 | 19 | /** 20 | * @var string 21 | */ 22 | private $mainMessage = ''; 23 | 24 | /** 25 | * @var int 26 | */ 27 | private $mainCode = 0; 28 | 29 | /** 30 | * @var bool Whether or not fields should be translated. 31 | */ 32 | private $translateFieldNames = false; 33 | 34 | /** 35 | * Create a new `Validation` object. 36 | * 37 | * This method is meant as a convenience to be passed to `Schema::setValidationFactory()`. 38 | * 39 | * @return Validation Returns a new instance. 40 | */ 41 | public static function createValidation() { 42 | return new static(); 43 | } 44 | 45 | /** 46 | * Get or set the error status code. 47 | * 48 | * The status code is an http response code and should be of the 4xx variety. 49 | * 50 | * @return int Returns the current status code. 51 | * @deprecated 52 | */ 53 | public function getStatus(): int { 54 | trigger_error("Validation::getStatus() is deprecated. Use Validation::getCode() instead.", E_USER_DEPRECATED); 55 | return $this->getCode(); 56 | } 57 | 58 | /** 59 | * Get the error code. 60 | * 61 | * The code is an HTTP response code and should be of the 4xx variety. 62 | * 63 | * @return int Returns an error code. 64 | */ 65 | public function getCode(): int { 66 | if ($status = $this->getMainCode()) { 67 | return $status; 68 | } 69 | 70 | if ($this->isValid()) { 71 | return 200; 72 | } 73 | 74 | // There was no status so loop through the errors and look for the highest one. 75 | $max = 0; 76 | foreach ($this->getRawErrors() as $error) { 77 | if (isset($error['code']) && $error['code'] > $max) { 78 | $max = $error['code']; 79 | } 80 | } 81 | 82 | return $max ?: 400; 83 | } 84 | 85 | /** 86 | * Get the main error number. 87 | * 88 | * @return int Returns an HTTP response code or zero to indicate it should be calculated. 89 | */ 90 | public function getMainCode(): int { 91 | return $this->mainCode; 92 | } 93 | 94 | /** 95 | * Set the main error number. 96 | * 97 | * @param int $status An HTTP response code or zero. 98 | * @return $this 99 | */ 100 | public function setMainCode(int $status) { 101 | $this->mainCode = $status; 102 | return $this; 103 | } 104 | 105 | /** 106 | * Check whether or not the validation is free of errors. 107 | * 108 | * @return bool Returns true if there are no errors, false otherwise. 109 | */ 110 | public function isValid(): bool { 111 | return empty($this->errors); 112 | } 113 | 114 | /** 115 | * Gets all of the errors as a flat array. 116 | * 117 | * The errors are internally stored indexed by field. This method flattens them for final error returns. 118 | * 119 | * @return \Traversable Returns all of the errors. 120 | */ 121 | protected function getRawErrors() { 122 | foreach ($this->errors as $field => $errors) { 123 | foreach ($errors as $error) { 124 | yield $field => $error; 125 | } 126 | } 127 | } 128 | 129 | /** 130 | * Get the message for this exception. 131 | * 132 | * @return string Returns the exception message. 133 | */ 134 | public function getMessage(): string { 135 | return $this->getFullMessage(); 136 | } 137 | 138 | /** 139 | * Get the full error message separated by field. 140 | * 141 | * @return string Returns the error message. 142 | */ 143 | public function getFullMessage(): string { 144 | $paras = []; 145 | 146 | if (!empty($this->getMainMessage())) { 147 | $paras[] = $this->getMainMessage(); 148 | } elseif ($this->getErrorCount() === 0) { 149 | return ''; 150 | } 151 | 152 | if (isset($this->errors[''])) { 153 | $paras[] = $this->formatErrorList('', $this->errors['']); 154 | } 155 | 156 | foreach ($this->errors as $field => $errors) { 157 | if ($field === '') { 158 | continue; 159 | } 160 | $paras[] = $this->formatErrorList($field, $errors); 161 | } 162 | 163 | $result = implode(" ", $paras); 164 | return $result; 165 | } 166 | 167 | /** 168 | * Get the main error message. 169 | * 170 | * If set, this message will be returned as the error message. Otherwise the message will be set from individual 171 | * errors. 172 | * 173 | * @return string Returns the main message. 174 | */ 175 | public function getMainMessage() { 176 | return $this->mainMessage; 177 | } 178 | 179 | /** 180 | * Set the main error message. 181 | * 182 | * @param string $message The new message. 183 | * @param bool $translate Whether or not to translate the message. 184 | * @return $this 185 | */ 186 | public function setMainMessage(string $message, bool $translate = true) { 187 | $this->mainMessage = $translate ? $this->translate($message) : $message; 188 | return $this; 189 | } 190 | 191 | /** 192 | * Get the error count, optionally for a particular field. 193 | * 194 | * @param string|null $field The name of a field or an empty string for all errors. 195 | * @return int Returns the error count. 196 | */ 197 | public function getErrorCount($field = null) { 198 | if ($field === null) { 199 | return iterator_count($this->getRawErrors()); 200 | } elseif (empty($this->errors[$field])) { 201 | return 0; 202 | } else { 203 | return count($this->errors[$field]); 204 | } 205 | } 206 | 207 | /** 208 | * Format a field's error messages into a single string. 209 | * 210 | * @param string $field The field name. If empty, messages are treated as global. 211 | * @param array $errors The list of errors for the field. 212 | * @return string Formatted error message string. 213 | */ 214 | private function formatErrorList(string $field, array $errors): string { 215 | // Determine if this is an unnamed/general error (not tied to a specific field). 216 | $isUnnamed = empty($field); 217 | 218 | // Resolve and format the actual error messages for the field. 219 | $messages = $this->errorMessages($field, $errors); 220 | 221 | // No messages? Return an empty string. 222 | if (empty($messages)) { 223 | return ''; 224 | } 225 | 226 | // Format the field name (if named). 227 | $fieldLabel = $isUnnamed ? '' : $this->formatFieldName($field); 228 | 229 | // For unnamed fields, just return the concatenated messages. 230 | if ($isUnnamed) { 231 | return implode(" ", $messages); 232 | } 233 | 234 | // If there's only one message, omit the label prefix (by design). 235 | if (count($messages) === 1) { 236 | return sprintf('%s', $messages[0]); 237 | } 238 | 239 | // Otherwise, include the field label followed by all messages. 240 | return sprintf('%s: %s', $fieldLabel, implode(" ", $messages)); 241 | } 242 | 243 | /** 244 | * Format the name of a field. 245 | * 246 | * @param string $field The field name to format. 247 | * @return string Returns the formatted field name. 248 | */ 249 | protected function formatFieldName(string $field): string { 250 | if ($this->getTranslateFieldNames()) { 251 | return $this->translate($field); 252 | } else { 253 | return $field; 254 | } 255 | } 256 | 257 | /** 258 | * Translate a string. 259 | * 260 | * This method doesn't do any translation itself, but is meant for subclasses wanting to add translation ability to 261 | * this class. 262 | * 263 | * @param string $str The string to translate. 264 | * @return string Returns the translated string. 265 | */ 266 | protected function translate(string $str): string { 267 | if (substr($str, 0, 1) === '@') { 268 | // This is a literal string that bypasses translation. 269 | return substr($str, 1); 270 | } else { 271 | return $str; 272 | } 273 | } 274 | 275 | /** 276 | * Format an array of error messages. 277 | * 278 | * @param string $field The name of the field. 279 | * @param array $errors The errors array from a field. 280 | * @return array Returns the error array. 281 | */ 282 | private function errorMessages(string $field, array $errors): array { 283 | $messages = []; 284 | 285 | foreach ($errors as $error) { 286 | $messages[] = $this->formatErrorMessage($error + ['field' => $field]); 287 | } 288 | return $messages; 289 | } 290 | 291 | /** 292 | * Whether or not fields should be translated. 293 | * 294 | * @return bool Returns **true** if field names are translated or **false** otherwise. 295 | */ 296 | public function getTranslateFieldNames() { 297 | return $this->translateFieldNames; 298 | } 299 | 300 | /** 301 | * Set whether or not fields should be translated. 302 | * 303 | * @param bool $translate Whether or not fields should be translated. 304 | * @return $this 305 | */ 306 | public function setTranslateFieldNames($translate) { 307 | $this->translateFieldNames = $translate; 308 | return $this; 309 | } 310 | 311 | /** 312 | * Get the error message for an error row. 313 | * 314 | * @param array $error The error row. 315 | * @return string Returns a formatted/translated error message. 316 | */ 317 | private function formatErrorMessage(array $error) { 318 | if (isset($error['messageCode'])) { 319 | $messageCode = $error['messageCode']; 320 | } elseif (isset($error['message'])) { 321 | return $error['message']; 322 | } else { 323 | $messageCode = $error['error']; 324 | } 325 | 326 | // Massage the field name for better formatting. 327 | $msg = $this->formatMessage($messageCode, $error); 328 | return $msg; 329 | } 330 | 331 | /** 332 | * Expand and translate a message format against an array of values. 333 | * 334 | * @param string $format The message format. 335 | * @param array $context The context arguments to apply to the message. 336 | * @return string Returns a formatted string. 337 | */ 338 | private function formatMessage($format, $context = []) { 339 | $format = $this->translate($format); 340 | 341 | $msg = preg_replace_callback('`({[^{}]+})`', function ($m) use ($context) { 342 | $args = array_filter(array_map('trim', explode(',', trim($m[1], '{}')))); 343 | $field = array_shift($args); 344 | 345 | switch ($field) { 346 | case 'value': 347 | return $this->formatValue($context[$field] ?? null); 348 | case 'field': 349 | $field = $context['field'] ?: 'value'; 350 | return $this->formatFieldName($field); 351 | default: 352 | return $this->formatField(isset($context[$field]) ? $context[$field] : null, $args); 353 | } 354 | }, $format); 355 | return $msg; 356 | } 357 | 358 | /** 359 | * Format a value for output in a message. 360 | * 361 | * @param mixed $value The value to format. 362 | * @return string Returns the formatted value. 363 | */ 364 | protected function formatValue($value): string { 365 | if (is_string($value) && mb_strlen($value) > 20) { 366 | $value = mb_substr($value, 0, 20).'…'; 367 | } 368 | 369 | if (is_scalar($value)) { 370 | return json_encode($value); 371 | } else { 372 | return $this->translate('value'); 373 | } 374 | } 375 | 376 | /** 377 | * Translate an argument being placed in an error message. 378 | * 379 | * @param mixed $value The argument to translate. 380 | * @param array $args Formatting arguments. 381 | * @return string Returns the translated string. 382 | */ 383 | private function formatField($value, array $args = []) { 384 | if ($value === null) { 385 | $r = $this->translate('null'); 386 | } elseif ($value === true) { 387 | $r = $this->translate('true'); 388 | } elseif ($value === false) { 389 | $r = $this->translate('false'); 390 | } elseif (is_string($value)) { 391 | $r = $this->translate($value); 392 | } elseif (is_numeric($value)) { 393 | $r = $value; 394 | } elseif (is_array($value)) { 395 | $argArray = array_map([$this, 'formatField'], $value); 396 | $r = implode(', ', $argArray); 397 | } elseif ($value instanceof \DateTimeInterface) { 398 | $r = $value->format('c'); 399 | } else { 400 | $r = $value; 401 | } 402 | 403 | $format = array_shift($args); 404 | switch ($format) { 405 | case 'plural': 406 | $singular = array_shift($args); 407 | $plural = array_shift($args) ?: $singular.'s'; 408 | $count = is_array($value) ? count($value) : $value; 409 | $r = $count == 1 ? $singular : $plural; 410 | break; 411 | } 412 | 413 | return (string)$r; 414 | } 415 | 416 | /** 417 | * Gets all of the errors as a flat array. 418 | * 419 | * The errors are internally stored indexed by field. This method flattens them for final error returns. 420 | * 421 | * @return array Returns all of the errors. 422 | */ 423 | public function getErrors(): array { 424 | $result = []; 425 | foreach ($this->getRawErrors() as $field => $error) { 426 | $result[] = $this->pluckError(['field' => $field] + $error); 427 | } 428 | return $result; 429 | } 430 | 431 | /** 432 | * Format a raw error row for consumption. 433 | * 434 | * @param array $error The error to format. 435 | * @return array Returns the error stripped of default values. 436 | */ 437 | private function pluckError(array $error) { 438 | $row = array_intersect_key( 439 | $error, 440 | ['field' => 1, 'status' => 1] 441 | ); 442 | 443 | // Map 'error' to 'code' for the output, and also keep 'error' for OpenAPI schema compatibility 444 | if (isset($error['error'])) { 445 | $row['code'] = $error['error']; // This will be the error type (string) 446 | $row['error'] = $error['error']; // Keep for OpenAPI schema compatibility 447 | } 448 | 449 | // Ensure 'code' field contains the HTTP status code (integer) 450 | if (isset($error['code']) && is_numeric($error['code'])) { 451 | $row['code'] = $error['code']; // Override with numeric HTTP status code 452 | } 453 | 454 | $row['message'] = $this->formatErrorMessage($error); 455 | return $row; 456 | } 457 | 458 | /** 459 | * Get the errors for a specific field. 460 | * 461 | * @param string $field The full path to the field. 462 | * @return array Returns an array of errors. 463 | */ 464 | public function getFieldErrors(string $field): array { 465 | if (empty($this->errors[$field])) { 466 | return []; 467 | } else { 468 | $result = []; 469 | foreach ($this->errors[$field] as $error) { 470 | $result[] = $this->pluckError($error + ['field' => $field]); 471 | } 472 | return $result; 473 | } 474 | } 475 | 476 | /** 477 | * Check whether or not a particular field is has errors. 478 | * 479 | * @param string $field The name of the field to check for validity. 480 | * @return bool Returns true if the field has no errors, false otherwise. 481 | */ 482 | public function isValidField(string $field): bool { 483 | $result = empty($this->errors[$field]); 484 | return $result; 485 | } 486 | 487 | /** 488 | * Merge another validation object with this one. 489 | * 490 | * @param Validation $validation The validation object to merge. 491 | * @param string $name The path to merge to. Use this parameter when the validation object is meant to be a subset of this one. 492 | * @return $this 493 | */ 494 | public function merge(Validation $validation, $name = '') { 495 | $paths = $validation->errors; 496 | 497 | foreach ($paths as $path => $errors) { 498 | foreach ($errors as $error) { 499 | if (strlen($name) > 0) { 500 | // We are merging a sub-schema error that did not occur on a particular property of the sub-schema. 501 | if ($path === '') { 502 | $fullPath = $name; 503 | } else { 504 | $fullPath = "{$name}/{$path}"; 505 | } 506 | $this->addError($fullPath, $error['error'], $error); 507 | } 508 | } 509 | } 510 | return $this; 511 | } 512 | 513 | /** 514 | * Add an error. 515 | * 516 | * @param string $field The name and path of the field to add or an empty string if this is a global error. 517 | * @param string $error The message code. 518 | * @param array $options An array of additional information to add to the error entry or a numeric error code. 519 | * 520 | * - **messageCode**: A specific message translation code for the final error. 521 | * - **number**: An error number for the error. 522 | * - Error specific fields can be added to format a custom error message. 523 | * @return $this 524 | */ 525 | public function addError(string $field, string $error, $options = []) { 526 | if (empty($error)) { 527 | throw new \InvalidArgumentException('The error code cannot be empty.', 500); 528 | } elseif (!in_array(gettype($options), ['integer', 'array'], true)) { 529 | throw new \InvalidArgumentException('$options must be an integer or array.', 500); 530 | } 531 | if (is_int($options)) { 532 | trigger_error('Passing an integer for $options in Validation::addError() is deprecated.', E_USER_DEPRECATED); 533 | $options = ['code' => $options]; 534 | } elseif (isset($options['status'])) { 535 | trigger_error('Validation::addError() expects $options[\'number\'], not $options[\'status\'].', E_USER_DEPRECATED); 536 | $options['code'] = $options['status']; 537 | unset($options['status']); 538 | } 539 | 540 | $row = ['error' => $error] + $options; 541 | $this->errors[$field][] = $row; 542 | 543 | return $this; 544 | } 545 | 546 | /** 547 | * Get the main error number. 548 | * 549 | * @return int Returns an HTTP response code or zero to indicate it should be calculated. 550 | * @deprecated 551 | */ 552 | public function getMainStatus(): int { 553 | trigger_error("Validation::getMainStatus() is deprecated. Use Validation::getMainCode() instead.", E_USER_DEPRECATED); 554 | return $this->mainCode; 555 | } 556 | 557 | /** 558 | * Set the main error number. 559 | * 560 | * @param int $status An HTTP response code or zero. 561 | * @return $this 562 | * @deprecated 563 | */ 564 | public function setMainStatus(int $status) { 565 | trigger_error("Validation::setMainStatus() is deprecated. Use Validation::getMainCode() instead.", E_USER_DEPRECATED); 566 | $this->mainCode = $status; 567 | return $this; 568 | } 569 | 570 | /** 571 | * Generate a global error string by concatenating field errors. 572 | * 573 | * @param string|null $field The name of a field to concatenate errors for. 574 | * @param string $sep The error message separator. 575 | * @param bool $punctuate Whether or not to automatically add punctuation to errors if they don't have it already. 576 | * @return string Returns an error message. 577 | */ 578 | public function getConcatMessage($field = null, string $sep = ' ', bool $punctuate = true): string { 579 | $sentence = $this->translate('%s.'); 580 | 581 | $errors = $field === null ? $this->getRawErrors() : ($this->errors[$field] ?? []); 582 | 583 | // Generate the message by concatenating all of the errors together. 584 | $messages = []; 585 | foreach ($errors as $field => $error) { 586 | $message = $this->formatErrorMessage($error + ['field' => $field]); 587 | if ($punctuate && preg_match('`\PP$`u', $message)) { 588 | $message = sprintf($sentence, $message); 589 | } 590 | $messages[] = $message; 591 | } 592 | return implode($sep, $messages); 593 | } 594 | 595 | /** 596 | * Specify data which should be serialized to JSON. 597 | * 598 | * @return mixed Data which can be serialized by json_encode, 599 | * which is a value of any type other than a resource. 600 | * @link http://php.net/manual/en/jsonserializable.jsonserialize.php 601 | */ 602 | #[\ReturnTypeWillChange] 603 | public function jsonSerialize() { 604 | $errors = []; 605 | 606 | foreach ($this->getRawErrors() as $field => $error) { 607 | $errors[] = $this->pluckError($error + ['field' => $field, 'status' => 400]); 608 | } 609 | 610 | $result = [ 611 | 'message' => $this->getSummaryMessage(), 612 | 'code' => $this->getCode(), 613 | 'errors' => $errors, 614 | ]; 615 | return $result; 616 | } 617 | 618 | /** 619 | * Get just the summary message for the validation. 620 | * 621 | * @return string Returns the message. 622 | */ 623 | public function getSummaryMessage(): string { 624 | if ($main = $this->getMainMessage()) { 625 | return $main; 626 | } elseif ($this->isValid()) { 627 | return $this->translate('Validation succeeded.'); 628 | } else { 629 | return $this->translate('Validation failed.'); 630 | } 631 | } 632 | } 633 | --------------------------------------------------------------------------------