diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml index b22f28f4..53c085c5 100644 --- a/.github/workflows/integrate.yaml +++ b/.github/workflows/integrate.yaml @@ -9,8 +9,8 @@ on: # yamllint disable-line rule:truthy - "master" env: - MIN_COVERED_MSI: 96 - MIN_MSI: 96 + MIN_COVERED_MSI: 91 + MIN_MSI: 91 REQUIRED_PHP_EXTENSIONS: "mbstring" jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 38959d76..53624d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), For a full diff see [`0.11.0...master`][0.11.0...master]. +### Added + +* Added `SchemaValidator::validate()`, which returns a `Result` composing validation error messages ([#268]), by [@localheinz] + ## [`0.11.0`][0.11.0] For a full diff see [`0.10.1...0.11.0`][0.10.1...0.11.0]. @@ -307,6 +311,7 @@ For a full diff see [`5d8b3e2...0.1.0`][5d8b3e2...0.1.0]. [#191]: https://github.com/ergebnis/json-normalizer/pull/191 [#202]: https://github.com/ergebnis/json-normalizer/pull/202 [#203]: https://github.com/ergebnis/json-normalizer/pull/203 +[#268]: https://github.com/ergebnis/json-normalizer/pull/268 [@BackEndTea]: https://github.com/BackEndTea [@ergebnis]: https://github.com/ergebnis diff --git a/Makefile b/Makefile index 74045c71..3ce994e4 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -MIN_COVERED_MSI:=96 -MIN_MSI:=96 +MIN_COVERED_MSI:=91 +MIN_MSI:=91 .PHONY: it it: coding-standards static-code-analysis tests ## Runs the coding-standards, static-code-analysis, and tests targets diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 12f4a335..e1dd7eb9 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -176,11 +176,19 @@ - + + $data + $schema + $data + $schema $data $schema - + + $data + $schema + $data + $schema $data $schema diff --git a/src/Exception/NormalizedInvalidAccordingToSchemaException.php b/src/Exception/NormalizedInvalidAccordingToSchemaException.php index ab741434..948b529d 100644 --- a/src/Exception/NormalizedInvalidAccordingToSchemaException.php +++ b/src/Exception/NormalizedInvalidAccordingToSchemaException.php @@ -20,7 +20,12 @@ final class NormalizedInvalidAccordingToSchemaException extends \RuntimeExceptio */ private $schemaUri = ''; - public static function fromSchemaUri(string $schemaUri): self + /** + * @var string[] + */ + private $errors = []; + + public static function fromSchemaUriAndErrors(string $schemaUri, string ...$errors): self { $exception = new self(\sprintf( 'Normalized JSON is not valid according to schema "%s".', @@ -28,6 +33,7 @@ public static function fromSchemaUri(string $schemaUri): self )); $exception->schemaUri = $schemaUri; + $exception->errors = $errors; return $exception; } @@ -36,4 +42,12 @@ public function schemaUri(): string { return $this->schemaUri; } + + /** + * @return string[] + */ + public function errors(): array + { + return $this->errors; + } } diff --git a/src/Exception/OriginalInvalidAccordingToSchemaException.php b/src/Exception/OriginalInvalidAccordingToSchemaException.php index 224101a2..5bb827b9 100644 --- a/src/Exception/OriginalInvalidAccordingToSchemaException.php +++ b/src/Exception/OriginalInvalidAccordingToSchemaException.php @@ -20,7 +20,12 @@ final class OriginalInvalidAccordingToSchemaException extends \RuntimeException */ private $schemaUri = ''; - public static function fromSchemaUri(string $schemaUri): self + /** + * @var string[] + */ + private $errors = []; + + public static function fromSchemaUriAndErrors(string $schemaUri, string ...$errors): self { $exception = new self(\sprintf( 'Original JSON is not valid according to schema "%s".', @@ -28,6 +33,7 @@ public static function fromSchemaUri(string $schemaUri): self )); $exception->schemaUri = $schemaUri; + $exception->errors = $errors; return $exception; } @@ -36,4 +42,12 @@ public function schemaUri(): string { return $this->schemaUri; } + + /** + * @return string[] + */ + public function errors(): array + { + return $this->errors; + } } diff --git a/src/SchemaNormalizer.php b/src/SchemaNormalizer.php index 098b7760..0c7bbeee 100644 --- a/src/SchemaNormalizer.php +++ b/src/SchemaNormalizer.php @@ -63,8 +63,16 @@ public function normalize(Json $json): Json throw Exception\SchemaUriReferencesInvalidJsonDocumentException::fromSchemaUri($this->schemaUri); } - if (!$this->schemaValidator->isValid($decoded, $schema)) { - throw Exception\OriginalInvalidAccordingToSchemaException::fromSchemaUri($this->schemaUri); + $resultBeforeNormalization = $this->schemaValidator->validate( + $decoded, + $schema + ); + + if (!$resultBeforeNormalization->isValid()) { + throw Exception\OriginalInvalidAccordingToSchemaException::fromSchemaUriAndErrors( + $this->schemaUri, + ...$resultBeforeNormalization->errors() + ); } $normalized = $this->normalizeData( @@ -72,8 +80,16 @@ public function normalize(Json $json): Json $schema ); - if (!$this->schemaValidator->isValid($normalized, $schema)) { - throw Exception\NormalizedInvalidAccordingToSchemaException::fromSchemaUri($this->schemaUri); + $resultAfterNormalization = $this->schemaValidator->validate( + $normalized, + $schema + ); + + if (!$resultAfterNormalization->isValid()) { + throw Exception\NormalizedInvalidAccordingToSchemaException::fromSchemaUriAndErrors( + $this->schemaUri, + ...$resultAfterNormalization->errors() + ); } /** @var string $encoded */ @@ -214,7 +230,12 @@ private function resolveSchema($data, \stdClass $schema): \stdClass */ if (\property_exists($schema, 'oneOf') && \is_array($schema->oneOf)) { foreach ($schema->oneOf as $oneOfSchema) { - if ($this->schemaValidator->isValid($data, $oneOfSchema)) { + $result = $this->schemaValidator->validate( + $data, + $oneOfSchema + ); + + if ($result->isValid()) { return $this->resolveSchema( $data, $oneOfSchema diff --git a/src/Validator/Result.php b/src/Validator/Result.php new file mode 100644 index 00000000..154dfd5e --- /dev/null +++ b/src/Validator/Result.php @@ -0,0 +1,45 @@ +errors = $errors; + } + + public static function create(string ...$errors): self + { + return new self(...$errors); + } + + public function isValid(): bool + { + return [] === $this->errors; + } + + /** + * @return string[] + */ + public function errors(): array + { + return $this->errors; + } +} diff --git a/src/Validator/SchemaValidator.php b/src/Validator/SchemaValidator.php index a4a5ccba..a331121e 100644 --- a/src/Validator/SchemaValidator.php +++ b/src/Validator/SchemaValidator.php @@ -38,4 +38,55 @@ public function isValid($data, \stdClass $schema): bool return $this->validator->isValid(); } + + public function validate($data, \stdClass $schema): Result + { + $this->validator->reset(); + + $this->validator->check( + $data, + $schema + ); + + /** @var array $originalErrors */ + $originalErrors = $this->validator->getErrors(); + + $errors = \array_map(static function (array $error): string { + $property = ''; + + if ( + \array_key_exists('property', $error) + && \is_string($error['property']) + && '' !== \trim($error['property']) + ) { + $property = \trim($error['property']); + } + + $message = ''; + + if ( + \array_key_exists('message', $error) + && \is_string($error['message']) + && '' !== \trim($error['message']) + ) { + $message = \trim($error['message']); + } + + if ('' === $property) { + return $message; + } + + return \sprintf( + '%s: %s', + $property, + $message + ); + }, $originalErrors); + + $filtered = \array_filter($errors, static function (string $error): bool { + return '' !== $error; + }); + + return Result::create(...$filtered); + } } diff --git a/src/Validator/SchemaValidatorInterface.php b/src/Validator/SchemaValidatorInterface.php index d06a51c0..9b561143 100644 --- a/src/Validator/SchemaValidatorInterface.php +++ b/src/Validator/SchemaValidatorInterface.php @@ -22,4 +22,12 @@ interface SchemaValidatorInterface * @return bool */ public function isValid($data, \stdClass $schema): bool; + + /** + * @param null|array|bool|float|int|\stdClass|string $data + * @param \stdClass $schema + * + * @return Result + */ + public function validate($data, \stdClass $schema): Result; } diff --git a/test/Unit/Exception/NormalizedInvalidAccordingToSchemaExceptionTest.php b/test/Unit/Exception/NormalizedInvalidAccordingToSchemaExceptionTest.php index 2129dbf3..a034cc02 100644 --- a/test/Unit/Exception/NormalizedInvalidAccordingToSchemaExceptionTest.php +++ b/test/Unit/Exception/NormalizedInvalidAccordingToSchemaExceptionTest.php @@ -27,13 +27,25 @@ public function testDefaults(): void $exception = new NormalizedInvalidAccordingToSchemaException(); self::assertSame('', $exception->schemaUri()); + self::assertSame([], $exception->errors()); } public function testFromSchemaUriReturnsNormalizedInvalidAccordingToSchemaException(): void { - $schemaUri = self::faker()->url; + $faker = self::faker(); - $exception = NormalizedInvalidAccordingToSchemaException::fromSchemaUri($schemaUri); + $schemaUri = $faker->url; + + $errors = [ + $faker->sentence, + $faker->sentence, + $faker->sentence, + ]; + + $exception = NormalizedInvalidAccordingToSchemaException::fromSchemaUriAndErrors( + $schemaUri, + ...$errors + ); $message = \sprintf( 'Normalized JSON is not valid according to schema "%s".', @@ -42,5 +54,6 @@ public function testFromSchemaUriReturnsNormalizedInvalidAccordingToSchemaExcept self::assertSame($message, $exception->getMessage()); self::assertSame($schemaUri, $exception->schemaUri()); + self::assertSame($errors, $exception->errors()); } } diff --git a/test/Unit/Exception/OriginalInvalidAccordingToSchemaExceptionTest.php b/test/Unit/Exception/OriginalInvalidAccordingToSchemaExceptionTest.php index a873fa0d..0addb926 100644 --- a/test/Unit/Exception/OriginalInvalidAccordingToSchemaExceptionTest.php +++ b/test/Unit/Exception/OriginalInvalidAccordingToSchemaExceptionTest.php @@ -26,14 +26,26 @@ public function testDefaults(): void { $exception = new OriginalInvalidAccordingToSchemaException(); + self::assertSame([], $exception->errors()); self::assertSame('', $exception->schemaUri()); } - public function testFromSchemaUriReturnsOriginalInvalidAccordingToSchemaException(): void + public function testFromSchemaUriAndErrorsReturnsOriginalInvalidAccordingToSchemaException(): void { - $schemaUri = self::faker()->url; + $faker = self::faker(); - $exception = OriginalInvalidAccordingToSchemaException::fromSchemaUri($schemaUri); + $schemaUri = $faker->url; + + $errors = [ + $faker->sentence, + $faker->sentence, + $faker->sentence, + ]; + + $exception = OriginalInvalidAccordingToSchemaException::fromSchemaUriAndErrors( + $schemaUri, + ...$errors + ); $message = \sprintf( 'Original JSON is not valid according to schema "%s".', @@ -42,5 +54,6 @@ public function testFromSchemaUriReturnsOriginalInvalidAccordingToSchemaExceptio self::assertSame($message, $exception->getMessage()); self::assertSame($schemaUri, $exception->schemaUri()); + self::assertSame($errors, $exception->errors()); } } diff --git a/test/Unit/SchemaNormalizerTest.php b/test/Unit/SchemaNormalizerTest.php index fbb67fc6..244fd8a7 100644 --- a/test/Unit/SchemaNormalizerTest.php +++ b/test/Unit/SchemaNormalizerTest.php @@ -16,6 +16,7 @@ use Ergebnis\Json\Normalizer\Exception; use Ergebnis\Json\Normalizer\Json; use Ergebnis\Json\Normalizer\SchemaNormalizer; +use Ergebnis\Json\Normalizer\Validator\Result; use Ergebnis\Json\Normalizer\Validator\SchemaValidator; use Ergebnis\Json\Normalizer\Validator\SchemaValidatorInterface; use JsonSchema\Exception\InvalidSchemaMediaTypeException; @@ -38,6 +39,7 @@ * @uses \Ergebnis\Json\Normalizer\Exception\SchemaUriReferencesDocumentWithInvalidMediaTypeException * @uses \Ergebnis\Json\Normalizer\Exception\SchemaUriReferencesInvalidJsonDocumentException * @uses \Ergebnis\Json\Normalizer\Json + * @uses \Ergebnis\Json\Normalizer\Validator\Result * @uses \Ergebnis\Json\Normalizer\Validator\SchemaValidator */ final class SchemaNormalizerTest extends AbstractNormalizerTestCase @@ -168,6 +170,8 @@ public function testNormalizeThrowsRuntimeExceptionIfSchemaUriReferencesResource public function testNormalizeThrowsOriginalInvalidAccordingToSchemaExceptionWhenOriginalNotValidAccordingToSchema(): void { + $faker = self::faker(); + $json = Json::fromEncoded( <<<'JSON' { @@ -177,7 +181,7 @@ public function testNormalizeThrowsOriginalInvalidAccordingToSchemaExceptionWhen JSON ); - $schemaUri = self::faker()->url; + $schemaUri = $faker->url; $schema = <<<'JSON' { @@ -197,12 +201,16 @@ public function testNormalizeThrowsOriginalInvalidAccordingToSchemaExceptionWhen $schemaValidator = $this->prophesize(SchemaValidatorInterface::class); $schemaValidator - ->isValid( + ->validate( Argument::is($json->decoded()), Argument::is($schemaDecoded) ) ->shouldBeCalled() - ->willReturn(false); + ->willReturn(Result::create( + $faker->sentence, + $faker->sentence, + $faker->sentence + )); $normalizer = new SchemaNormalizer( $schemaUri, @@ -217,6 +225,8 @@ public function testNormalizeThrowsOriginalInvalidAccordingToSchemaExceptionWhen public function testNormalizeThrowsNormalizedInvalidAccordingToSchemaExceptionWhenNormalizedNotValidAccordingToSchema(): void { + $faker = self::faker(); + $json = Json::fromEncoded( <<<'JSON' { @@ -226,7 +236,7 @@ public function testNormalizeThrowsNormalizedInvalidAccordingToSchemaExceptionWh JSON ); - $schemaUri = self::faker()->url; + $schemaUri = $faker->url; $schema = <<<'JSON' { @@ -264,21 +274,25 @@ public function testNormalizeThrowsNormalizedInvalidAccordingToSchemaExceptionWh $schemaValidator = $this->prophesize(SchemaValidatorInterface::class); $schemaValidator - ->isValid( + ->validate( Argument::is($json->decoded()), Argument::is($schemaDecoded) ) ->shouldBeCalled() - ->will(function () use ($schemaValidator, $normalized, $schemaDecoded): bool { + ->will(function () use ($schemaValidator, $normalized, $schemaDecoded, $faker): Result { $schemaValidator - ->isValid( + ->validate( Argument::exact($normalized->decoded()), Argument::is($schemaDecoded) ) ->shouldBeCalled() - ->willReturn(false); + ->willReturn(Result::create( + $faker->sentence, + $faker->sentence, + $faker->sentence + )); - return true; + return Result::create(); }); $normalizer = new SchemaNormalizer( diff --git a/test/Unit/Validator/ResultTest.php b/test/Unit/Validator/ResultTest.php new file mode 100644 index 00000000..2c2645ab --- /dev/null +++ b/test/Unit/Validator/ResultTest.php @@ -0,0 +1,52 @@ +isValid()); + self::assertSame([], $result->errors()); + } + + public function testCreateReturnsResultWithErrors(): void + { + $faker = self::faker(); + + $errors = [ + $faker->sentence, + $faker->sentence, + $faker->sentence, + ]; + + $result = Result::create(...$errors); + + self::assertFalse($result->isValid()); + self::assertSame($errors, $result->errors()); + } +} diff --git a/test/Unit/Validator/SchemaValidatorTest.php b/test/Unit/Validator/SchemaValidatorTest.php index f481b454..e72b76ae 100644 --- a/test/Unit/Validator/SchemaValidatorTest.php +++ b/test/Unit/Validator/SchemaValidatorTest.php @@ -24,6 +24,8 @@ * @internal * * @covers \Ergebnis\Json\Normalizer\Validator\SchemaValidator + * + * @uses \Ergebnis\Json\Normalizer\Validator\Result */ final class SchemaValidatorTest extends Framework\TestCase { @@ -39,7 +41,7 @@ public function testImplementsSchemaValidatorInterface(): void * * @param bool $isValid */ - public function testValidateUsesSchemaValidator(bool $isValid): void + public function testIsValidUsesSchemaValidator(bool $isValid): void { $dataJson = <<<'JSON' { @@ -82,4 +84,112 @@ public function testValidateUsesSchemaValidator(bool $isValid): void self::assertSame($isValid, $validator->isValid($data, $schema)); } + + public function testValidateReturnsResultWhenDataIsNotValidAccordingToSchema(): void + { + $dataJson = <<<'JSON' +{ + "number": 1600, + "street_name": "Pennsylvania", + "street_type": "Avenue", + "direction": "NW" +} +JSON; + + $schemaJson = <<<'JSON' +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "address": { + "type": "string" + }, + "telephone": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ], + "additionalProperties": false +} +JSON; + + $data = \json_decode($dataJson); + $schema = \json_decode($schemaJson); + + $validator = new SchemaValidator(new Validator()); + + $result = $validator->validate( + $data, + $schema + ); + + self::assertFalse($result->isValid()); + + $expected = [ + 'name: The property name is required', + 'email: The property email is required', + 'The property number is not defined and the definition does not allow additional properties', + 'The property street_name is not defined and the definition does not allow additional properties', + 'The property street_type is not defined and the definition does not allow additional properties', + 'The property direction is not defined and the definition does not allow additional properties', + ]; + + self::assertSame($expected, $result->errors()); + } + + public function testValidateReturnsResultWhenDataIsValidAccordingToSchema(): void + { + $dataJson = <<<'JSON' +{ + "name": "Jane Doe", + "email": "jane.doe@example.org" +} +JSON; + + $schemaJson = <<<'JSON' +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "address": { + "type": "string" + }, + "telephone": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ], + "additionalProperties": false +} +JSON; + + $data = \json_decode($dataJson); + $schema = \json_decode($schemaJson); + + $validator = new SchemaValidator(new Validator()); + + $result = $validator->validate( + $data, + $schema + ); + + self::assertTrue($result->isValid()); + self::assertSame([], $result->errors()); + } } diff --git a/test/Unit/Vendor/Composer/ComposerJsonNormalizerTest.php b/test/Unit/Vendor/Composer/ComposerJsonNormalizerTest.php index 1c59a926..c60025a3 100644 --- a/test/Unit/Vendor/Composer/ComposerJsonNormalizerTest.php +++ b/test/Unit/Vendor/Composer/ComposerJsonNormalizerTest.php @@ -29,13 +29,14 @@ * @covers \Ergebnis\Json\Normalizer\Vendor\Composer\ComposerJsonNormalizer * * @uses \Ergebnis\Json\Normalizer\ChainNormalizer + * @uses \Ergebnis\Json\Normalizer\Json + * @uses \Ergebnis\Json\Normalizer\SchemaNormalizer + * @uses \Ergebnis\Json\Normalizer\Validator\Result + * @uses \Ergebnis\Json\Normalizer\Validator\SchemaValidator * @uses \Ergebnis\Json\Normalizer\Vendor\Composer\BinNormalizer * @uses \Ergebnis\Json\Normalizer\Vendor\Composer\ConfigHashNormalizer * @uses \Ergebnis\Json\Normalizer\Vendor\Composer\PackageHashNormalizer * @uses \Ergebnis\Json\Normalizer\Vendor\Composer\VersionConstraintNormalizer - * @uses \Ergebnis\Json\Normalizer\Json - * @uses \Ergebnis\Json\Normalizer\SchemaNormalizer - * @uses \Ergebnis\Json\Normalizer\Validator\SchemaValidator */ final class ComposerJsonNormalizerTest extends AbstractComposerTestCase {