Skip to content

Commit

Permalink
Add Json::validate and Json::parse
Browse files Browse the repository at this point in the history
  • Loading branch information
jncarver committed Feb 6, 2020
1 parent fa3943d commit 44b8004
Show file tree
Hide file tree
Showing 4 changed files with 383 additions and 0 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,36 @@ The following checks that `$value` is an email.
\TraderInteractive\Filter\Email::filter($value);
```

#### Json::validate

This filter verifies that the value is in a valid JSON format.

The second parameter can be set to `true` to allow null values through without an error.

The third parameter determines the maximum recursion depth that is allowed.

The following checks that `$value` is a valid JSON string.

```php
\TraderInteractive\Filter\Json::validate($value);
```

#### Json::parse

This filter parses a valid JSON string into an array, int, double, or bool. Invalid JSON will throw an error.

The second parameter can be set to `true` to allow null values through without an error.

The third parameter determines the maximum recursion depth that is allowed.

The following checks that `$value` is a valid JSON string and parses it into an array.

```php
$value = '{ "string": "value", "array": [1, 2, 3] }';
\TraderInteractive\Filter\Json::parse($value);
assert($value === ['string' => 'value', 'array' => [1, 2, 3]]);
```

## Contact

Developers may be contacted at:
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"license": "MIT",
"require": {
"php": "^7.0",
"ext-json": "*",
"traderinteractive/exceptions": "^1.0"
},
"require-dev": {
Expand Down
111 changes: 111 additions & 0 deletions src/Filter/Json.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

namespace TraderInteractive\Filter;

use TraderInteractive\Exceptions\FilterException;

/**
* A collection of filters for JSON
*/
final class Json
{
/**
* @var bool
*/
const DEFAULT_SHOULD_ALLOW_NULL = false;

/**
* @var int
*/
const DEFAULT_RECURSION_DEPTH = 512;

/**
* @var string
*/
const ERROR_CANNOT_BE_NULL = "Value cannot be null";

/**
* @var string
*/
const ERROR_NOT_A_STRING = "Value '%s' is not a string";

/**
* @var string
*/
const ERROR_INVALID_JSON = "JSON failed validation with message '%s'";

/**
* Parses a JSON string and returns the result.
*
* @param mixed $value The value to filter.
* @param bool $shouldAllowNull Allows null values to pass through the filter when set to true.
* @param int $depth The maximum recursion depth.
*
* @return array|bool|int|float|double|null
*
* @throws FilterException Thrown if the value is invalid.
*/
public static function parse(
$value,
bool $shouldAllowNull = self::DEFAULT_SHOULD_ALLOW_NULL,
int $depth = self::DEFAULT_RECURSION_DEPTH
) {
return self::decode($value, $shouldAllowNull, true, $depth);
}

/**
* Checks that the JSON is valid and returns the original value.
*
* @param mixed $value The value to filter.
* @param bool $shouldAllowNull Allows null values to pass through the filter when set to true.
* @param int $depth The maximum recursion depth.
*
* @return string|null
*
* @throws FilterException Thrown if the value is invalid.
*/
public static function validate(
$value,
bool $shouldAllowNull = self::DEFAULT_SHOULD_ALLOW_NULL,
int $depth = self::DEFAULT_RECURSION_DEPTH
) {
self::decode($value, $shouldAllowNull, false, $depth);
return $value;
}

/**
* Parses a JSON string and returns the result.
*
* @param mixed $value The value to filter.
* @param bool $shouldAllowNull Allows null values to pass through the filter when set to true.
* @param bool $shouldDecodeToArray Decodes the JSON string to an associative array when set to true.
* @param int $depth The maximum recursion depth.
*
* @return string|array|bool|int|float|double|null
*
* @throws FilterException Thrown if the value is invalid.
*/
private static function decode($value, bool $shouldAllowNull, bool $shouldDecodeToArray, int $depth)
{
if ($shouldAllowNull && $value === null) {
return $value;
}

if (!$shouldAllowNull && $value === null) {
throw new FilterException(self::ERROR_CANNOT_BE_NULL);
}

if (!is_string($value)) {
throw new FilterException(sprintf(self::ERROR_NOT_A_STRING, var_export($value, true)));
}

$value = json_decode($value, $shouldDecodeToArray, $depth);
$lastErrorCode = json_last_error();
if ($lastErrorCode !== JSON_ERROR_NONE) {
$message = sprintf(self::ERROR_INVALID_JSON, json_last_error_msg());
throw new FilterException($message, $lastErrorCode);
}

return $value;
}
}
241 changes: 241 additions & 0 deletions tests/Filter/JsonTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<?php

namespace TraderInteractive\Filter;

use PHPUnit\Framework\TestCase;
use TraderInteractive\Exceptions\FilterException;

/**
* @coversDefaultClass \TraderInteractive\Filter\Json
* @covers ::<private>
*/
final class JsonFilterTest extends TestCase
{
/**
* @test
* @covers ::parse
* @dataProvider provideParse
*
* @param string $value The value to filter.
* @param mixed $expected The expected result.
*/
public function parse(string $value, $expected)
{
$result = Json::parse($value);

$this->assertSame($expected, $result);
}

/**
* @return array
*/
public function provideParse() : array
{
return [
'json' => [
'value' => '{"a":"b","c":[1,2,[3],{"4":"d"}], "e": "f"}',
'expected' => [
'a' => 'b',
'c' => [
1,
2,
[3],
[4 => 'd'],
],
'e' => 'f',
],
],
'null string' => [
'value' => 'null',
'expected' => null,
],
'integer string' => [
'value' => '1',
'expected' => 1,
],
'float string' => [
'value' => '0.000001',
'expected' => 0.000001,
],
'double string' => [
'value' => '1.56e10',
'expected' => 1.56e10,
],
'true string' => [
'value' => 'true',
'expected' => true,
],
'false string' => [
'value' => 'false',
'expected' => false,
],
];
}

/**
* @test
* @covers ::parse
*/
public function parseNullWithAllowNull()
{
$value = null;
$result = Json::parse($value, true);

$this->assertSame($value, $result);
}

/**
* @test
* @covers ::parse
* @dataProvider provideInvalidJSON
*
* @param mixed $value The value to filter.
* @param string $message The expected error message.
*/
public function parseThrowsException($value, string $message)
{
$this->expectException(FilterException::class);
$this->expectExceptionMessage($message);

Json::parse($value);
}

/**
* @test
* @covers ::parse
*/
public function parseThrowsExceptionOnNull()
{
$this->expectException(FilterException::class);
$this->expectExceptionMessage(Json::ERROR_CANNOT_BE_NULL);

Json::parse(null);
}

/**
* @test
* @covers ::parse
*/
public function parseThrowsExceptionForRecursionDepth()
{
$this->expectException(FilterException::class);
$this->expectExceptionMessage(sprintf(Json::ERROR_INVALID_JSON, 'Maximum stack depth exceeded'));

Json::parse('[[]]', false, 1);
}

/**
* @test
* @covers ::validate
* @dataProvider provideValidate
*
* @param string $value The value to filter.
*/
public function validate(string $value)
{
$result = Json::validate($value);

$this->assertSame($value, $result);
}

/**
* @return array
*/
public function provideValidate() : array
{
return [
'json' => ['{"a": "b", "c":[1, {"2": 3},[4]], "d": "e"}'],
'null' => ['null'],
'integer string' => ['12345'],
'float string' => ['1.000003'],
'double string' => ['445.2e100'],
'true string' => ['true'],
'false string' => ['false'],
];
}

/**
* @test
* @covers ::validate
*/
public function validateNullWithAllowNull()
{
$value = null;
$result = Json::validate($value, true);

$this->assertSame($value, $result);
}

/**
* @test
* @covers ::validate
* @dataProvider provideInvalidJSON
*
* @param mixed $value The value to filter.
* @param string $message The expected error message.
*/
public function validateThrowsException($value, string $message)
{
$this->expectException(FilterException::class);
$this->expectExceptionMessage($message);

Json::validate($value);
}

/**
* @test
* @covers ::validate
*/
public function validateThrowsExceptionOnNull()
{
$this->expectException(FilterException::class);
$this->expectExceptionMessage(Json::ERROR_CANNOT_BE_NULL);

Json::validate(null);
}

/**
* @test
* @covers ::validate
*/
public function validateThrowsExceptionForRecursionDepth()
{
$this->expectException(FilterException::class);
$this->expectExceptionMessage(sprintf(Json::ERROR_INVALID_JSON, 'Maximum stack depth exceeded'));

Json::validate('[[]]', false, 1);
}

/**
* @return array
*/
public function provideInvalidJSON() : array
{
return [
'not a string' => [
'value' => [],
'message' => sprintf(Json::ERROR_NOT_A_STRING, var_export([], true)),
],
'empty string' => [
'value' => '',
'message' => sprintf(Json::ERROR_INVALID_JSON, 'Syntax error'),
],
'only whitespace' => [
'value' => ' ',
'message' => sprintf(Json::ERROR_INVALID_JSON, 'Syntax error'),
],
'non-json string' => [
'value' => 'some string',
'message' => sprintf(Json::ERROR_INVALID_JSON, 'Syntax error'),
],
'invalid json' => [
'value' => '{"incomplete":',
'message' => sprintf(Json::ERROR_INVALID_JSON, 'Syntax error'),
],
'unpaired UTF-16 surrogate' => [
'value' => '["\uD834"]',
'message' => sprintf(Json::ERROR_INVALID_JSON, 'Single unpaired UTF-16 surrogate in unicode escape'),
],
];
}
}

0 comments on commit 44b8004

Please sign in to comment.