Skip to content

Commit 5b9d785

Browse files
committed
feat: Adds assertions that canonicalize JSON encoded values
1 parent 95e0e09 commit 5b9d785

File tree

4 files changed

+276
-2
lines changed

4 files changed

+276
-2
lines changed

app/Helpers/Json.php

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
/**
3+
* Imported from PHPUnit\Util\Json
4+
*
5+
* We need these methods to cheaply do deep-object comparison,
6+
* (e.g. in HasJsonModelAttributes::isJsonModelAttributeDirty)
7+
* but we don't install PHPUnit in production.
8+
*/
9+
10+
namespace Carsdotcom\JsonSchemaValidation\Helpers;
11+
12+
use Illuminate\Support\Arr;
13+
use Illuminate\Support\Str;
14+
use InvalidArgumentException;
15+
use function count;
16+
use function is_array;
17+
use function is_object;
18+
use function json_decode;
19+
use function json_encode;
20+
use function json_last_error;
21+
use function ksort;
22+
23+
/**
24+
* Class Json
25+
* @package App\Helpers
26+
*/
27+
class Json
28+
{
29+
/**
30+
* Given a complex object, especially one with "magic" properties that would fail property_exists,
31+
* remove all the magic. (Like a Muggle https://en.wikipedia.org/wiki/Muggle)
32+
* turn it into a simple array or standard-object representation.
33+
* Uses JSON so arbitrary depth all gets mugglified at once.
34+
* @param $stuff
35+
* @param bool $associativeStyle Like second arg to json_decode
36+
* @return mixed
37+
*/
38+
public static function mugglify($stuff, $associativeStyle = true)
39+
{
40+
return json_decode(json_encode($stuff), $associativeStyle);
41+
}
42+
43+
/**
44+
* To allow comparison of JSON strings, first process them into a consistent
45+
* format so that they can be compared as strings.
46+
* @param string $json
47+
* @param int $options
48+
* @return string
49+
*/
50+
public static function canonicalize(string $json, int $options = 0): string
51+
{
52+
$decodedJson = self::decodeOrThrow($json);
53+
54+
self::recursiveSort($decodedJson);
55+
56+
$reencodedJson = json_encode($decodedJson, $options);
57+
58+
return $reencodedJson;
59+
}
60+
61+
/**
62+
* Compare any two JSON serializable things, where order of string object properties is ignored.
63+
* Returns true if they are, canonically, identical
64+
* @param $thing1
65+
* @param $thing2
66+
* @return bool
67+
* @throws \Exception
68+
*/
69+
public static function canonicallySame($thing1, $thing2): bool
70+
{
71+
return self::canonicalize(json_encode($thing1)) === self::canonicalize(json_encode($thing2));
72+
}
73+
74+
/**
75+
* Compare any two JSON serializable things, where order of string object properties is ignored.
76+
* Creates an internal muggle representation without the dotted-notation keys in $ignoreKeys
77+
* Returns true if those muggles are, canonically, identical
78+
* @param $thing1
79+
* @param $thing2
80+
* @param array $ignoreKeys
81+
* @return bool
82+
* @throws \Exception
83+
*/
84+
public static function canonicallySameExcept($thing1, $thing2, array $ignoreKeys): bool
85+
{
86+
$muggle1 = self::mugglify($thing1);
87+
Arr::forget($muggle1, $ignoreKeys);
88+
$muggle2 = self::mugglify($thing2);
89+
Arr::forget($muggle2, $ignoreKeys);
90+
return self::canonicallySame($muggle1, $muggle2);
91+
}
92+
93+
/*
94+
* JSON object keys are unordered while PHP array keys are ordered.
95+
* Sort all array keys to ensure both the expected and actual values have
96+
* their keys in the same order.
97+
*/
98+
private static function recursiveSort(&$json): void
99+
{
100+
if (is_array($json) === false) {
101+
// If the object is not empty, change it to an associative array
102+
// so we can sort the keys (and we will still re-encode it
103+
// correctly, since PHP encodes associative arrays as JSON objects.)
104+
// But EMPTY objects MUST remain empty objects. (Otherwise we will
105+
// re-encode it as a JSON array rather than a JSON object.)
106+
// See #2919.
107+
if (is_object($json) && count((array) $json) > 0) {
108+
$json = (array) $json;
109+
} else {
110+
return;
111+
}
112+
}
113+
114+
ksort($json);
115+
116+
foreach ($json as $key => &$value) {
117+
self::recursiveSort($value);
118+
}
119+
}
120+
121+
/**
122+
* Wrapper for json_decode that throws when an error occurs.
123+
* Cloned here from Guzzle, to make it obvious that it behaves differently from the builtin language function.
124+
*
125+
* @param string $json JSON data to parse
126+
* @param bool $assoc When true, returned objects will be converted
127+
* into associative arrays.
128+
* @param int $depth User specified recursion depth.
129+
* @param int $options Bitmask of JSON decode options.
130+
*
131+
* @return mixed
132+
* @throws InvalidArgumentException if the JSON cannot be decoded.
133+
* @link http://www.php.net/manual/en/function.json-decode.php
134+
*/
135+
public static function decodeOrThrow($json, $assoc = false, $depth = 512, $options = 0)
136+
{
137+
$data = json_decode($json, $assoc, $depth, $options);
138+
if (JSON_ERROR_NONE !== json_last_error()) {
139+
throw new InvalidArgumentException('json_decode error: ' . json_last_error_msg());
140+
}
141+
142+
return $data;
143+
}
144+
145+
public static function isObject($mixed): bool
146+
{
147+
return Str::startsWith(json_encode($mixed, flags: JSON_THROW_ON_ERROR), '{');
148+
}
149+
}

