Skip to content

Commit 4eb3442

Browse files
authoredOct 21, 2022
Merge pull request #57 from wmde/master
Improve JsonPatch exceptions
·
v3.12.1v3.10.0
2 parents ccfc713 + 478e618 commit 4eb3442

9 files changed

+344
-34
lines changed
 

‎src/JsonPatch.php

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,15 @@ public static function import(array $data)
6060
$operation = (object)$operation;
6161
}
6262

63+
if (!is_object($operation)) {
64+
throw new Exception('Invalid patch operation - should be a JSON object');
65+
}
66+
6367
if (!isset($operation->op)) {
64-
throw new Exception('Missing "op" in operation data');
68+
throw new MissingFieldException('op', $operation);
6569
}
6670
if (!isset($operation->path)) {
67-
throw new Exception('Missing "path" in operation data');
71+
throw new MissingFieldException('path', $operation);
6872
}
6973

7074
$op = null;
@@ -88,18 +92,18 @@ public static function import(array $data)
8892
$op = new Test();
8993
break;
9094
default:
91-
throw new Exception('Unknown "op": ' . $operation->op);
95+
throw new UnknownOperationException($operation);
9296
}
9397
$op->path = $operation->path;
9498
if ($op instanceof OpPathValue) {
9599
if (property_exists($operation, 'value')) {
96100
$op->value = $operation->value;
97101
} else {
98-
throw new Exception('Missing "value" in operation data');
102+
throw new MissingFieldException('value', $operation);
99103
}
100104
} elseif ($op instanceof OpPathFrom) {
101105
if (!isset($operation->from)) {
102-
throw new Exception('Missing "from" in operation data');
106+
throw new MissingFieldException('from', $operation);
103107
}
104108
$op->from = $operation->from;
105109
}
@@ -141,20 +145,26 @@ public function apply(&$original, $stopOnError = true)
141145
$errors = array();
142146
foreach ($this->operations as $operation) {
143147
try {
148+
// track the current pointer field so we can use it for a potential PathException
149+
$pointerField = 'path';
144150
$pathItems = JsonPointer::splitPath($operation->path);
145151
switch (true) {
146152
case $operation instanceof Add:
147153
JsonPointer::add($original, $pathItems, $operation->value, $this->flags);
148154
break;
149155
case $operation instanceof Copy:
156+
$pointerField = 'from';
150157
$fromItems = JsonPointer::splitPath($operation->from);
151158
$value = JsonPointer::get($original, $fromItems);
159+
$pointerField = 'path';
152160
JsonPointer::add($original, $pathItems, $value, $this->flags);
153161
break;
154162
case $operation instanceof Move:
163+
$pointerField = 'from';
155164
$fromItems = JsonPointer::splitPath($operation->from);
156165
$value = JsonPointer::get($original, $fromItems);
157166
JsonPointer::remove($original, $fromItems, $this->flags);
167+
$pointerField = 'path';
158168
JsonPointer::add($original, $pathItems, $value, $this->flags);
159169
break;
160170
case $operation instanceof Remove:
@@ -170,11 +180,22 @@ public function apply(&$original, $stopOnError = true)
170180
$diff = new JsonDiff($operation->value, $value,
171181
JsonDiff::STOP_ON_DIFF);
172182
if ($diff->getDiffCnt() !== 0) {
173-
throw new PatchTestOperationFailedException('Test operation ' . json_encode($operation, JSON_UNESCAPED_SLASHES)
174-
. ' failed: ' . json_encode($value));
183+
throw new PatchTestOperationFailedException($operation, $value);
175184
}
176185
break;
177186
}
187+
} catch (JsonPointerException $jsonPointerException) {
188+
$pathException = new PathException(
189+
$jsonPointerException->getMessage(),
190+
$operation,
191+
$pointerField,
192+
$jsonPointerException->getCode()
193+
);
194+
if ($stopOnError) {
195+
throw $pathException;
196+
} else {
197+
$errors[] = $pathException;
198+
}
178199
} catch (Exception $exception) {
179200
if ($stopOnError) {
180201
throw $exception;
@@ -185,4 +206,4 @@ public function apply(&$original, $stopOnError = true)
185206
}
186207
return $errors;
187208
}
188-
}
209+
}

