diff --git a/src/JsonPatch.php b/src/JsonPatch.php index 62a80f2..5b07e2c 100644 --- a/src/JsonPatch.php +++ b/src/JsonPatch.php @@ -60,11 +60,15 @@ public static function import(array $data) $operation = (object)$operation; } + if (!is_object($operation)) { + throw new Exception('Invalid patch operation - should be a JSON object'); + } + if (!isset($operation->op)) { - throw new Exception('Missing "op" in operation data'); + throw new MissingFieldException('op', $operation); } if (!isset($operation->path)) { - throw new Exception('Missing "path" in operation data'); + throw new MissingFieldException('path', $operation); } $op = null; @@ -88,18 +92,18 @@ public static function import(array $data) $op = new Test(); break; default: - throw new Exception('Unknown "op": ' . $operation->op); + throw new UnknownOperationException($operation); } $op->path = $operation->path; if ($op instanceof OpPathValue) { if (property_exists($operation, 'value')) { $op->value = $operation->value; } else { - throw new Exception('Missing "value" in operation data'); + throw new MissingFieldException('value', $operation); } } elseif ($op instanceof OpPathFrom) { if (!isset($operation->from)) { - throw new Exception('Missing "from" in operation data'); + throw new MissingFieldException('from', $operation); } $op->from = $operation->from; } @@ -141,20 +145,26 @@ public function apply(&$original, $stopOnError = true) $errors = array(); foreach ($this->operations as $operation) { try { + // track the current pointer field so we can use it for a potential PathException + $pointerField = 'path'; $pathItems = JsonPointer::splitPath($operation->path); switch (true) { case $operation instanceof Add: JsonPointer::add($original, $pathItems, $operation->value, $this->flags); break; case $operation instanceof Copy: + $pointerField = 'from'; $fromItems = JsonPointer::splitPath($operation->from); $value = JsonPointer::get($original, $fromItems); + $pointerField = 'path'; JsonPointer::add($original, $pathItems, $value, $this->flags); break; case $operation instanceof Move: + $pointerField = 'from'; $fromItems = JsonPointer::splitPath($operation->from); $value = JsonPointer::get($original, $fromItems); JsonPointer::remove($original, $fromItems, $this->flags); + $pointerField = 'path'; JsonPointer::add($original, $pathItems, $value, $this->flags); break; case $operation instanceof Remove: @@ -170,11 +180,22 @@ public function apply(&$original, $stopOnError = true) $diff = new JsonDiff($operation->value, $value, JsonDiff::STOP_ON_DIFF); if ($diff->getDiffCnt() !== 0) { - throw new PatchTestOperationFailedException('Test operation ' . json_encode($operation, JSON_UNESCAPED_SLASHES) - . ' failed: ' . json_encode($value)); + throw new PatchTestOperationFailedException($operation, $value); } break; } + } catch (JsonPointerException $jsonPointerException) { + $pathException = new PathException( + $jsonPointerException->getMessage(), + $operation, + $pointerField, + $jsonPointerException->getCode() + ); + if ($stopOnError) { + throw $pathException; + } else { + $errors[] = $pathException; + } } catch (Exception $exception) { if ($stopOnError) { throw $exception; @@ -185,4 +206,4 @@ public function apply(&$original, $stopOnError = true) } return $errors; } -} \ No newline at end of file +} diff --git a/src/JsonPointer.php b/src/JsonPointer.php index d9fc2e0..0b950a4 100644 --- a/src/JsonPointer.php +++ b/src/JsonPointer.php @@ -66,7 +66,7 @@ public static function splitPath($path) return self::splitPathURIFragment($pathItems); } else { if ($first !== '') { - throw new Exception('Path must start with "/": ' . $path); + throw new JsonPointerException('Path must start with "/": ' . $path); } return self::splitPathJsonString($pathItems); } @@ -105,7 +105,7 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV while (null !== $key = array_shift($pathItems)) { if ($ref instanceof \stdClass || is_object($ref)) { if (PHP_VERSION_ID < 70100 && '' === $key) { - throw new Exception('Empty property name is not supported by PHP <7.1', + throw new JsonPointerException('Empty property name is not supported by PHP <7.1', Exception::EMPTY_PROPERTY_NAME_UNSUPPORTED); } @@ -113,7 +113,7 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV $ref = &$ref->$key; } else { if (!isset($ref->$key) && count($pathItems)) { - throw new Exception('Non-existent path item: ' . $key); + throw new JsonPointerException('Non-existent path item: ' . $key); } else { $ref = &$ref->$key; } @@ -126,7 +126,7 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV $ref = new \stdClass(); $ref = &$ref->{$key}; } else { - throw new Exception('Non-existent path item: ' . $key); + throw new JsonPointerException('Non-existent path item: ' . $key); } } elseif ([] === $ref && 0 === ($flags & self::STRICT_MODE) && false === $intKey && '-' !== $key) { $ref = new \stdClass(); @@ -138,7 +138,7 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV } else { if (false === $intKey) { if (0 === ($flags & self::TOLERATE_ASSOCIATIVE_ARRAYS)) { - throw new Exception('Invalid key for array operation'); + throw new JsonPointerException('Invalid key for array operation'); } $ref = &$ref[$key]; continue; @@ -148,9 +148,9 @@ public static function add(&$holder, $pathItems, $value, $flags = self::RECURSIV } if (0 === ($flags & self::TOLERATE_ASSOCIATIVE_ARRAYS)) { if ($intKey > count($ref) && 0 === ($flags & self::RECURSIVE_KEY_CREATION)) { - throw new Exception('Index is greater than number of items in array'); + throw new JsonPointerException('Index is greater than number of items in array'); } elseif ($intKey < 0) { - throw new Exception('Negative index'); + throw new JsonPointerException('Negative index'); } } @@ -203,7 +203,7 @@ public static function get($holder, $pathItems) while (null !== $key = array_shift($pathItems)) { if ($ref instanceof \stdClass) { if (PHP_VERSION_ID < 70100 && '' === $key) { - throw new Exception('Empty property name is not supported by PHP <7.1', + throw new JsonPointerException('Empty property name is not supported by PHP <7.1', Exception::EMPTY_PROPERTY_NAME_UNSUPPORTED); } @@ -211,22 +211,22 @@ public static function get($holder, $pathItems) if (self::arrayKeyExists($key, $vars)) { $ref = self::arrayGet($key, $vars); } else { - throw new Exception('Key not found: ' . $key); + throw new JsonPointerException('Key not found: ' . $key); } } elseif (is_array($ref)) { if (self::arrayKeyExists($key, $ref)) { $ref = $ref[$key]; } else { - throw new Exception('Key not found: ' . $key); + throw new JsonPointerException('Key not found: ' . $key); } } elseif (is_object($ref)) { if (isset($ref->$key)) { $ref = $ref->$key; } else { - throw new Exception('Key not found: ' . $key); + throw new JsonPointerException('Key not found: ' . $key); } } else { - throw new Exception('Key not found: ' . $key); + throw new JsonPointerException('Key not found: ' . $key); } } return $ref; @@ -260,19 +260,19 @@ public static function remove(&$holder, $pathItems, $flags = 0) if (property_exists($ref, $key)) { $ref = &$ref->$key; } else { - throw new Exception('Key not found: ' . $key); + throw new JsonPointerException('Key not found: ' . $key); } } elseif (is_object($ref)) { if (isset($ref->$key)) { $ref = &$ref->$key; } else { - throw new Exception('Key not found: ' . $key); + throw new JsonPointerException('Key not found: ' . $key); } } else { if (array_key_exists($key, $ref)) { $ref = &$ref[$key]; } else { - throw new Exception('Key not found: ' . $key); + throw new JsonPointerException('Key not found: ' . $key); } } } diff --git a/src/JsonPointerException.php b/src/JsonPointerException.php new file mode 100644 index 0000000..480a301 --- /dev/null +++ b/src/JsonPointerException.php @@ -0,0 +1,5 @@ +missingField = $missingField; + $this->operation = $operation; + } + + /** + * @return string + */ + public function getMissingField() + { + return $this->missingField; + } + + /** + * @return object + */ + public function getOperation() + { + return $this->operation; + } +} diff --git a/src/PatchTestOperationFailedException.php b/src/PatchTestOperationFailedException.php index 8fc7431..c2ded02 100644 --- a/src/PatchTestOperationFailedException.php +++ b/src/PatchTestOperationFailedException.php @@ -3,6 +3,47 @@ namespace Swaggest\JsonDiff; +use Throwable; + class PatchTestOperationFailedException extends Exception { -} \ No newline at end of file + /** @var object */ + private $operation; + /** @var string */ + private $actualValue; + + /** + * @param object $operation + * @param string $actualValue + * @param int $code + * @param Throwable|null $previous + */ + public function __construct( + $operation, + $actualValue, + $code = 0, + Throwable $previous = null + ) + { + parent::__construct('Test operation ' . json_encode($operation, JSON_UNESCAPED_SLASHES) + . ' failed: ' . json_encode($actualValue), $code, $previous); + $this->operation = $operation; + $this->actualValue = $actualValue; + } + + /** + * @return object + */ + public function getOperation() + { + return $this->operation; + } + + /** + * @return string + */ + public function getActualValue() + { + return $this->actualValue; + } +} diff --git a/src/PathException.php b/src/PathException.php new file mode 100644 index 0000000..c96f83f --- /dev/null +++ b/src/PathException.php @@ -0,0 +1,51 @@ +operation = $operation; + $this->field = $field; + } + + /** + * @return object + */ + public function getOperation() + { + return $this->operation; + } + + /** + * @return string + */ + public function getField() + { + return $this->field; + } +} diff --git a/src/UnknownOperationException.php b/src/UnknownOperationException.php new file mode 100644 index 0000000..0855bb2 --- /dev/null +++ b/src/UnknownOperationException.php @@ -0,0 +1,36 @@ +op, $code, $previous); + $this->operation = $operation; + } + + /** + * @return object + */ + public function getOperation() + { + return $this->operation; + } +} diff --git a/tests/src/JsonPatchTest.php b/tests/src/JsonPatchTest.php index 0870442..e15c910 100644 --- a/tests/src/JsonPatchTest.php +++ b/tests/src/JsonPatchTest.php @@ -5,7 +5,11 @@ use Swaggest\JsonDiff\Exception; use Swaggest\JsonDiff\JsonDiff; use Swaggest\JsonDiff\JsonPatch; +use Swaggest\JsonDiff\JsonPatch\OpPath; +use Swaggest\JsonDiff\MissingFieldException; use Swaggest\JsonDiff\PatchTestOperationFailedException; +use Swaggest\JsonDiff\PathException; +use Swaggest\JsonDiff\UnknownOperationException; class JsonPatchTest extends \PHPUnit_Framework_TestCase { @@ -75,8 +79,16 @@ public function testNull() public function testMissingOp() { - $this->setExpectedException(get_class(new Exception()), 'Missing "op" in operation data'); - JsonPatch::import(array((object)array('path' => '/123'))); + $operation = (object)array('path' => '/123'); + try { + JsonPatch::import(array($operation)); + $this->fail('Expected exception was not thrown'); + } catch (Exception $exception) { + $this->assertInstanceOf(MissingFieldException::class, $exception); + $this->assertSame('Missing "op" in operation data', $exception->getMessage()); + $this->assertSame('op', $exception->getMissingField()); + $this->assertSame($operation, $exception->getOperation()); + } } public function testMissingPath() @@ -87,8 +99,15 @@ public function testMissingPath() public function testInvalidOp() { - $this->setExpectedException(get_class(new Exception()), 'Unknown "op": wat'); - JsonPatch::import(array((object)array('op' => 'wat', 'path' => '/123'))); + $operation = (object)array('op' => 'wat', 'path' => '/123'); + try { + JsonPatch::import(array($operation)); + $this->fail('Expected exception was not thrown'); + } catch (Exception $exception) { + $this->assertInstanceOf(UnknownOperationException::class, $exception); + $this->assertSame('Unknown "op": wat', $exception->getMessage()); + $this->assertSame($operation, $exception->getOperation()); + } } public function testMissingFrom() @@ -145,11 +164,100 @@ public function testApplyNonExistentLevelOne() public function testTestOperationFailed() { - $data = array('abc' => 'xyz'); + $actualValue = 'xyz'; + $data = array('abc' => $actualValue); + $operation = new JsonPatch\Test('/abc', 'def'); + $p = new JsonPatch(); - $p->op(new JsonPatch\Test('/abc', 'def')); - $errors = $p->apply($data, false); + $p->op($operation); + $testError = $p->apply($data, false)[0]; + $this->assertInstanceOf(PatchTestOperationFailedException::class, $testError); + $this->assertSame($operation, $testError->getOperation()); + $this->assertSame($actualValue, $testError->getActualValue()); + } + + public function testPathExceptionContinueOnError() + { + $actualValue = 'xyz'; + $data = array('abc' => $actualValue); + $patch = new JsonPatch(); + + $operation1 = new JsonPatch\Test('/abc', 'def'); + $patch->op($operation1); + + $operation2 = new JsonPatch\Move('/target', '/source'); + $patch->op($operation2); + + $errors = $patch->apply($data, false); + $this->assertInstanceOf(PatchTestOperationFailedException::class, $errors[0]); + $this->assertSame($operation1, $errors[0]->getOperation()); + + $this->assertInstanceOf(PathException::class, $errors[1]); + $this->assertSame($operation2, $errors[1]->getOperation()); + $this->assertSame('from', $errors[1]->getField()); } -} \ No newline at end of file + public function pathExceptionProvider() { + return [ + 'splitPath_path' => [ + new JsonPatch\Copy('invalid/path', '/valid/from'), + 'Path must start with "/": invalid/path', + 'path' + ], + 'splitPath_from' => [ + new JsonPatch\Copy('/valid/path', 'invalid/from'), + 'Path must start with "/": invalid/from', + 'from' + ], + 'add' => [ + new JsonPatch\Add('/some/path', 22), + 'Non-existent path item: some', + 'path' + ], + 'get_from' => [ + new JsonPatch\Copy('/target', '/source'), + 'Key not found: source', + 'from' + ], + 'get_path' => [ + new JsonPatch\Replace('/some/path', 23), + 'Key not found: some', + 'path' + ], + 'remove_from' => [ + new JsonPatch\Move('/target', '/source'), + 'Key not found: source', + 'from' + ], + 'remove_path' => [ + new JsonPatch\Remove('/some/path'), + 'Key not found: some', + 'path' + ] + ]; + } + + /** + * @param OpPath $operation + * @param string $expectedMessage + * @param string $expectedField + * + * @dataProvider pathExceptionProvider + */ + public function testPathException(OpPath $operation, $expectedMessage, $expectedField) { + $data = new \stdClass(); + $patch = new JsonPatch(); + + $patch->op($operation); + + try { + $patch->apply($data ); + $this->fail('PathException expected'); + } catch (Exception $ex) { + $this->assertInstanceOf(PathException::class, $ex); + $this->assertEquals($expectedMessage, $ex->getMessage()); + $this->assertEquals($expectedField, $ex->getField()); + } + } +} diff --git a/tests/src/JsonPointerTest.php b/tests/src/JsonPointerTest.php index d17a2fc..bf025c3 100644 --- a/tests/src/JsonPointerTest.php +++ b/tests/src/JsonPointerTest.php @@ -5,6 +5,7 @@ use Swaggest\JsonDiff\Exception; use Swaggest\JsonDiff\JsonPointer; +use Swaggest\JsonDiff\JsonPointerException; class JsonPointerTest extends \PHPUnit_Framework_TestCase { @@ -30,7 +31,7 @@ public function testProcess() try { $this->assertSame('null', json_encode(JsonPointer::get($json, JsonPointer::splitPath('/l1/l2/non-existent')))); - } catch (Exception $exception) { + } catch (JsonPointerException $exception) { $this->assertSame('Key not found: non-existent', $exception->getMessage()); } @@ -89,7 +90,7 @@ public function testGetSetDeleteObject() try { JsonPointer::get($s, ['one', 'two']); $this->fail('Exception expected'); - } catch (Exception $e) { + } catch (JsonPointerException $e) { $this->assertEquals('Key not found: two', $e->getMessage()); } $this->assertEquals(null, $s->one->two);