├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src └── Validator.php └── tests ├── JsonTypeTest.php └── autoload.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | composer.lock 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 RethinkPHP 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON Validator 2 | 3 | A JSON Validator that designed to be elegant and easy to use. 4 | 5 | ## Motivation 6 | 7 | JSON Validation is a common task in automated API testing, JSON-Schema is complex and not easy to use, so i 8 | created this library to simplify the JSON validation process and made JSON validation more elegant and fun. 9 | 10 | ## Features 11 | 12 | * JSON Schema validation, useful for automated API testing 13 | * Custom type support, it is possible to define your custom types and reuse it everywhere 14 | * Nullable type support 15 | * More is coming... 16 | 17 | 18 | ## Installation 19 | 20 | You can install the latest version of JSON validator with the following command: 21 | 22 | ```bash 23 | composer require rethink/json-validator:dev-master 24 | ``` 25 | 26 | ## Documentation 27 | 28 | ### Types 29 | 30 | By default, JSON Validator shipped with seven kinds of built-in types: 31 | 32 | - integer 33 | - double 34 | - boolean 35 | - string 36 | - number 37 | - array 38 | - object 39 | 40 | Besides the built-in types, it is possible to define your custom type via `defineType()` method. 41 | 42 | The following code snippets shows how we can define custom types through array or callable. 43 | 44 | #### 1. Define a composite type 45 | 46 | ```php 47 | $validator->defineType('User', [ 48 | 'name' => 'string', 49 | 'gender' => 'string', 50 | 'age' => '?integer', 51 | 'rating' => '?integer|boolean', 52 | ]); 53 | ``` 54 | 55 | This example defines a custom type named `User`, which have four properties. name and gender require be a 56 | string, age requires be an integer but allows to be nullable, and rating required to integer or boolean and allows to be null. 57 | 58 | #### 2. Define a list type 59 | 60 | ```php 61 | $validator->defineType('UserCollection', ['User']); 62 | ``` 63 | 64 | This defines `UserCollection` to be an array of `User`. In order to define a list type, the definition of the type much 65 | contains only one element. 66 | 67 | 68 | #### 3. Define a type in callable 69 | 70 | ```php 71 | $validator->defineType('timestamp', function ($value) { 72 | if ((!is_string($value) && !is_numeric($value)) || strtotime($value) === false) { 73 | return false; 74 | } 75 | 76 | $date = date_parse($value); 77 | 78 | return checkdate($date['month'], $date['day'], $date['year']); 79 | }); 80 | ``` 81 | 82 | It is also possible to define a type using a callable, which is useful to perform some validation on the data. Such as 83 | the example above defined a timestamp type, that requires the data to be a valid datetime. 84 | 85 | ### Validate a Type 86 | 87 | We can validate a type by the following two steps: 88 | 89 | #### 1. Create a Validator instance 90 | 91 | ```php 92 | use rethink\jsv\Validator; 93 | 94 | $validator = new Validator(); 95 | // $validator->defineType(...) Add your custom type if necessary 96 | ``` 97 | 98 | #### 2. Preform the validation 99 | 100 | ```php 101 | $matched = $validator->matches($data, 'User'); 102 | if ($matched) { 103 | // Validation passed 104 | } else { 105 | $errors = $validator->getErrors(); 106 | } 107 | ``` 108 | 109 | This example will check whether the given `$data` matches the type `User`, if validation fails, we can get the error 110 | messages through `getErrors()` method. 111 | 112 | 113 | ### Strict Mode 114 | 115 | In some situations, we may want an object matches our type strictly, we can utilizing `strict mode` to achieve this, 116 | the following is the example: 117 | 118 | ```php 119 | $data = [ 120 | 'name' => 'Bob', 121 | 'gender' => 'Male', 122 | 'age' => 19, 123 | 'phone' => null, // This property is unnecessary 124 | ]; 125 | $matched = $validator->matches($data, 'User', true); // strict mode is turned on 126 | var_dump($matched); // false is returned 127 | ``` 128 | 129 | 130 | ## Related Projects 131 | 132 | * [Blink Framework](https://github.com/bixuehujin/blink) - A high performance web framework and application server in PHP. Edit 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "rethink/json-validator", 3 | "description": "A json validator", 4 | "keywords": ["json"], 5 | "license":"MIT", 6 | "authors" : [ 7 | { 8 | "name" : "Jin Hu", 9 | "email": "bixuehujin@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.0.0" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "~5.0" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "rethink\\jsv\\": "src", 21 | "rethink\\jsv\\tests\\": "tests" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | ./tests/ 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Validator.php: -------------------------------------------------------------------------------- 1 | types = $this->getBuiltInTypeValidators(); 21 | } 22 | 23 | protected function builtInTypes() 24 | { 25 | return [ 26 | 'integer' => 'is_integer', 27 | 'double' => 'is_double', 28 | 'boolean' => 'is_bool', 29 | 'string' => 'is_string', 30 | 'null' => 'is_null', 31 | 'number' => [$this, 'isNumber'], 32 | 'array' => [$this, 'isArray'], 33 | 'object' => [$this, 'isObject'], 34 | ]; 35 | } 36 | 37 | protected function getBuiltInTypeValidators() 38 | { 39 | $types = $this->builtInTypes(); 40 | 41 | $validators = []; 42 | foreach ($types as $type => $func) { 43 | $validators[$type] = $this->createValidator($type, $func); 44 | } 45 | 46 | return $validators; 47 | } 48 | 49 | protected function createValidator($type, callable $callable) 50 | { 51 | return function ($value) use ($callable, $type) { 52 | if ($callable($value)) { 53 | return true; 54 | } 55 | 56 | $givenType = $this->getType($value); 57 | 58 | $path = $this->getNormalizedPath(); 59 | 60 | $this->addError($path, "The path of '$path' requires to be a $type, $givenType is given"); 61 | 62 | return false; 63 | }; 64 | } 65 | 66 | protected function isNumber($data) 67 | { 68 | return is_integer($data) || is_float($data); 69 | } 70 | 71 | protected function isArray($data) 72 | { 73 | return is_array($data) && (empty($data) || array_keys($data) === range(0, count($data) - 1)); 74 | } 75 | 76 | protected function isObject($data) 77 | { 78 | return is_object($data) || (is_array($data) && !empty($data) && array_keys($data) !== range(0, count($data) - 1)); 79 | } 80 | 81 | protected function getType($value) 82 | { 83 | if (is_null($value)) { 84 | return 'null'; 85 | } else if (is_scalar($value)) { 86 | return gettype($value); 87 | } else if ($this->isArray($value)) { 88 | return 'array'; 89 | } else { 90 | return 'object'; 91 | } 92 | } 93 | 94 | /** 95 | * Define a new type. 96 | * 97 | * @param string $type 98 | * @param mixed $definition 99 | */ 100 | public function defineType(string $type, $definition) 101 | { 102 | if (isset($this->types[$type])) { 103 | throw new \InvalidArgumentException("The type: $type is already exists"); 104 | } 105 | 106 | $this->types[$type] = $this->buildTypeValidator($type, $definition); 107 | } 108 | 109 | protected $errors = []; 110 | protected $path = ['$']; 111 | 112 | protected function getNormalizedPath() 113 | { 114 | return strtr(implode('.', $this->path), [ 115 | '.[' => '[', 116 | ]); 117 | } 118 | 119 | public function getErrors() 120 | { 121 | return $this->errors; 122 | } 123 | 124 | public function addError($key, $message) 125 | { 126 | $this->errors[$key] = $message; 127 | } 128 | 129 | protected function reset() 130 | { 131 | $this->errors = []; 132 | } 133 | 134 | /** 135 | * Return whether the data match the given type. 136 | * 137 | * @param $data 138 | * @param $type 139 | * @param $strict 140 | * @return bool 141 | */ 142 | public function matches($data, $type, $strict = false) 143 | { 144 | $this->reset(); 145 | 146 | $this->strict = $strict; 147 | 148 | return $this->matchInternal($data, $type); 149 | } 150 | 151 | protected function buildTypeValidator($type, $definition) 152 | { 153 | if (is_callable($definition)) { 154 | $validator = $this->createValidator($type, $definition); 155 | } else if ($this->isObject($definition)) { 156 | $validator = function ($data) use ($definition) { 157 | return $this->matchObject($data, $definition); 158 | }; 159 | } else if ($this->isArray($definition)) { 160 | $validator = function ($data) use ($definition) { 161 | return $this->matchArray($data, $definition); 162 | }; 163 | } 164 | 165 | return $validator; 166 | } 167 | 168 | protected function matchArray(array $data, $definition) 169 | { 170 | $definition = $definition[0]; 171 | 172 | foreach ($data as $index => $row) { 173 | array_push($this->path, '[' . $index . ']'); 174 | $result = $this->matchInternal($row, $definition); 175 | array_pop($this->path); 176 | 177 | if (!$result) { 178 | return false; 179 | } 180 | } 181 | 182 | return true; 183 | } 184 | 185 | protected function matchObject($data, $definition) 186 | { 187 | if (is_object($data)) { 188 | $data = (array)$data; 189 | } 190 | 191 | if ($this->strict && !$this->matchObjectKeys($data, $definition)) { 192 | return false; 193 | } 194 | 195 | $hasErrors = false; 196 | foreach ($definition as $name => $type) { 197 | array_push($this->path, $name); 198 | $result = $this->matchInternal($data[$name] ?? null, $type); 199 | array_pop($this->path); 200 | 201 | if (!$result) { 202 | $hasErrors = true; 203 | } 204 | } 205 | 206 | return !$hasErrors; 207 | } 208 | 209 | protected function matchObjectKeys($data, $definition) 210 | { 211 | $requiredKeys = array_keys($definition); 212 | $providedKeys = array_keys($data); 213 | 214 | sort($requiredKeys); 215 | sort($providedKeys); 216 | 217 | if ($requiredKeys === $providedKeys) { 218 | return true; 219 | } 220 | 221 | $absenceKeys = array_diff($requiredKeys, $providedKeys); 222 | $notRequiredKeys = array_diff($providedKeys, $requiredKeys); 223 | 224 | $message = "The object keys doesn't match the type definition"; 225 | 226 | if ($absenceKeys) { 227 | $absenceKeys = implode(',', $absenceKeys); 228 | $message .= ": '$absenceKeys' are absent"; 229 | } 230 | 231 | if ($notRequiredKeys) { 232 | $notRequiredKeys = implode(',', $notRequiredKeys); 233 | $message .= ": '$notRequiredKeys' are not required"; 234 | } 235 | 236 | $this->addError($this->getNormalizedPath(), $message); 237 | 238 | return false; 239 | } 240 | 241 | protected function matchInternal($data, $type) 242 | { 243 | if (is_string($type) && $type[0] === '?') { 244 | if ($data === null) { 245 | return true; 246 | } 247 | 248 | $type = substr($type, 1); 249 | } 250 | 251 | if (is_string($type) && strpos($type, '|') !== false) { 252 | $option = explode('|', $type); 253 | 254 | if (in_array($this->getType($data), $option)) { 255 | $type = $this->getType($data); 256 | } else { 257 | foreach ($option as $key) { 258 | $givenType = $this->getType($data); 259 | $path = $this->getNormalizedPath(); 260 | 261 | $this->addError($path, "The path of '$path' requires to be a $type, $givenType is given"); 262 | return false; 263 | } 264 | } 265 | } 266 | 267 | return $this->matchInternalProcess($data, $type); 268 | } 269 | 270 | protected function matchInternalProcess($data, $type) 271 | { 272 | if (!is_string($type)) { 273 | $definition = $type; 274 | } else if (isset($this->types[$type])) { 275 | $definition = $this->types[$type]; 276 | } else { 277 | throw new \InvalidArgumentException('The definition can not be recognized'); 278 | } 279 | 280 | if (is_callable($definition)) { 281 | return $definition($data); 282 | } else if ($this->isObject($definition)) { 283 | return $this->matchObject($data, $definition); 284 | } else { 285 | return $this->matchArray($data, $definition); 286 | } 287 | } 288 | } -------------------------------------------------------------------------------- /tests/JsonTypeTest.php: -------------------------------------------------------------------------------- 1 | defineType('timestamp', $this->timestampValidator()); 20 | 21 | return $validator; 22 | } 23 | 24 | public function basicTypes() 25 | { 26 | return [ 27 | ['string', 'foobar', true], 28 | ['string', 123, false], 29 | ['integer', 123, true], 30 | ['double', 123.1, true], 31 | 32 | ['number', 123.1, true], 33 | ['number', 234, true], 34 | ['number', '234', false], 35 | 36 | ['array', [], true], 37 | ['array', [1, 2, 3], true], 38 | ['array', [1 => 1, 2, 3], false], 39 | 40 | ['object', [1 => 1, 2, 3], true], 41 | ['object', [], false], 42 | ['object', ['a' => 'b'], true], 43 | ]; 44 | } 45 | 46 | /** 47 | * @dataProvider basicTypes 48 | */ 49 | public function testMatchBasicTypes($type, $data, $result) 50 | { 51 | $json = $this->createValidator(); 52 | 53 | $method = $result ? 'assertTrue' : 'assertFalse'; 54 | 55 | $this->$method($json->matches($data, $type)); 56 | } 57 | 58 | protected function userType() 59 | { 60 | return [ 61 | 'name' => 'string', 62 | 'gender' => 'string', 63 | 'age' => 'integer', 64 | 'tags' => ['string'], 65 | 'rating' => ['?integer|double'], 66 | ]; 67 | } 68 | 69 | protected function userData() 70 | { 71 | return [ 72 | 'name' => 'John', 73 | 'gender' => 'Male', 74 | 'age' => 18, 75 | 'tags' => ['Foo', 'Bar'], 76 | 'rating' => [null, 1, 2.5], 77 | ]; 78 | } 79 | 80 | public function testMatchCustomType() 81 | { 82 | $json = $this->createValidator(); 83 | 84 | $json->defineType('user', $this->userType()); 85 | 86 | $this->assertTrue($json->matches($this->userData(), 'user')); 87 | } 88 | 89 | public function testMatchArrayOfCustomType() 90 | { 91 | $json = $this->createValidator(); 92 | 93 | $json->defineType('user', $this->userType()); 94 | 95 | $this->assertTrue($json->matches([$this->userData()], ['user'])); 96 | } 97 | 98 | protected function timestampValidator() 99 | { 100 | return function ($value) { 101 | if ((!is_string($value) && !is_numeric($value)) || strtotime($value) === false) { 102 | return false; 103 | } 104 | 105 | $date = date_parse($value); 106 | 107 | return checkdate($date['month'], $date['day'], $date['year']); 108 | }; 109 | } 110 | 111 | public function testAddCustomTypeThroughCallable() 112 | { 113 | $json = $this->createValidator(); 114 | 115 | 116 | $this->assertTrue($json->matches('2017-01-01 00:00:00', 'timestamp')); 117 | $this->assertFalse($json->matches('2017-01-91 00:00:00', 'timestamp')); 118 | } 119 | 120 | public function typeErrorMessages() 121 | { 122 | return [ 123 | // Basic Types 124 | [ 125 | 123, 126 | 'string', 127 | '$', 128 | "The path of '$' requires to be a string, integer is given", 129 | ], 130 | [ 131 | [], 132 | 'object', 133 | '$', 134 | "The path of '$' requires to be a object, array is given", 135 | ], 136 | [ 137 | new \stdClass(), 138 | 'array', 139 | '$', 140 | "The path of '$' requires to be a array, object is given", 141 | ], 142 | [ 143 | ['a' => 'b'], 144 | 'array', 145 | '$', 146 | "The path of '$' requires to be a array, object is given", 147 | ], 148 | [ 149 | null, 150 | 'string', 151 | '$', 152 | "The path of '$' requires to be a string, null is given", 153 | ], 154 | 155 | // Complex Types 156 | [ 157 | ['foo' => 123], 158 | ['foo' => 'string'], 159 | '$.foo', 160 | "The path of '$.foo' requires to be a string, integer is given", 161 | ], 162 | [ 163 | [1, 2, 3], 164 | ['string'], 165 | '$[0]', 166 | "The path of '$[0]' requires to be a string, integer is given", 167 | ], 168 | [ 169 | [ 170 | 'foo' => [3, 2, 1], 171 | ], 172 | [ 173 | 'foo' => ['string'], 174 | ], 175 | '$.foo[0]', 176 | "The path of '$.foo[0]' requires to be a string, integer is given", 177 | ], 178 | [ 179 | [ 180 | 'foo' => [3, 2, 1], 181 | ], 182 | [ 183 | 'foo' => ['string|double'], 184 | ], 185 | '$.foo[0]', 186 | "The path of '$.foo[0]' requires to be a string|double, integer is given", 187 | ], 188 | 189 | // Custom Types 190 | [ 191 | 'foo', 192 | 'timestamp', 193 | '$', 194 | "The path of '$' requires to be a timestamp, string is given", 195 | ], 196 | [ 197 | ['foo' => 'bar'], 198 | ['foo' => 'timestamp'], 199 | '$.foo', 200 | "The path of '$.foo' requires to be a timestamp, string is given", 201 | ], 202 | 203 | ]; 204 | } 205 | 206 | /** 207 | * @dataProvider typeErrorMessages 208 | */ 209 | public function testErrorMessages($value, $type, $key, $message) 210 | { 211 | $json = $this->createValidator(); 212 | 213 | $this->assertFalse($json->matches($value, $type)); 214 | $this->assertEquals($message, $json->getErrors()[$key] ?? ''); 215 | } 216 | 217 | public function typeStrictData() 218 | { 219 | return [ 220 | [ 221 | ['key1' => 'v1', 'key3' => 'v3'], 222 | [ 223 | 'key1' => 'string', 224 | 'key2' => 'string', 225 | ], 226 | '$', 227 | "The object keys doesn't match the type definition: 'key2' are absent: 'key3' are not required", 228 | ] 229 | ]; 230 | } 231 | 232 | /** 233 | * @dataProvider typeStrictData 234 | */ 235 | public function testStrictMode($value, $type, $key, $message) 236 | { 237 | $json = $this->createValidator(); 238 | 239 | $this->assertFalse($json->matches($value, $type, true)); 240 | $this->assertEquals($message, $json->getErrors()[$key] ?? ''); 241 | } 242 | 243 | public function typeNullableData() 244 | { 245 | return [ 246 | [ 247 | [ 248 | 'name' => 'foo', 249 | 'gender' => null, 250 | ], 251 | [ 252 | 'name' => 'string', 253 | 'gender' => '?string', 254 | ], 255 | true, 256 | ], 257 | [ 258 | ['foo', 'bar', null], 259 | ['?string'], 260 | true, 261 | ], 262 | [ 263 | [ 264 | 'dt' => null, 265 | ], 266 | [ 267 | 'dt' => '?timestamp', 268 | ], 269 | true, 270 | ], 271 | [ 272 | 'foo', 273 | '?integer', 274 | false, 275 | '$', 276 | 'The path of \'$\' requires to be a integer, string is given', 277 | ] 278 | ]; 279 | } 280 | 281 | /** 282 | * @dataProvider typeNullableData 283 | */ 284 | public function testNullableTypes($value, $type, $matched, $key = null, $message = null) 285 | { 286 | $json = $this->createValidator(true); 287 | 288 | $method = $matched ? 'assertTrue' : 'assertFalse'; 289 | $this->$method($json->matches($value, $type)); 290 | 291 | if (!$matched) { 292 | $this->assertEquals($message, $json->getErrors()[$key] ?? null); 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /tests/autoload.php: -------------------------------------------------------------------------------- 1 |