‎src/JsonPointer.php

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public static function splitPath($path)
6666
return self::splitPathURIFragment($pathItems);
6767
} else {
6868
if ($first !== '') {
69-
throw new Exception('Path must start with "/": ' . $path);
69+
throw new JsonPointerException('Path must start with "/": ' . $path);
7070
}
7171
return self::splitPathJsonString($pathItems);
7272
}
@@ -105,15 +105,15 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV
105105
while (null !== $key = array_shift($pathItems)) {
106106
if ($ref instanceof \stdClass || is_object($ref)) {
107107
if (PHP_VERSION_ID < 70100 && '' === $key) {
108-
throw new Exception('Empty property name is not supported by PHP <7.1',
108+
throw new JsonPointerException('Empty property name is not supported by PHP <7.1',
109109
Exception::EMPTY_PROPERTY_NAME_UNSUPPORTED);
110110
}
111111

112112
if ($flags & self::RECURSIVE_KEY_CREATION) {
113113
$ref = &$ref->$key;
114114
} else {
115115
if (!isset($ref->$key) && count($pathItems)) {
116-
throw new Exception('Non-existent path item: ' . $key);
116+
throw new JsonPointerException('Non-existent path item: ' . $key);
117117
} else {
118118
$ref = &$ref->$key;
119119
}
@@ -126,7 +126,7 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV
126126
$ref = new \stdClass();
127127
$ref = &$ref->{$key};
128128
} else {
129-
throw new Exception('Non-existent path item: ' . $key);
129+
throw new JsonPointerException('Non-existent path item: ' . $key);
130130
}
131131
} elseif ([] === $ref && 0 === ($flags & self::STRICT_MODE) && false === $intKey && '-' !== $key) {
132132
$ref = new \stdClass();
@@ -138,7 +138,7 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV
138138
} else {
139139
if (false === $intKey) {
140140
if (0 === ($flags & self::TOLERATE_ASSOCIATIVE_ARRAYS)) {
141-
throw new Exception('Invalid key for array operation');
141+
throw new JsonPointerException('Invalid key for array operation');
142142
}
143143
$ref = &$ref[$key];
144144
continue;
@@ -148,9 +148,9 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV
148148
}
149149
if (0 === ($flags & self::TOLERATE_ASSOCIATIVE_ARRAYS)) {
150150
if ($intKey > count($ref) && 0 === ($flags & self::RECURSIVE_KEY_CREATION)) {
151-
throw new Exception('Index is greater than number of items in array');
151+
throw new JsonPointerException('Index is greater than number of items in array');
152152
} elseif ($intKey < 0) {
153-
throw new Exception('Negative index');
153+
throw new JsonPointerException('Negative index');
154154
}
155155
}
156156

@@ -203,30 +203,30 @@ public static function get($holder, $pathItems)
203203
while (null !== $key = array_shift($pathItems)) {
204204
if ($ref instanceof \stdClass) {
205205
if (PHP_VERSION_ID < 70100 && '' === $key) {
206-
throw new Exception('Empty property name is not supported by PHP <7.1',
206+
throw new JsonPointerException('Empty property name is not supported by PHP <7.1',
207207
Exception::EMPTY_PROPERTY_NAME_UNSUPPORTED);
208208
}
209209

210210
$vars = (array)$ref;
211211
if (self::arrayKeyExists($key, $vars)) {
212212
$ref = self::arrayGet($key, $vars);
213213
} else {
214-
throw new Exception('Key not found: ' . $key);
214+
throw new JsonPointerException('Key not found: ' . $key);
215215
}
216216
} elseif (is_array($ref)) {
217217
if (self::arrayKeyExists($key, $ref)) {
218218
$ref = $ref[$key];
219219
} else {
220-
throw new Exception('Key not found: ' . $key);
220+
throw new JsonPointerException('Key not found: ' . $key);
221221
}
222222
} elseif (is_object($ref)) {
223223
if (isset($ref->$key)) {
224224
$ref = $ref->$key;
225225
} else {
226-
throw new Exception('Key not found: ' . $key);
226+
throw new JsonPointerException('Key not found: ' . $key);
227227
}
228228
} else {
229-
throw new Exception('Key not found: ' . $key);
229+
throw new JsonPointerException('Key not found: ' . $key);
230230
}
231231
}
232232
return $ref;
@@ -260,19 +260,19 @@ public static function remove(&$holder, $pathItems, $flags = 0)
260260
if (property_exists($ref, $key)) {
261261
$ref = &$ref->$key;
262262
} else {
263-
throw new Exception('Key not found: ' . $key);
263+
throw new JsonPointerException('Key not found: ' . $key);
264264
}
265265
} elseif (is_object($ref)) {
266266
if (isset($ref->$key)) {
267267
$ref = &$ref->$key;
268268
} else {
269-
throw new Exception('Key not found: ' . $key);
269+
throw new JsonPointerException('Key not found: ' . $key);
270270
}
271271
} else {
272272
if (array_key_exists($key, $ref)) {
273273
$ref = &$ref[$key];
274274
} else {
275-
throw new Exception('Key not found: ' . $key);
275+
throw new JsonPointerException('Key not found: ' . $key);
276276
}
277277
}
278278
}

‎src/JsonPointerException.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace Swaggest\JsonDiff;
4+
5+
class JsonPointerException extends Exception {}

‎src/MissingFieldException.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace Swaggest\JsonDiff;
4+
5+
use Throwable;
6+
7+
class MissingFieldException extends Exception
8+
{
9+
/** @var string */
10+
private $missingField;
11+
/** @var object */
12+
private $operation;
13+
14+
/**
15+
* @param string $missingField
16+
* @param object $operation
17+
* @param int $code
18+
* @param Throwable|null $previous
19+
*/
20+
public function __construct(
21+
$missingField,
22+
$operation,
23+
$code = 0,
24+
Throwable $previous = null
25+
)
26+
{
27+
parent::__construct('Missing "' . $missingField . '" in operation data', $code, $previous);
28+
$this->missingField = $missingField;
29+
$this->operation = $operation;
30+
}
31+
32+
/**
33+
* @return string
34+
*/
35+
public function getMissingField()
36+
{
37+
return $this->missingField;
38+
}
39+
40+
/**
41+
* @return object
42+
*/
43+
public function getOperation()
44+
{
45+
return $this->operation;
46+
}
47+
}

‎src/PatchTestOperationFailedException.php

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,47 @@
33
namespace Swaggest\JsonDiff;
44

55

6+
use Throwable;
7+
68
class PatchTestOperationFailedException extends Exception
79
{
8-
}
10+
/** @var object */
11+
private $operation;
12+
/** @var string */
13+
private $actualValue;
14+
15+
/**
16+
* @param object $operation
17+
* @param string $actualValue
18+
* @param int $code
19+
* @param Throwable|null $previous
20+
*/
21+
public function __construct(
22+
$operation,
23+
$actualValue,
24+
$code = 0,
25+
Throwable $previous = null
26+
)
27+
{
28+
parent::__construct('Test operation ' . json_encode($operation, JSON_UNESCAPED_SLASHES)
29+
. ' failed: ' . json_encode($actualValue), $code, $previous);
30+
$this->operation = $operation;
31+
$this->actualValue = $actualValue;
32+
}
33+
34+
/**
35+
* @return object
36+
*/
37+
public function getOperation()
38+
{
39+
return $this->operation;
40+
}
41+
42+
/**
43+
* @return string
44+
*/
45+
public function getActualValue()
46+
{
47+
return $this->actualValue;
48+
}
49+
}

‎src/PathException.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Swaggest\JsonDiff;
4+
5+
6+
use Throwable;
7+
8+
class PathException extends Exception
9+
{
10+
/** @var object */
11+
private $operation;
12+
13+
/** @var string */
14+
private $field;
15+
16+
/**
17+
* @param string $message
18+
* @param object $operation
19+
* @param string $field
20+
* @param int $code
21+
* @param Throwable|null $previous
22+
*/
23+
public function __construct(
24+
$message,
25+
$operation,
26+
$field,
27+
$code = 0,
28+
Throwable $previous = null
29+
)
30+
{
31+
parent::__construct($message, $code, $previous);
32+
$this->operation = $operation;
33+
$this->field = $field;
34+
}
35+
36+
/**
37+
* @return object
38+
*/
39+
public function getOperation()
40+
{
41+
return $this->operation;
42+
}
43+
44+
/**
45+
* @return string
46+
*/
47+
public function getField()
48+
{
49+
return $this->field;
50+
}
51+
}

‎src/UnknownOperationException.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Swaggest\JsonDiff;
4+
5+
6+
use Throwable;
7+
8+
class UnknownOperationException extends Exception
9+
{
10+
/** @var object */
11+
private $operation;
12+
13+
/**
14+
* @param object $operation
15+
* @param int $code
16+
* @param Throwable|null $previous
17+
*/
18+
public function __construct(
19+
$operation,
20+
$code = 0,
21+
Throwable $previous = null
22+
)
23+
{
24+
// @phpstan-ignore-next-line MissingFieldOperation will be thrown if op is not set
25+
parent::__construct('Unknown "op": ' . $operation->op, $code, $previous);
26+
$this->operation = $operation;
27+
}
28+
29+
/**
30+
* @return object
31+
*/
32+
public function getOperation()
33+
{
34+
return $this->operation;
35+
}
36+
}

‎tests/src/JsonPatchTest.php

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
use Swaggest\JsonDiff\Exception;
66
use Swaggest\JsonDiff\JsonDiff;
77
use Swaggest\JsonDiff\JsonPatch;
8+
use Swaggest\JsonDiff\JsonPatch\OpPath;
9+
use Swaggest\JsonDiff\MissingFieldException;
810
use Swaggest\JsonDiff\PatchTestOperationFailedException;
11+
use Swaggest\JsonDiff\PathException;
12+
use Swaggest\JsonDiff\UnknownOperationException;
913

1014
class JsonPatchTest extends \PHPUnit_Framework_TestCase
1115
{
@@ -75,8 +79,16 @@ public function testNull()
7579

7680
public function testMissingOp()
7781
{
78-
$this->setExpectedException(get_class(new Exception()), 'Missing "op" in operation data');
79-
JsonPatch::import(array((object)array('path' => '/123')));
82+
$operation = (object)array('path' => '/123');
83+
try {
84+
JsonPatch::import(array($operation));
85+
$this->fail('Expected exception was not thrown');
86+
} catch (Exception $exception) {
87+
$this->assertInstanceOf(MissingFieldException::class, $exception);
88+
$this->assertSame('Missing "op" in operation data', $exception->getMessage());
89+
$this->assertSame('op', $exception->getMissingField());
90+
$this->assertSame($operation, $exception->getOperation());
91+
}
8092
}
8193

8294
public function testMissingPath()
@@ -87,8 +99,15 @@ public function testMissingPath()
8799

88100
public function testInvalidOp()
89101
{
90-
$this->setExpectedException(get_class(new Exception()), 'Unknown "op": wat');
91-
JsonPatch::import(array((object)array('op' => 'wat', 'path' => '/123')));
102+
$operation = (object)array('op' => 'wat', 'path' => '/123');
103+
try {
104+
JsonPatch::import(array($operation));
105+
$this->fail('Expected exception was not thrown');
106+
} catch (Exception $exception) {
107+
$this->assertInstanceOf(UnknownOperationException::class, $exception);
108+
$this->assertSame('Unknown "op": wat', $exception->getMessage());
109+
$this->assertSame($operation, $exception->getOperation());
110+
}
92111
}
93112

94113
public function testMissingFrom()
@@ -145,11 +164,100 @@ public function testApplyNonExistentLevelOne()
145164

146165
public function testTestOperationFailed()
147166
{
148-
$data = array('abc' => 'xyz');
167+
$actualValue = 'xyz';
168+
$data = array('abc' => $actualValue);
169+
$operation = new JsonPatch\Test('/abc', 'def');
170+
149171
$p = new JsonPatch();
150-
$p->op(new JsonPatch\Test('/abc', 'def'));
151-
$errors = $p->apply($data, false);
172+
$p->op($operation);
173+
$testError = $p->apply($data, false)[0];
174+
$this->assertInstanceOf(PatchTestOperationFailedException::class, $testError);
175+
$this->assertSame($operation, $testError->getOperation());
176+
$this->assertSame($actualValue, $testError->getActualValue());
177+
}
178+
179+
public function testPathExceptionContinueOnError()
180+
{
181+
$actualValue = 'xyz';
182+
$data = array('abc' => $actualValue);
183+
$patch = new JsonPatch();
184+
185+
$operation1 = new JsonPatch\Test('/abc', 'def');
186+
$patch->op($operation1);
187+
188+
$operation2 = new JsonPatch\Move('/target', '/source');
189+
$patch->op($operation2);
190+
191+
$errors = $patch->apply($data, false);
192+
152193
$this->assertInstanceOf(PatchTestOperationFailedException::class, $errors[0]);
194+
$this->assertSame($operation1, $errors[0]->getOperation());
195+
196+
$this->assertInstanceOf(PathException::class, $errors[1]);
197+
$this->assertSame($operation2, $errors[1]->getOperation());
198+
$this->assertSame('from', $errors[1]->getField());
153199
}
154200

155-
}
201+
public function pathExceptionProvider() {
202+
return [
203+
'splitPath_path' => [
204+
new JsonPatch\Copy('invalid/path', '/valid/from'),
205+
'Path must start with "/": invalid/path',
206+
'path'
207+
],
208+
'splitPath_from' => [
209+
new JsonPatch\Copy('/valid/path', 'invalid/from'),
210+
'Path must start with "/": invalid/from',
211+
'from'
212+
],
213+
'add' => [
214+
new JsonPatch\Add('/some/path', 22),
215+
'Non-existent path item: some',
216+
'path'
217+
],
218+
'get_from' => [
219+
new JsonPatch\Copy('/target', '/source'),
220+
'Key not found: source',
221+
'from'
222+
],
223+
'get_path' => [
224+
new JsonPatch\Replace('/some/path', 23),
225+
'Key not found: some',
226+
'path'
227+
],
228+
'remove_from' => [
229+
new JsonPatch\Move('/target', '/source'),
230+
'Key not found: source',
231+
'from'
232+
],
233+
'remove_path' => [
234+
new JsonPatch\Remove('/some/path'),
235+
'Key not found: some',
236+
'path'
237+
]
238+
];
239+
}
240+
241+
/**
242+
* @param OpPath $operation
243+
* @param string $expectedMessage
244+
* @param string $expectedField
245+
*
246+
* @dataProvider pathExceptionProvider
247+
*/
248+
public function testPathException(OpPath $operation, $expectedMessage, $expectedField) {
249+
$data = new \stdClass();
250+
$patch = new JsonPatch();
251+
252+
$patch->op($operation);
253+
254+
try {
255+
$patch->apply($data );
256+
$this->fail('PathException expected');
257+
} catch (Exception $ex) {
258+
$this->assertInstanceOf(PathException::class, $ex);
259+
$this->assertEquals($expectedMessage, $ex->getMessage());
260+
$this->assertEquals($expectedField, $ex->getField());
261+
}
262+
}
263+
}

‎tests/src/JsonPointerTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
use Swaggest\JsonDiff\Exception;
77
use Swaggest\JsonDiff\JsonPointer;
8+
use Swaggest\JsonDiff\JsonPointerException;
89

910
class JsonPointerTest extends \PHPUnit_Framework_TestCase
1011
{
@@ -30,7 +31,7 @@ public function testProcess()
3031

3132
try {
3233
$this->assertSame('null', json_encode(JsonPointer::get($json, JsonPointer::splitPath('/l1/l2/non-existent'))));
33-
} catch (Exception $exception) {
34+
} catch (JsonPointerException $exception) {
3435
$this->assertSame('Key not found: non-existent', $exception->getMessage());
3536
}
3637

@@ -89,7 +90,7 @@ public function testGetSetDeleteObject()
8990
try {
9091
JsonPointer::get($s, ['one', 'two']);
9192
$this->fail('Exception expected');
92-
} catch (Exception $e) {
93+
} catch (JsonPointerException $e) {
9394
$this->assertEquals('Key not found: two', $e->getMessage());
9495
}
9596
$this->assertEquals(null, $s->one->two);

0 commit comments

Comments
 (0)
Please sign in to comment.