Introduce `IsJsonString` validator
ADD
src/IsJsonString.php
+<?php
+
+declare(strict_types=1);
+
+namespace Laminas\Validator;
+
+use JsonException;
+
+use function gettype;
+use function is_float;
+use function is_int;
+use function is_numeric;
+use function is_string;
+use function json_decode;
+use function str_starts_with;
+
+use const JSON_ERROR_DEPTH;
+use const JSON_THROW_ON_ERROR;
+
+/**
+ * @psalm-type CustomOptions = array{
+ * allow: int-mask-of<self::ALLOW_*>,
+ * 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<self::ERROR_*, non-empty-string> */
+ 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<string, string> */
+ protected $messageVariables = [
+ 'type' => 'type',
+ 'maxDepth' => 'maxDepth',
+ ];
+
+ protected ?string $type = null;
+ /** @var int-mask-of<self::ALLOW_*> */
+ protected int $allow = self::ALLOW_ALL;
+ /** @var positive-int */
+ protected int $maxDepth = 512;
+
+ /** @param int-mask-of<self::ALLOW_*> $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;
+ }
+}

MODIFY
src/ValidatorPluginManager.php
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,

ADD
test/IsJsonStringTest.php
+<?php
+
+declare(strict_types=1);
+
+namespace LaminasTest\Validator;
+
+use Laminas\ServiceManager\ServiceManager;
+use Laminas\Validator\IsJsonString;
+use Laminas\Validator\ValidatorPluginManager;
+use PHPUnit\Framework\TestCase;
+
+use function json_encode;
+
+class IsJsonStringTest extends TestCase
+{
+ /**
+ * @return array<string, array{
+ * 0: int-mask-of<IsJsonString::ALLOW_*>,
+ * 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<IsJsonString::ALLOW_*> $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<array{0: mixed}> */
+ 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<array-key, array{0: string}> */
+ 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<string, array{0: string}> */
+ 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());
+ }
+}

Introduce `IsJsonString` validator

Signed-off-by: George Steel <george@net-glue.co.uk>