Skip to content

Commit

Permalink
Merge pull request #172 from gsteel/json-validator
Browse files Browse the repository at this point in the history
Introduce `IsJsonString` validator
  • Loading branch information
gsteel committed Jan 30, 2023
2 parents db5ddf4 + e2fa8fe commit 0acb2b6
Show file tree
Hide file tree
Showing 6 changed files with 366 additions and 37 deletions.
66 changes: 66 additions & 0 deletions docs/book/validators/is-json-string.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# IsJsonString

`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 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)_.

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);
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 9 additions & 37 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.3.0@b6faa3e96b8eb50ec71384c53799b8a107236bb6">
<files psalm-version="5.4.0@62db5d4f6a7ae0a20f7cc5a4952d730272fc0863">
<file src="bin/update_hostname_validator.php">
<MissingClosureParamType occurrences="2">
<code>$domain</code>
Expand All @@ -16,7 +16,8 @@
</MixedArgument>
</file>
<file src="src/AbstractValidator.php">
<LessSpecificReturnStatement occurrences="4">
<LessSpecificReturnStatement occurrences="5">
<code>$this</code>
<code>$this</code>
<code>$this</code>
<code>$this</code>
Expand Down Expand Up @@ -46,7 +47,8 @@
<PropertyTypeCoercion occurrences="1">
<code>$this-&gt;abstractOptions</code>
</PropertyTypeCoercion>
<UndefinedThisPropertyAssignment occurrences="30">
<UndefinedThisPropertyAssignment occurrences="31">
<code>$this-&gt;options</code>
<code>$this-&gt;options</code>
<code>$this-&gt;options</code>
<code>$this-&gt;options</code>
Expand Down Expand Up @@ -1615,22 +1617,11 @@
<code>is_array($options)</code>
<code>is_string($value)</code>
</DocblockTypeContradiction>
<MixedArgument occurrences="12">
<MixedArgument occurrences="2">
<code>$regexChar</code>
<code>$regexKey</code>
<code>$value</code>
<code>128 + ($value &amp; 63)</code>
<code>128 + ($value &amp; 63)</code>
<code>128 + ($value &amp; 63)</code>
<code>128 + (($value &gt;&gt; 12) &amp; 63)</code>
<code>128 + (($value &gt;&gt; 6) &amp; 63)</code>
<code>128 + (($value &gt;&gt; 6) &amp; 63)</code>
<code>192 + ($value &gt;&gt; 6)</code>
<code>224 + ($value &gt;&gt; 12)</code>
<code>240 + ($value &gt;&gt; 18)</code>
</MixedArgument>
<MixedAssignment occurrences="10">
<code>$decoded[$i]</code>
</MixedArgument>
<MixedAssignment occurrences="8">
<code>$partRegexChars</code>
<code>$regexChar</code>
<code>$regexChars</code>
Expand All @@ -1639,34 +1630,15 @@
<code>$temp['ipValidator']</code>
<code>$temp['useIdnCheck']</code>
<code>$temp['useTldCheck']</code>
<code>$value</code>
</MixedAssignment>
<MixedInferredReturnType occurrences="4">
<code>Ip</code>
<code>bool</code>
<code>bool</code>
<code>int</code>
</MixedInferredReturnType>
<MixedOperand occurrences="19">
<MixedOperand occurrences="1">
<code>$regexChars</code>
<code>$value</code>
<code>$value</code>
<code>$value</code>
<code>$value</code>
<code>$value</code>
<code>$value</code>
<code>$value</code>
<code>$value</code>
<code>$value</code>
<code>$value &amp; 63</code>
<code>$value &amp; 63</code>
<code>$value &amp; 63</code>
<code>$value &gt;&gt; 12</code>
<code>$value &gt;&gt; 18</code>
<code>$value &gt;&gt; 6</code>
<code>($value &gt;&gt; 12) &amp; 63</code>
<code>($value &gt;&gt; 6) &amp; 63</code>
<code>($value &gt;&gt; 6) &amp; 63</code>
</MixedOperand>
<MixedReturnStatement occurrences="4">
<code>$this-&gt;options['allow']</code>
Expand Down
152 changes: 152 additions & 0 deletions src/IsJsonString.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?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;
}
}
1 change: 1 addition & 0 deletions src/ValidatorPluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 0acb2b6

Please sign in to comment.