From c5d6f1e803736928b45715bb2925f015f7dbda7d Mon Sep 17 00:00:00 2001 From: George Steel Date: Thu, 26 Jan 2023 15:04:10 +0000 Subject: [PATCH 1/4] Introduce `IsJsonString` validator Signed-off-by: George Steel --- src/IsJsonString.php | 152 +++++++++++++++++++++++++++++++++ src/ValidatorPluginManager.php | 1 + test/IsJsonStringTest.php | 137 +++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 src/IsJsonString.php create mode 100644 test/IsJsonStringTest.php diff --git a/src/IsJsonString.php b/src/IsJsonString.php new file mode 100644 index 000000000..b4f008fbd --- /dev/null +++ b/src/IsJsonString.php @@ -0,0 +1,152 @@ +, + * maxDepth: positive-int, + * } + * @psalm-import-type AbstractOptions from AbstractValidator + * @psalm-type Options = AbstractOptions|CustomOptions + */ +final class IsJsonString extends AbstractValidator +{ + public const ERROR_NOT_STRING = 'errorNotString'; + public const ERROR_TYPE_NOT_ALLOWED = 'errorTypeNotAllowed'; + public const ERROR_MAX_DEPTH_EXCEEDED = 'errorMaxDepthExceeded'; + public const ERROR_INVALID_JSON = 'errorInvalidJson'; + + public const ALLOW_INT = 0b0000001; + public const ALLOW_FLOAT = 0b0000010; + public const ALLOW_BOOL = 0b0000100; + public const ALLOW_ARRAY = 0b0001000; + public const ALLOW_OBJECT = 0b0010000; + public const ALLOW_ALL = 0b0011111; + + /** @var array */ + protected $messageTemplates = [ + self::ERROR_NOT_STRING => 'Expected a string but %type% was received', + self::ERROR_TYPE_NOT_ALLOWED => 'Received a JSON %type% but this type is not acceptable', + self::ERROR_MAX_DEPTH_EXCEEDED => 'The decoded JSON payload exceeds the allowed depth of %maxDepth%', + self::ERROR_INVALID_JSON => 'An invalid JSON payload was received', + ]; + + /** @var array */ + protected $messageVariables = [ + 'type' => 'type', + 'maxDepth' => 'maxDepth', + ]; + + protected ?string $type = null; + /** @var int-mask-of */ + protected int $allow = self::ALLOW_ALL; + /** @var positive-int */ + protected int $maxDepth = 512; + + /** @param int-mask-of $type */ + public function setAllow(int $type): void + { + $this->allow = $type; + } + + /** @param positive-int $maxDepth */ + public function setMaxDepth(int $maxDepth): void + { + $this->maxDepth = $maxDepth; + } + + public function isValid(mixed $value): bool + { + if (! is_string($value)) { + $this->error(self::ERROR_NOT_STRING); + $this->type = gettype($value); + + return false; + } + + if (is_numeric($value)) { + /** @psalm-var mixed $value */ + $value = json_decode($value); + + if (is_int($value) && ! $this->isAllowed(self::ALLOW_INT)) { + $this->error(self::ERROR_TYPE_NOT_ALLOWED); + $this->type = 'int'; + + return false; + } + + if (is_float($value) && ! $this->isAllowed(self::ALLOW_FLOAT)) { + $this->error(self::ERROR_TYPE_NOT_ALLOWED); + $this->type = 'float'; + + return false; + } + + return true; + } + + if ($value === 'true' || $value === 'false') { + if (! $this->isAllowed(self::ALLOW_BOOL)) { + $this->error(self::ERROR_TYPE_NOT_ALLOWED); + $this->type = 'boolean'; + + return false; + } + + return true; + } + + if (str_starts_with($value, '[') && ! $this->isAllowed(self::ALLOW_ARRAY)) { + $this->error(self::ERROR_TYPE_NOT_ALLOWED); + $this->type = 'array'; + + return false; + } + + if (str_starts_with($value, '{') && ! $this->isAllowed(self::ALLOW_OBJECT)) { + $this->error(self::ERROR_TYPE_NOT_ALLOWED); + $this->type = 'object'; + + return false; + } + + try { + /** @psalm-suppress UnusedFunctionCall */ + json_decode($value, true, $this->maxDepth, JSON_THROW_ON_ERROR); + + return true; + } catch (JsonException $e) { + if ($e->getCode() === JSON_ERROR_DEPTH) { + $this->error(self::ERROR_MAX_DEPTH_EXCEEDED); + + return false; + } + + $this->error(self::ERROR_INVALID_JSON); + + return false; + } + } + + /** @param self::ALLOW_* $flag */ + private function isAllowed(int $flag): bool + { + return ($this->allow & $flag) === $flag; + } +} diff --git a/src/ValidatorPluginManager.php b/src/ValidatorPluginManager.php index 0cae3baef..612680d8e 100644 --- a/src/ValidatorPluginManager.php +++ b/src/ValidatorPluginManager.php @@ -420,6 +420,7 @@ class ValidatorPluginManager extends AbstractPluginManager Isbn::class => InvokableFactory::class, IsCountable::class => InvokableFactory::class, IsInstanceOf::class => InvokableFactory::class, + IsJsonString::class => InvokableFactory::class, LessThan::class => InvokableFactory::class, NotEmpty::class => InvokableFactory::class, I18nValidator\PhoneNumber::class => InvokableFactory::class, diff --git a/test/IsJsonStringTest.php b/test/IsJsonStringTest.php new file mode 100644 index 000000000..5f8f40f65 --- /dev/null +++ b/test/IsJsonStringTest.php @@ -0,0 +1,137 @@ +, + * 1: string, + * 2: bool, + * 3: IsJsonString::ERROR_*|null, + * }> + */ + public function allowProvider(): array + { + // phpcs:disable Generic.Files.LineLength + return [ + 'Standalone Integer' => [IsJsonString::ALLOW_INT, '1', true, null], + 'Standalone Integer, Not Allowed' => [IsJsonString::ALLOW_ALL ^ IsJsonString::ALLOW_INT, '1', false, IsJsonString::ERROR_TYPE_NOT_ALLOWED], + 'Standalone Float' => [IsJsonString::ALLOW_FLOAT, '1.23', true, null], + 'Standalone Float, Not Allowed' => [IsJsonString::ALLOW_ALL ^ IsJsonString::ALLOW_FLOAT, '1.23', false, IsJsonString::ERROR_TYPE_NOT_ALLOWED], + 'Standalone True' => [IsJsonString::ALLOW_BOOL, 'true', true, null], + 'Standalone False' => [IsJsonString::ALLOW_BOOL, 'false', true, null], + 'Case Sensitive True' => [IsJsonString::ALLOW_BOOL, 'TRUE', false, IsJsonString::ERROR_INVALID_JSON], + 'Case Sensitive False' => [IsJsonString::ALLOW_BOOL, 'FALSE', false, IsJsonString::ERROR_INVALID_JSON], + 'Standalone True, Not Allowed' => [IsJsonString::ALLOW_ALL ^ IsJsonString::ALLOW_BOOL, 'true', false, IsJsonString::ERROR_TYPE_NOT_ALLOWED], + 'Standalone False, Not Allowed' => [IsJsonString::ALLOW_ALL ^ IsJsonString::ALLOW_BOOL, 'false', false, IsJsonString::ERROR_TYPE_NOT_ALLOWED], + 'List Notation' => [IsJsonString::ALLOW_ARRAY, '["Some","List"]', true, null], + 'List Notation, Not Allowed' => [IsJsonString::ALLOW_ALL ^ IsJsonString::ALLOW_ARRAY, '["Some","List"]', false, IsJsonString::ERROR_TYPE_NOT_ALLOWED], + 'Object Notation' => [IsJsonString::ALLOW_OBJECT, '{"Some":"Object"}', true, null], + 'Object Notation, Not Allowed' => [IsJsonString::ALLOW_ALL ^ IsJsonString::ALLOW_OBJECT, '{"Some":"Object"}', false, IsJsonString::ERROR_TYPE_NOT_ALLOWED], + ]; + // phpcs:enable Generic.Files.LineLength + } + + /** + * @param int-mask-of $allowed + * @param IsJsonString::ERROR_*|null $expectedErrorKey + * @dataProvider allowProvider + */ + public function testBasicBehaviour(int $allowed, string $input, bool $expect, string|null $expectedErrorKey): void + { + $validator = new IsJsonString(); + $validator->setAllow($allowed); + $result = $validator->isValid($input); + self::assertSame($expect, $result); + if ($expectedErrorKey !== null) { + self::assertArrayHasKey($expectedErrorKey, $validator->getMessages()); + } + } + + /** @return list */ + public function provideThingsThatAreNotStrings(): array + { + return [ + [true], + [false], + [ + new class () { + }, + ], + [[]], + [1], + [1.23], + [null], + ]; + } + + /** @dataProvider provideThingsThatAreNotStrings */ + public function testThatNonStringsAreInvalid(mixed $input): void + { + $validator = new IsJsonString(); + self::assertFalse($validator->isValid($input)); + self::assertArrayHasKey(IsJsonString::ERROR_NOT_STRING, $validator->getMessages()); + } + + public function testThatMaxDepthCanBeExceeded(): void + { + $validator = new IsJsonString(); + $input = json_encode([ + 'foo' => [ + 'bar' => [ + 'baz' => 'goats', + ], + ], + ]); + + $validator->setMaxDepth(1); + self::assertFalse($validator->isValid($input)); + self::assertArrayHasKey(IsJsonString::ERROR_MAX_DEPTH_EXCEEDED, $validator->getMessages()); + } + + /** @return array */ + public function pluginManagerNameProvider(): array + { + return [ + [IsJsonString::class], + ]; + } + + /** @dataProvider pluginManagerNameProvider */ + public function testThatTheValidatorCanBeRetrievedFromThePluginManagerWithDefaultConfiguration(string $name): void + { + $pluginManager = new ValidatorPluginManager(new ServiceManager()); + $validator = $pluginManager->get($name); + self::assertInstanceOf(IsJsonString::class, $validator); + } + + /** @return array */ + public function invalidStringProvider(): array + { + return [ + 'Empty String' => [''], + 'Invalid Object' => ['{nuts}'], + 'Invalid Array' => ['["Foo"'], + 'Arbitrary String' => ['goats are friends'], + ]; + } + + /** @dataProvider invalidStringProvider */ + public function testInvalidStrings(string $input): void + { + $validator = new IsJsonString(); + self::assertFalse($validator->isValid($input)); + self::assertArrayHasKey(IsJsonString::ERROR_INVALID_JSON, $validator->getMessages()); + } +} From 55e020f1456d22cab5f63a70e3ddf99f6091cc53 Mon Sep 17 00:00:00 2001 From: George Steel Date: Thu, 26 Jan 2023 20:36:57 +0000 Subject: [PATCH 2/4] Baseline update Signed-off-by: George Steel --- psalm-baseline.xml | 46 +++++++++------------------------------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 65eee7608..9eb694629 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + $domain @@ -16,7 +16,8 @@ - + + $this $this $this $this @@ -46,7 +47,8 @@ $this->abstractOptions - + + $this->options $this->options $this->options $this->options @@ -1615,22 +1617,11 @@ is_array($options) is_string($value) - + $regexChar $regexKey - $value - 128 + ($value & 63) - 128 + ($value & 63) - 128 + ($value & 63) - 128 + (($value >> 12) & 63) - 128 + (($value >> 6) & 63) - 128 + (($value >> 6) & 63) - 192 + ($value >> 6) - 224 + ($value >> 12) - 240 + ($value >> 18) - - - $decoded[$i] + + $partRegexChars $regexChar $regexChars @@ -1639,7 +1630,6 @@ $temp['ipValidator'] $temp['useIdnCheck'] $temp['useTldCheck'] - $value Ip @@ -1647,26 +1637,8 @@ bool int - + $regexChars - $value - $value - $value - $value - $value - $value - $value - $value - $value - $value & 63 - $value & 63 - $value & 63 - $value >> 12 - $value >> 18 - $value >> 6 - ($value >> 12) & 63 - ($value >> 6) & 63 - ($value >> 6) & 63 $this->options['allow'] From c9a6c4829a78698f40b016d61b41dc0bf6a849de Mon Sep 17 00:00:00 2001 From: George Steel Date: Thu, 26 Jan 2023 22:02:51 +0000 Subject: [PATCH 3/4] Add documentation for the `IsJsonString` validator Signed-off-by: George Steel --- docs/book/validators/is-json-string.md | 66 ++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 67 insertions(+) create mode 100644 docs/book/validators/is-json-string.md diff --git a/docs/book/validators/is-json-string.md b/docs/book/validators/is-json-string.md new file mode 100644 index 000000000..e476e4b16 --- /dev/null +++ b/docs/book/validators/is-json-string.md @@ -0,0 +1,66 @@ +# IsJsonString Validator + +`Laminas\Validator\IsJsonString` allows you to validate whether a given value is a string that will be successfully decoded by `json_decode`. + +## Basic Usage + +```php +$validator = new Laminas\Validator\IsJsonString(); +$input = '{"some":"json"}'; + +if ($validator->isValid($input)) { + // $input can be successfully decoded +} else { + // $input is not a valid JSON string +} +``` + +## Restricting Acceptable JSON types + +`json_decode` accepts numeric strings representing integers and floating point numbers, booleans, arrays and objects. +You can restrict what is considered valid input using the `allow` option of the validator. + +```php +use Laminas\Validator\IsJsonString; + +$validator = new IsJsonString([ + 'allow' => IsJsonString::ALLOW_ALL ^ IsJsonString::ALLOW_BOOL, +]); + +$validator->isValid('true'); // false +``` + +The `allow` option is a bit mask of the `ALLOW_*` constants in `IsJsonString`: + +- `IsJsonString::ALLOW_INT` - Accept numeric such as "1" +- `IsJsonString::ALLOW_FLOAT` - Accept numeric strings such as "1.234" +- `IsJsonString::ALLOW_BOOL` - Accept "true" and "false" +- `IsJsonString::ALLOW_ARRAY` - Accept JSON arrays such as `["One", "Two"]` +- `IsJsonString::ALLOW_OBJECT` - Accept JSON objects such as `{"Some":"Object"}` +- `IsJsonString::ALLOW_ALL` - A convenience constant allowing all of the above _(Also the default)_. + +The `allow` option also has a companion setter method `setAllow`. For example, to only accept arrays and objects: + +```php +use Laminas\Validator\IsJsonString; + +$validator = new IsJsonString(); +$validator->setAllow(IsJsonString::ALLOW_ARRAY | IsJsonString::ALLOW_OBJECT); +$validator->isValid("1.234"); // false +``` + +## Restricting Max Object or Array Nesting Level + +If you wish to restrict the nesting level of arrays and objects that are considered valid, the validator accepts a `maxDepth` option. The default value of this option is `512` - the same default value as `json_decode`. + +```php +$validator = new Laminas\Validator\IsJsonString(['maxDepth' => 2]); +$validator->isValid('{"nested": {"object: "here"}}'); // false +``` + +Again, the max nesting level allowed has a companion setter method: + +```php +$validator = new Laminas\Validator\IsJsonString(); +$validator->setMaxDepth(10); +``` diff --git a/mkdocs.yml b/mkdocs.yml index d925a04c8..cbdcb4587 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Isbn: validators/isbn.md - IsCountable: validators/is-countable.md - IsInstanceOf: validators/isinstanceof.md + - IsJsonString: validators/is-json-string.md - LessThan: validators/less-than.md - NotEmpty: validators/not-empty.md - Regex: validators/regex.md From e2fa8fe539f2dafe09bf4d98cc401ab344a5999a Mon Sep 17 00:00:00 2001 From: George Steel Date: Fri, 27 Jan 2023 10:35:09 +0000 Subject: [PATCH 4/4] Minor formatting adjustments to documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Frank Brückner Signed-off-by: George Steel --- docs/book/validators/is-json-string.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/book/validators/is-json-string.md b/docs/book/validators/is-json-string.md index e476e4b16..23a6b3e9b 100644 --- a/docs/book/validators/is-json-string.md +++ b/docs/book/validators/is-json-string.md @@ -1,4 +1,4 @@ -# IsJsonString Validator +# IsJsonString `Laminas\Validator\IsJsonString` allows you to validate whether a given value is a string that will be successfully decoded by `json_decode`. @@ -15,7 +15,7 @@ if ($validator->isValid($input)) { } ``` -## Restricting Acceptable JSON types +## Restricting Acceptable JSON Types `json_decode` accepts numeric strings representing integers and floating point numbers, booleans, arrays and objects. You can restrict what is considered valid input using the `allow` option of the validator. @@ -32,9 +32,9 @@ $validator->isValid('true'); // false The `allow` option is a bit mask of the `ALLOW_*` constants in `IsJsonString`: -- `IsJsonString::ALLOW_INT` - Accept numeric such as "1" -- `IsJsonString::ALLOW_FLOAT` - Accept numeric strings such as "1.234" -- `IsJsonString::ALLOW_BOOL` - Accept "true" and "false" +- `IsJsonString::ALLOW_INT` - Accept integer such as `1` +- `IsJsonString::ALLOW_FLOAT` - Accept floating-point value such as `1.234` +- `IsJsonString::ALLOW_BOOL` - Accept `true` and `false` - `IsJsonString::ALLOW_ARRAY` - Accept JSON arrays such as `["One", "Two"]` - `IsJsonString::ALLOW_OBJECT` - Accept JSON objects such as `{"Some":"Object"}` - `IsJsonString::ALLOW_ALL` - A convenience constant allowing all of the above _(Also the default)_. @@ -46,7 +46,7 @@ use Laminas\Validator\IsJsonString; $validator = new IsJsonString(); $validator->setAllow(IsJsonString::ALLOW_ARRAY | IsJsonString::ALLOW_OBJECT); -$validator->isValid("1.234"); // false +$validator->isValid('1.234'); // false ``` ## Restricting Max Object or Array Nesting Level