app/Traits/JsonSchemaAssertions.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,37 @@
66

77
namespace Carsdotcom\JsonSchemaValidation\Traits;
88

9-
109
use Carsdotcom\JsonSchemaValidation\Contracts\CanValidate;
1110
use Carsdotcom\JsonSchemaValidation\Exceptions\JsonSchemaValidationException;
11+
use Carsdotcom\JsonSchemaValidation\Helpers\Json;
1212
use Carsdotcom\JsonSchemaValidation\SchemaValidator;
13+
use PHPUnit\Framework\Assert;
1314

15+
/**
16+
* @mixin Assert This trait should be added to PHPUnit test classes, usually BaseTestCase
17+
*/
1418
trait JsonSchemaAssertions
1519
{
20+
/**
21+
* Given two things that support JSON encoding,
22+
* assert that they are identical in their canonicalized (sorted props), stringified form
23+
* @param mixed $a literally anything that can be JSON encoded
24+
* @param mixed $b literally anything that can be JSON encoded
25+
*/
26+
public static function assertCanonicallySame(mixed $a, mixed $b, string $comment = ''): void
27+
{
28+
$cannonA = Json::canonicalize(json_encode($a), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
29+
$cannonB = Json::canonicalize(json_encode($b), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
30+
self::assertSame($cannonA, $cannonB, $comment);
31+
}
32+
33+
public function assertCanonicallySameExcept($a, $b, array $ignoredKeys, string $comment = ''): void
34+
{
35+
$a = collect($a)->except($ignoredKeys)->toArray();
36+
$b = collect($b)->except($ignoredKeys)->toArray();
37+
self::assertCanonicallySame($a, $b, $comment);
38+
}
39+
1640
/**
1741
* The passed Object validates for the passed Json Schema
1842
*

tests/BaseTestCase.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
namespace Tests;
44

5+
use Carsdotcom\JsonSchemaValidation\Traits\JsonSchemaAssertions;
56
use Orchestra\Testbench\TestCase;
67

78
class BaseTestCase extends TestCase
89
{
10+
use JsonSchemaAssertions;
11+
912
/**
1013
* Define environment setup.
1114
*
@@ -24,4 +27,4 @@ protected function defineEnvironment($app)
2427
'prefix' => '',
2528
]);
2629
}
27-
}
30+
}

tests/Unit/Helpers/JsonTest.php

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
/**
3+
* Unit tests for JSON helpers.
4+
* Note the original class was imported from PHPUnit utilities, so these tests aren't exhaustive
5+
*/
6+
7+
namespace Tests\Unit\Helpers;
8+
9+
use Carsdotcom\JsonSchemaValidation\Helpers\Json;
10+
use Illuminate\Support\Collection;
11+
use Tests\BaseTestCase;
12+
use Tests\Mocks\Models\Vehicle;
13+
14+
class JsonTest extends BaseTestCase
15+
{
16+
public function testMugglifyPropertyExists()
17+
{
18+
$vehicle = Vehicle::factory()->make(['vin' => '11111111111111111']);
19+
self::assertFalse(property_exists($vehicle, 'vin'), 'property_exists fails for class object');
20+
$flatVehicle = Json::mugglify($vehicle, false);
21+
self::assertTrue(property_exists($flatVehicle, 'vin'), 'property_exists succeeds after flattening');
22+
}
23+
24+
public function testCanonicalize()
25+
{
26+
$ab = '{ "a" : 1, "b" : 2 }';
27+
$ba = '{ "b" : 2, "a" : 1 }';
28+
29+
self::assertNotSame($ab, $ba);
30+
self::assertSame('{"a":1,"b":2}', Json::canonicalize($ab), 'No change to order, normalizes whitespace');
31+
self::assertSame('{"a":1,"b":2}', Json::canonicalize($ba));
32+
self::assertSame(Json::canonicalize($ab), Json::canonicalize($ba));
33+
}
34+
35+
/**
36+
* @param $thing1
37+
* @param $thing2
38+
* @param $expected
39+
* @throws \Exception
40+
* @dataProvider provideCanonicallySame
41+
*/
42+
public function testCanonicallySame($thing1, $thing2, bool $expected)
43+
{
44+
self::assertSame($expected, Json::canonicallySame($thing1, $thing2));
45+
}
46+
47+
public static function provideCanonicallySame(): iterable
48+
{
49+
return [
50+
'simple strings match' => ['ab', 'ab', true],
51+
'simple strings miss' => ['ab', 'abc', false],
52+
'null and false not equivalent' => [null, false, false],
53+
'string and number not equivalent' => ['0', 0, false],
54+
'equal properties match' => [['a' => 1, 'b' => 2], ['a' => 1, 'b' => 2], true],
55+
'unequal properties miss' => [['a' => 2, 'b' => 2], ['a' => 1, 'b' => 2], false],
56+
'ignores property order, match' => [['a' => 1, 'b' => 2], ['b' => 2, 'a' => 1], true],
57+
];
58+
}
59+
60+
public function testDecodeOrThrowThrows(): void
61+
{
62+
$this->expectException(\InvalidArgumentException::class);
63+
$this->expectExceptionMessage('json_decode error: Syntax error');
64+
Json::decodeOrThrow('{"incomplete":');
65+
}
66+
67+
public function testDecodeOrThrowIsCoolWithNull(): void
68+
{
69+
// The way it notices decode errors isn't based on the return value of json_decode
70+
self::assertNull(Json::decodeOrThrow('null'));
71+
}
72+
73+
/**
74+
* @dataProvider provideIsObject
75+
*/
76+
public function testIsObject(mixed $subject, bool $expected): void
77+
{
78+
self::assertSame($expected, Json::isObject($subject));
79+
}
80+
81+
public static function provideIsObject(): iterable
82+
{
83+
return [
84+
[null, false],
85+
[0, false],
86+
[1, false],
87+
[1.1, false],
88+
['stringy', false],
89+
['Object', false],
90+
[false, false],
91+
[[], false],
92+
[new Collection(), false],
93+
[(object) [], true],
94+
[['foo' => 'bar'], true],
95+
[(object) ['foo' => 'bar'], true],
96+
];
97+
}
98+
}

0 commit comments

Comments
 (0)