diff --git a/composer.json b/composer.json index 68bdf00..034b9ff 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "php": ">=8.4", "laravel/framework": ">=12.0", "ramsey/uuid": "^4.7", - "justinrainbow/json-schema": "^6.4" + "opis/json-schema": "^2.6" }, "require-dev": { "fakerphp/faker": "^1.24", diff --git a/src/Form/Form.php b/src/Form/Form.php index c944e45..6d82a6b 100644 --- a/src/Form/Form.php +++ b/src/Form/Form.php @@ -5,9 +5,8 @@ namespace MyParcelCom\JsonSchema\FormBuilder\Form; use ArrayObject; -use JsonSchema\Constraints\Constraint; -use JsonSchema\Validator; -use MyParcelCom\JsonSchema\FormBuilder\Form\Exceptions\FormValidationException; +use MyParcelCom\JsonSchema\FormBuilder\Validation\Validator; +use MyParcelCom\JsonSchema\FormBuilder\Validation\Exceptions\FormValidationException; /** * @extends ArrayObject @@ -18,6 +17,7 @@ public function toJsonSchema(): array { return [ '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'type' => 'object', 'additionalProperties' => false, 'required' => $this->getRequired(), 'properties' => $this->getProperties()->toArray(), @@ -29,16 +29,11 @@ public function toJsonSchema(): array * @param array $values a key value array of form values * @throws FormValidationException */ - public function validate(array $values): void + public function validate(array $values, ?Validator $validator = null): void { - /** - * CHECK_MODE_TYPE_CAST: Enable fuzzy type checking for associative arrays and objects - * src: https://github.com/jsonrainbow/json-schema?tab=readme-ov-file#configuration-options - **/ - $validator = new Validator; - $validator->validate($values, $this->toJsonSchema(), Constraint::CHECK_MODE_TYPE_CAST); - if (!$validator->isValid()) { - throw new FormValidationException("Form validation failed", $validator->getErrors()); + $validator ??= new Validator($values, $this->toJsonSchema()); + if(!$validator->isValid()) { + throw new FormValidationException(errors: $validator->getErrors()); } } } diff --git a/src/Form/Exceptions/FormValidationException.php b/src/Validation/Exceptions/FormValidationException.php similarity index 53% rename from src/Form/Exceptions/FormValidationException.php rename to src/Validation/Exceptions/FormValidationException.php index 4da8bce..974cbe0 100644 --- a/src/Form/Exceptions/FormValidationException.php +++ b/src/Validation/Exceptions/FormValidationException.php @@ -2,21 +2,23 @@ declare(strict_types=1); -namespace MyParcelCom\JsonSchema\FormBuilder\Form\Exceptions; +namespace MyParcelCom\JsonSchema\FormBuilder\Validation\Exceptions; use Exception; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Arr; -use Throwable; +use Opis\JsonSchema\Errors\ErrorFormatter; +use Opis\JsonSchema\ValidationResult; +/** + * Exception thrown when form validation fails. + */ class FormValidationException extends Exception { public function __construct( string $message = 'Form validation failed', - private readonly array $errors = [], - ?Throwable $previous = null, + private readonly ?array $errors = null, ) { - parent::__construct(message: $message, previous: $previous); + parent::__construct($message); } /** @@ -29,16 +31,10 @@ public function report(): bool public function render(): JsonResponse { - $errorMapping = Arr::collapse(array_map( - fn ($error) => [ - $error['property'] => $error['message'], - ], - $this->errors, - )); - return new JsonResponse([ + return new JsonResponse(array_filter([ 'message' => $this->getMessage(), - 'errors' => $errorMapping, - ], 422); + 'errors' => $this->errors, + ]), 422); } /** diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php new file mode 100644 index 0000000..31323f8 --- /dev/null +++ b/src/Validation/Validator.php @@ -0,0 +1,36 @@ +validationResult = $jsonSchemaValidator->validate(Helper::toJSON($this->values), Helper::toJSON($this->schema)); + } + + /** + * Validates the form values against the JSON Schema. + */ + public function isValid(): bool + { + return $this->validationResult->isValid(); + } + public function getErrors(): array + { + return $this->validationResult->hasError() + ? new ErrorFormatter()->format($this->validationResult->error()) + : []; + } +} diff --git a/tests/Form/Exceptions/FormValidationExceptionTest.php b/tests/Form/Exceptions/FormValidationExceptionTest.php deleted file mode 100644 index beab83f..0000000 --- a/tests/Form/Exceptions/FormValidationExceptionTest.php +++ /dev/null @@ -1,41 +0,0 @@ - 'foo.bar', - 'message' => 'Foo is required', - ], - [ - 'property' => 'bar.foo', - 'message' => 'Bar is required', - ], - ], - ); - - $response = $exception->render(); - - assertEquals(422, $response->getStatusCode()); - assertEquals($response->getData(true), [ - 'message' => 'Form validation failed', - 'errors' => [ - 'foo.bar' => 'Foo is required', - 'bar.foo' => 'Bar is required', - ], - ]); - } -} diff --git a/tests/Form/FormTest.php b/tests/Form/FormTest.php index 44b58d9..464a09e 100644 --- a/tests/Form/FormTest.php +++ b/tests/Form/FormTest.php @@ -4,8 +4,8 @@ namespace Tests\Form; +use Mockery; use MyParcelCom\JsonSchema\FormBuilder\Form\Checkbox; -use MyParcelCom\JsonSchema\FormBuilder\Form\Exceptions\FormValidationException; use MyParcelCom\JsonSchema\FormBuilder\Form\Form; use MyParcelCom\JsonSchema\FormBuilder\Form\FormElementCollection; use MyParcelCom\JsonSchema\FormBuilder\Form\Group; @@ -13,6 +13,8 @@ use MyParcelCom\JsonSchema\FormBuilder\Form\OptionCollection; use MyParcelCom\JsonSchema\FormBuilder\Form\RadioButtons; use MyParcelCom\JsonSchema\FormBuilder\Form\Text; +use MyParcelCom\JsonSchema\FormBuilder\Validation\Exceptions\FormValidationException; +use MyParcelCom\JsonSchema\FormBuilder\Validation\Validator; use PHPUnit\Framework\TestCase; use function PHPUnit\Framework\assertEquals; @@ -136,6 +138,7 @@ public function test_it_converts_to_json_schema(): void { assertEquals([ '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'type' => 'object', 'additionalProperties' => false, 'required' => ['name_2', 'name_3'], 'properties' => [ @@ -250,9 +253,15 @@ public function test_it_gets_values(): void /** * @throws FormValidationException */ - public function test_it_validates(): void + public function test_it_validates_success(): void { $this->expectNotToPerformAssertions(); + + $validator = Mockery::mock(Validator::class); + + $validator->expects('isValid')->andReturnTrue(); + $validator->expects('getErrors')->never(); + $this->form->validate([ 'name_1' => 'value', 'name_2' => false, @@ -262,54 +271,26 @@ public function test_it_validates(): void 'name_2' => false, 'name_3' => 'a', ], - ]); - - $this->form->validate([ - 'name_1' => 'value', - 'name_2' => false, - 'name_3' => 'a', - ]); + ], $validator); } - public function test_it_fails_to_validate_required_missing(): void + public function test_it_validates_failure(): void { $this->expectException(FormValidationException::class); + + $validator = Mockery::mock(Validator::class); + $validator->expects('isValid')->andReturnFalse(); + $validator->expects('getErrors'); + $this->form->validate([ 'name_1' => 'value', 'name_2' => false, - 'name_4' => [ - 'name_1' => 'value', - 'name_3' => 'a', - ], - ]); - } - - public function test_it_fails_to_validate_wrong_property_type(): void - { - $this->expectException(FormValidationException::class); - $this->form->validate([ - 'name_1' => 5, - 'name_2' => 'hello', 'name_3' => 'a', 'name_4' => [ 'name_1' => 'value', + 'name_2' => false, 'name_3' => 'a', ], - ]); - } - - public function test_it_fails_to_validate_invalid_enum_value(): void - { - $this->expectException(FormValidationException::class); - $this->form->validate([ - 'name_1' => 'value', - 'name_2' => true, - 'name_3' => 'x', - 'name_4' => [ - 'name_1' => 'value', - 'name_2' => true, - 'name_3' => 'y', - ], - ]); + ], $validator); } } diff --git a/tests/Validation/Exceptions/FormValidationExceptionTest.php b/tests/Validation/Exceptions/FormValidationExceptionTest.php new file mode 100644 index 0000000..3e7d6c1 --- /dev/null +++ b/tests/Validation/Exceptions/FormValidationExceptionTest.php @@ -0,0 +1,36 @@ + 'Foo is required', + 'bar.foo' => 'Bar is required', + ]); + + $response = $exception->render(); + + assertEquals(422, $response->getStatusCode()); + assertEquals($response->getData(true), [ + 'message' => 'What a failure', + 'errors' => [ + 'foo.bar' => 'Foo is required', + 'bar.foo' => 'Bar is required', + ], + ]); + } +} diff --git a/tests/Validation/ValidatorTest.php b/tests/Validation/ValidatorTest.php new file mode 100644 index 0000000..43b564b --- /dev/null +++ b/tests/Validation/ValidatorTest.php @@ -0,0 +1,128 @@ +schema = [ + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'type' => 'object', + 'additionalProperties' => false, + 'required' => ['name_2', 'name_3'], + 'properties' => [ + 'name_1' => [ + 'type' => 'string', + 'description' => 'Label 1', + ], + 'name_2' => [ + 'type' => 'boolean', + 'description' => 'Label 2', + 'meta' => [ + 'help' => 'Assistance is required', + ], + ], + 'name_3' => [ + 'type' => 'string', + 'description' => 'Label 3', + 'enum' => [ + 'a', + 'b', + ], + 'meta' => [ + 'help' => 'This field has no purpose', + 'field_type' => 'radio', + 'enum_labels' => [ + 'a' => 'A', + 'b' => 'B', + ], + ], + ], + 'name_4' => [ + 'type' => 'object', + 'description' => 'Label 4', + 'required' => [ + 0 => 'name_2', + 1 => 'name_3', + ], + 'properties' => [ + 'name_1' => [ + 'type' => 'string', + 'description' => 'Label 1', + ], + 'name_2' => [ + 'type' => 'boolean', + 'description' => 'Label 2', + 'meta' => [ + 'help' => 'Assistance is required', + ], + ], + 'name_3' => [ + 'type' => 'string', + 'description' => 'Label 3', + 'enum' => [ + 'a', + 'b', + ], + 'meta' => [ + 'help' => 'This field has no purpose', + 'field_type' => 'radio', + 'enum_labels' => [ + 'a' => 'A', + 'b' => 'B', + ], + ], + ], + ], + ], + ], + ]; + } + + public function test_it_validates_success(): void + { + $values = [ + 'name_1' => 'value', + 'name_2' => false, + 'name_3' => 'a', + 'name_4' => [ + 'name_1' => 'value', + 'name_2' => false, + 'name_3' => 'a', + ], + ]; + + $validator = new Validator($values, $this->schema); + self::assertTrue($validator->isValid()); + self::assertEquals([], $validator->getErrors()); + } + + public function test_it_validates_failure(): void + { + $values = [ + 'name_1' => 'value', + 'name_3' => 'a', + 'name_4' => [ + 'name_1' => 'value', + 'name_2' => false, + 'name_3' => 'a', + ], + ]; + + $validator = new Validator($values, $this->schema); + self::assertFalse($validator->isValid()); + self::assertContains( + ['The required properties (name_2) are missing'], + $validator->getErrors() + ); + } +}