diff --git a/README.md b/README.md index 2046e55..094fe1f 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,64 @@ The expected workflow is that once you got a `FastJsonPatch` instance you can ca Patch application is designed to be atomic. If any operation of a given patch fails the original document is restored, ensuring a consistent state of the document. +If you are building patches within your application, rather than receiving them from an external source, you may wish +to build them as native PHP objects. This provides strict typing of the available parameters for each operation. + +The above example could also be represented as: + +```php +use blancks\JsonPatch\FastJsonPatch; +use blancks\JsonPatch\exceptions\FastJsonPatchException; +use blancks\JsonPatch\operations\PatchOperationList; +use blancks\JsonPatch\operations\Add; +use blancks\JsonPatch\operations\Replace; +use blancks\JsonPatch\operations\Remove; + +$document = '{ + "contacts":[ + {"name":"John","number":"-"}, + {"name":"Dave","number":"+1 222 333 4444"} + ] +}'; + +$patch = new PatchOperationList( + new Add(path: '/contacts/-', value: ['name' => 'Jane', 'number' => '+1 353 644 2121']), + new Replace(path: '/contacts/0/number', value: '+1 212 555 1212'), + new Remove(path: '/contacts/1'), +); + +$FastJsonPatch = FastJsonPatch::fromJson($document); + +try { + + $FastJsonPatch->apply($patch); + +} catch (FastJsonPatchException $e) { + + // here if patch cannot be applied for some reason + echo $e->getMessage(), "\n"; + +} + +var_dump($FastJsonPatch->getDocument()); +``` + +### Should I use DTOs or JSON strings for patches? + +The exact answer will depend on your usecase, but broadly speaking: + +* If your patches are coming from an external or serialized source, keep them as JSON strings. This provides a clearer + and more forgiving validation process (for example, if the patch has missing or additional properties). It also avoids + any (limited) performance overhead to build the patch as typed objects. +* If you are building patches at runtime in your own application, consider using DTOs. This provides additional + type-safety within your code, and may be more efficient than serialising a patch to JSON and back. + +When working with DTOs, the `PatchOperationList` can be serialized to JSON using the native `json_encode` (or any method +that supports the `JsonSerializable` interface). It can also be unserialized from JSON - and you can optionally provide +a JSON handler and a mapping of `PatchOperation` classes to customise the parsing. + +Note that - unlike the JSON format - the core operation DTOs do not accept any additional parameters. If you need to +include additional parameters in your patch you can provide your own `PatchOperation` implementation(s). ## Constructor diff --git a/src/FastJsonPatch.php b/src/FastJsonPatch.php index eae4c2d..c925db2 100644 --- a/src/FastJsonPatch.php +++ b/src/FastJsonPatch.php @@ -21,9 +21,6 @@ JsonPointerHandlerAwareTrait, JsonPointerHandlerInterface }; -use blancks\JsonPatch\operations\{ - PatchValidationTrait -}; use blancks\JsonPatch\operations\handlers\{ AddHandler, CopyHandler, @@ -33,6 +30,10 @@ ReplaceHandler, TestHandler }; +use blancks\JsonPatch\operations\{ + PatchOperationList, + PatchValidationTrait +}; /** * This class allow to perform a sequence of operations to apply to a target JSON document as per RFC 6902 @@ -123,11 +124,11 @@ public function registerOperationHandler(PatchOperationHandlerInterface $PatchOp /** * Applies the patch to the referenced document. * The operation is atomic, if the patch cannot be applied the original document is restored - * @param string $patch + * @param string|PatchOperationList $patch * @return void * @throws FastJsonPatchException */ - public function apply(string $patch): void + public function apply(string|PatchOperationList $patch): void { try { $revertPatch = []; @@ -204,18 +205,22 @@ public function &getDocument(): mixed } /** - * @param string $patch + * @param string|PatchOperationList $patch * @return \Generator & iterable */ - private function patchIterator(string $patch): \Generator + private function patchIterator(string|PatchOperationList $patch): \Generator { - $decodedPatch = $this->JsonHandler->decode($patch); + if (is_string($patch)) { + $patchOperations = $this->JsonHandler->decode($patch); - if (!is_array($decodedPatch)) { - throw new InvalidPatchException('Invalid patch structure'); + if (!is_array($patchOperations)) { + throw new InvalidPatchException('Invalid patch structure'); + } + } else { + $patchOperations = $patch->operations; } - foreach ($decodedPatch as $p) { + foreach ($patchOperations as $p) { $p = (object) $p; $this->assertValidOp($p); $this->assertValidPath($p); diff --git a/src/operations/Add.php b/src/operations/Add.php new file mode 100644 index 0000000..8e54237 --- /dev/null +++ b/src/operations/Add.php @@ -0,0 +1,20 @@ + + */ + public array $operations; + + /** + * @param string $jsonOperations + * @param JsonHandlerInterface $jsonHandler + * @param array> $customClasses + * @return self + */ + public static function fromJson( + string $jsonOperations, + JsonHandlerInterface $jsonHandler = new BasicJsonHandler(), + array $customClasses = [], + ): self { + $patches = $jsonHandler->decode($jsonOperations); + if (!(is_array($patches) && array_is_list($patches))) { + throw new InvalidPatchException( + sprintf('Invalid patch structure (expected list, got %s)', get_debug_type($patches)), + ); + } + + $classes = [ + 'add' => Add::class, + 'copy' => Copy::class, + 'move' => Move::class, + 'remove' => Remove::class, + 'replace' => Replace::class, + 'test' => Test::class, + ...$customClasses, + ]; + + return new PatchOperationList( + ...array_map( + function (mixed $patch) use ($classes) { + [$op, $values] = self::extractPatchOpAndValues($patch); + if (!isset($classes[$op])) { + throw new InvalidPatchOperationException(sprintf('Unknown operation "%s"', $op)); + } + return self::createPatchDtoFromValues($op, $classes[$op], $values); + }, + $patches + ), + ); + } + + /** + * @phpstan-return array{string, array} + */ + private static function extractPatchOpAndValues(mixed $patch): array + { + if (!$patch instanceof stdClass) { + throw new InvalidPatchOperationException( + sprintf('Each patch item must be an object, got %s', get_debug_type($patch)) + ); + } + + if (!isset($patch->op)) { + throw new InvalidPatchOperationException('Each patch item must specify "op"'); + } + + if (!is_string($patch->op)) { + throw new InvalidPatchOperationException( + sprintf('Patch "op" must be a string, got %s', get_debug_type($patch->op)) + ); + } + + $op = $patch->op; + unset($patch->op); + + $values = []; + foreach ((array) $patch as $key => $value) { + // We rely on the properties being strings, to ensure that PHP will enforce that all named properties are + // present / defined when creating the arbitrary DTO object. Ensure this is the case + if (!is_string($key)) { + throw new InvalidPatchOperationException('All patch operation properties must have string names'); + } + $values[$key] = $value; + } + + return [$op, $values]; + } + + /** + * @phpstan-param class-string $class + * @param array $values + * @return PatchOperation + * + * @throws InvalidPatchOperationException + */ + private static function createPatchDtoFromValues(string $op, string $class, array $values): PatchOperation + { + try { + return new $class(...$values); + } catch (ArgumentCountError $e) { + // We can be relatively confident that this was triggered for the DTO constructor. If the DTO was calling a + // method (e.g. a parent method / internal helper method) with incorrect argument counts that should have + // been detected by its own tests. + throw new InvalidPatchOperationException( + sprintf('Missing required param(s) for %s operation as %s: %s', $op, $class, $e->getMessage()), + previous: $e, + ); + } catch (TypeError $e) { + // We can be relatively confident that this relates to the property types passed to the DTO constructor. + // If the DTO constructor has a type signature that does not match the expected supported values, that + // should have been detected by its own tests. + throw new InvalidPatchOperationException( + sprintf('Invalid param(s) for %s operation as %s: %s', $op, $class, $e->getMessage()), + previous: $e, + ); + } catch (Error $e) { + if (str_contains($e->getMessage(), 'Unknown named parameter')) { + // This does not have a dedicated error type so we have to match on the message. + throw new InvalidPatchOperationException( + sprintf('Unexpected param(s) for %s operation as %s: %s', $op, $class, $e->getMessage()), + previous: $e, + ); + } + throw $e; + } + } + + /** + * @no-named-arguments + */ + public function __construct( + PatchOperation ...$operations + ) { + $this->operations = $operations; + } + + /** + * @return list + */ + public function jsonSerialize(): array + { + return $this->operations; + } +} diff --git a/src/operations/Remove.php b/src/operations/Remove.php new file mode 100644 index 0000000..0a9450f --- /dev/null +++ b/src/operations/Remove.php @@ -0,0 +1,18 @@ + + */ + public static function validOperationsForDTOsProvider(): iterable + { + foreach (self::validOperationsProvider() as $name => $values) { + if ($name === 'Test with optional patch properties') { + // DTOs do not support arbitrary constructor parameters + continue; + } + + yield $name => $values; + } + } + + /** + * @param string $json + * @param string $patches + * @param string $expected + * @return void + * @throws \JsonException + * @throws FastJsonPatchException + */ + #[DataProvider('validOperationsForDTOsProvider')] + public function testValidJsonPatchesAsDTOs(string $json, string $patches, string $expected): void + { + $patch = PatchOperationList::fromJson($patches); + + $FastJsonPatch = FastJsonPatch::fromJson($json); + $FastJsonPatch->apply($patch); + + $this->assertSame( + $this->normalizeJson($expected), + $this->normalizeJson($this->jsonEncode($FastJsonPatch->getDocument())) + ); + } + /** * @param string $json * @param string $patches diff --git a/tests/operations/PatchOperationListTest.php b/tests/operations/PatchOperationListTest.php new file mode 100644 index 0000000..ef227c6 --- /dev/null +++ b/tests/operations/PatchOperationListTest.php @@ -0,0 +1,369 @@ +, string}> + */ + public static function validEncodeDecodeProvider(): array + { + $objValue = new stdClass(); + $objValue->type = 'foo'; + $objValue->list = ['bar', 'baz']; + + return [ + 'empty list' => [ + [], + '[]', + ], + 'single operation' => [ + [ + new Add(path: '/foo', value: 'World'), + ], + '[{"op": "add", "path": "/foo", "value": "World"}]', + ], + 'all supported operations' => [ + [ + new Add(path: '/bar', value: 'Worldy'), + new Copy(path: '/foo', from: '/bar'), + new Move(path: '/food', from: '/foo'), + new Remove(path: '/food'), + new Replace(path: '/bar', value: 'World'), + new Test(path: '/bar', value: 'World'), + ], + <<<'JSON' + [ + {"op":"add","path":"/bar","value":"Worldy"}, + {"op":"copy","path":"/foo","from":"/bar"}, + {"op":"move","path":"/food","from":"/foo"}, + {"op":"remove","path":"/food"}, + {"op":"replace","path":"/bar","value":"World"}, + {"op":"test","path":"/bar","value":"World"} + ] + JSON, + ], + 'operations with object values' => [ + [ + new Add(path: '/bar', value: $objValue), + new Replace(path: '/bar', value: $objValue), + new Test(path: '/bar', value: $objValue), + ], + <<<'JSON' + [ + {"op":"add","path":"/bar","value":{"type":"foo","list":["bar","baz"]}}, + {"op":"replace","path":"/bar","value":{"type":"foo","list":["bar","baz"]}}, + {"op":"test","path":"/bar","value":{"type":"foo","list":["bar","baz"]}} + ] + JSON, + ], + ]; + } + + /** + * @param list $operationDTOs + * @param string $jsonOperations + * @return void + * @throws \JsonException + */ + #[DataProvider('validEncodeDecodeProvider')] + public function testItCanBeEncodedToJsonPatch(array $operationDTOs, string $jsonOperations): void + { + $this->assertSame( + $this->normalizeJson($jsonOperations), + $this->normalizeJson($this->jsonEncode(new PatchOperationList(...$operationDTOs))), + ); + } + + /** + * @param list $operationDTOs + * @param string $jsonOperations + * @return void + * @throws \JsonException + */ + #[DataProvider('validEncodeDecodeProvider')] + public function testItCanBeBuiltFromEncodedJson(array $operationDTOs, string $jsonOperations): void + { + $this->assertEquals( + new PatchOperationList(...$operationDTOs), + PatchOperationList::fromJson($jsonOperations), + ); + } + + public function testItCanUseCustomHandlerWhenDecodingFromJson(): void + { + $result = PatchOperationList::fromJson( + '[fake]', + jsonHandler: new class implements JsonHandlerInterface { + public function write(mixed &$document, string $path, mixed $value): mixed + { + throw new \BadMethodCallException(__METHOD__); + } + + public function &read(mixed &$document, string $path): mixed + { + throw new \BadMethodCallException(__METHOD__); + } + + public function update(mixed &$document, string $path, mixed $value): mixed + { + throw new \BadMethodCallException(__METHOD__); + } + + public function delete(mixed &$document, string $path): mixed + { + throw new \BadMethodCallException(__METHOD__); + } + + public function encode(mixed $document, array $options = []): string + { + throw new \BadMethodCallException(__METHOD__); + } + + public function decode(string $json, array $options = []): mixed + { + Assert::assertSame( + [ + 'json' => '[fake]', + 'options' => [], + ], + get_defined_vars(), + 'JSONHandler should have been called with expected args', + ); + return [ + (object) ['op' => 'remove', 'path' => '/some/path'], + ]; + } + }, + ); + + $this->assertEquals( + new PatchOperationList(new Remove('/some/path')), + $result, + ); + } + + public function testItCanDecodeWithCustomOperationClasses(): void + { + $result = PatchOperationList::fromJson( + <<<'JSON' + [ + {"op": "add", "path": "/greeting", "value": "Hello", "custom": "my own var" }, + {"op": "append", "path": "/greeting", "suffix": " World" }, + {"op": "copy", "path": "/whatever", "from": "/greeting"} + ] + JSON, + customClasses: [ + 'add' => CustomAdd::class, + 'append' => Append::class, + ], + ); + + $this->assertEquals( + new PatchOperationList( + new CustomAdd('/greeting', 'Hello', 'my own var'), + new Append('/greeting', ' World'), + new Copy(path: '/whatever', from: '/greeting'), + ), + $result, + ); + } + + /** + * @return array, string}> + */ + public static function invalidJsonProvider(): array + { + return [ + 'json is not a list (example 1)' => [ + '{"some": "field"}', + InvalidPatchException::class, + 'Invalid patch structure (expected list, got stdClass)', + ], + 'json is not a list (example 2)' => [ + 'true', + InvalidPatchException::class, + 'Invalid patch structure (expected list, got bool)', + ], + 'json is not a list (example 3)' => [ + '{"2": {"op": "add", "path": "/some/path", "value": "World"}}', + InvalidPatchException::class, + 'Invalid patch structure (expected list, got stdClass)', + ], + 'patch item is not an object (example 1)' => [ + '[["foo"]]', + InvalidPatchOperationException::class, + 'Each patch item must be an object, got array', + ], + 'patch item is not an object (example 2)' => [ + '[{"op": "remove", "path": "/some/path"}, true]', + InvalidPatchOperationException::class, + 'Each patch item must be an object, got bool', + ], + 'patch without op property' => [ + '[{"path": "/some/path", "value": "anything"}]', + InvalidPatchOperationException::class, + 'Each patch item must specify "op"', + ], + 'op is invalid type' => [ + '[{"op": true}]', + InvalidPatchOperationException::class, + 'Patch "op" must be a string, got bool', + ], + 'unknown operation' => [ + '[{"op": "scramble", "path": "/anywhere"}]', + InvalidPatchOperationException::class, + 'Unknown operation "scramble"', + ], + 'unexpected extra operation property' => [ + '[{"op":"add", "path": "/foo", "value": "bar", "custom": "things"}]', + InvalidPatchOperationException::class, + // The message also contains the original PHP message but this varies between versions so we do not + // include it in the assertion. + sprintf( + 'Unexpected param(s) for add operation as %s', + Add::class, + ), + ], + 'missing operation property' => [ + '[{"op":"add", "path": "/foo"}]', + InvalidPatchOperationException::class, + // The message also contains the original PHP message but this varies between versions so we do not + // include it in the assertion. + sprintf( + 'Missing required param(s) for add operation as %s', + Add::class, + ), + ], + 'operation property has incorrect type' => [ + '[{"op":"add", "path": true, "value": "bar"}]', + InvalidPatchOperationException::class, + // The message also contains the original PHP message but this varies between versions so we do not + // include it in the assertion. + sprintf( + 'Invalid param(s) for add operation as %s', + Add::class, + ), + ], + 'operation with mixed string and int keys (example 1)' => [ + // PHP would internally convert this into positional args and accept it even though there's no guarantee + // we have the right keys in the right sequence / have no extra properties. + '[{"op":"add", "0": "bar", "1": "/from"}]', + InvalidPatchOperationException::class, + 'All patch operation properties must have string names', + ], + 'operation with mixed string and int keys (example 2)' => [ + // PHP would internally convert this into positional args and accept it even though there's no guarantee + // we have the right keys in the right sequence / have no extra properties. + '[{"op":"add", "1": "bar", "2": "/from"}]', + InvalidPatchOperationException::class, + 'All patch operation properties must have string names', + ], + ]; + } + + /** + * @param string $json + * @param class-string $expect_exception + * @param string $expect_msg + * @return void + */ + #[DataProvider('invalidJsonProvider')] + public function testItThrowsOnAttemptToCreateFromInvalidJson(string $json, string $expect_exception, string $expect_msg): void + { + $this->expectException($expect_exception); + $this->expectExceptionMessage($expect_msg); + PatchOperationList::fromJson($json); + } + + public function testItsFromJsonBubblesGenericPhpErrors(): void + { + // Very contrived example to prove that our code rethrows any unexpected PHP errors during DTO creation + $this->expectException(DivisionByZeroError::class); + PatchOperationList::fromJson( + <<<'JSON' + [{"op": "fraction", "numerator": 1, "denominator": 0}] + JSON, + customClasses: ['fraction' => Fraction::class] + ); + } +} + +readonly class Append extends PatchOperation +{ + public function __construct( + public string $path, + public string $suffix, + ) { + parent::__construct('append'); + } +} + +readonly class CustomAdd extends PatchOperation +{ + public function __construct( + public string $path, + public mixed $value, + public mixed $custom, + ) { + parent::__construct('add'); + } +} + +readonly class Fraction extends PatchOperation +{ + public float $fraction; + + public function __construct( + public int $numerator, + public int $denominator, + ) { + parent::__construct('fraction'); + $this->fraction = $this->numerator / $this->denominator; + } +}