├── .gitignore
├── Tests
├── schema-invalid.json
├── schema-simple.json
├── ValidateJsonResponseTest.php
├── ValidateJsonRequestTest.php
├── ValidateJsonResponseListenerTest.php
├── ValidateJsonRequestListenerTest.php
└── ValidateJsonExceptionListenerTest.php
├── JsonValidationBundle.php
├── phpunit.xml.dist
├── DependencyInjection
├── Configuration.php
└── JsonValidationExtension.php
├── composer.json
├── .github
└── workflows
│ └── tests.yml
├── EventListener
├── AnnotationReader.php
├── ValidateJsonResponseListener.php
├── ValidateJsonExceptionListener.php
└── ValidateJsonRequestListener.php
├── Exception
└── JsonValidationRequestException.php
├── LICENSE
├── Annotation
├── ValidateJsonRequest.php
└── ValidateJsonResponse.php
├── Resources
└── config
│ └── services.xml
├── JsonValidator
└── JsonValidator.php
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | /vendor/
3 | composer.lock
--------------------------------------------------------------------------------
/Tests/schema-invalid.json:
--------------------------------------------------------------------------------
1 | {
2 | invalid
3 |
--------------------------------------------------------------------------------
/Tests/schema-simple.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "id",
3 | "$schema": "http://json-schema.org/draft-04/schema#",
4 | "description": "Simple JSON schema test",
5 | "type": "object",
6 | "properties": {
7 | "test": {
8 | "type": "string",
9 | "minLength": 1
10 | }
11 | },
12 | "required": [
13 | "test"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/JsonValidationBundle.php:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 | ./
7 |
8 |
9 | ./Resources
10 | ./Tests
11 | ./vendor
12 |
13 |
14 |
15 |
16 | ./Tests
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/DependencyInjection/Configuration.php:
--------------------------------------------------------------------------------
1 | getRootNode()
15 | ->children()
16 | ->booleanNode('enable_request_listener')->defaultTrue()->end()
17 | ->booleanNode('enable_response_listener')->defaultTrue()->end()
18 | ->booleanNode('enable_exception_listener')->defaultTrue()->end();
19 |
20 | return $treeBuilder;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mrsuh/json-validation-bundle",
3 | "description": "This bundle provides a way to validate JSON in request/response against a schema",
4 | "type": "symfony-bundle",
5 | "require": {
6 | "php": ">=8.0",
7 | "ext-json": "*",
8 | "symfony/config": ">=3.0",
9 | "symfony/http-foundation": "^6.0",
10 | "symfony/http-kernel": "^6.0",
11 | "symfony/dependency-injection": "^6.0",
12 | "justinrainbow/json-schema": ">=5.2",
13 | "psr/log": "^1.1",
14 | "doctrine/annotations": "^2.0"
15 | },
16 | "require-dev": {
17 | "phpunit/phpunit": "^10"
18 | },
19 | "license": "MIT",
20 | "authors": [
21 | {
22 | "name": "Anton Sukhachev",
23 | "email": "mrsuh6@gmail.com"
24 | }
25 | ],
26 | "autoload": {
27 | "psr-4": { "Mrsuh\\JsonValidationBundle\\": "" }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: "tests"
2 |
3 | on:
4 | push:
5 |
6 | jobs:
7 | phpunit:
8 | name: "Tests"
9 | runs-on: ${{ matrix.operating-system }}
10 | strategy:
11 | matrix:
12 | php-version:
13 | - "8.1"
14 | - "8.2"
15 | operating-system:
16 | - "ubuntu-latest"
17 |
18 | steps:
19 | - name: "Checkout"
20 | uses: "actions/checkout@v3"
21 |
22 | - name: "Install PHP"
23 | uses: "shivammathur/setup-php@v2"
24 | with:
25 | php-version: "${{ matrix.php-version }}"
26 | ini-values: memory_limit=-1
27 | tools: composer:v2
28 |
29 | - name: "Install dependencies"
30 | run: "composer install --no-interaction --no-progress --no-suggest"
31 |
32 | - name: "Tests"
33 | run: "php vendor/bin/phpunit"
34 |
--------------------------------------------------------------------------------
/EventListener/AnnotationReader.php:
--------------------------------------------------------------------------------
1 | attributes->has($className)) {
12 | return $request->attributes->get($className);
13 | }
14 |
15 | $parts = explode('::', $request->attributes->get('_controller', ''));
16 | if (count($parts) !== 2) {
17 | return null;
18 | }
19 |
20 | $annotationReader = new \Doctrine\Common\Annotations\AnnotationReader();
21 |
22 | $readerAnnotations = $annotationReader->getMethodAnnotations(new \ReflectionMethod($parts[0], $parts[1]));
23 | foreach ($readerAnnotations as $readerAnnotation) {
24 | if ($readerAnnotation::class === $className) {
25 | return $readerAnnotation;
26 | }
27 | }
28 |
29 | return null;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Exception/JsonValidationRequestException.php:
--------------------------------------------------------------------------------
1 | request = $request;
17 | $this->schemaPath = $schemaPath;
18 | $this->errors = $errors;
19 |
20 | parent::__construct('Json request validation error');
21 | }
22 |
23 | public function getErrors(): array
24 | {
25 | return $this->errors;
26 | }
27 |
28 | public function getRequest(): Request
29 | {
30 | return $this->request;
31 | }
32 |
33 | public function getSchemaPath(): string
34 | {
35 | return $this->schemaPath;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2020 Sukhachev Anton
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | "Software"), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/Tests/ValidateJsonResponseTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($annotation->getPath(), $expectedPath);
18 | $this->assertEquals($annotation->getEmptyIsValid(), $expectedEmptyIsValid);
19 | $this->assertEquals($annotation->getStatuses(), $expectedStatuses);
20 | }
21 |
22 | public static function constructorOptionsProvider(): array
23 | {
24 | return [
25 | [['value' => 'abc'], 'abc', false, []],
26 | [['path' => 'abc'], 'abc', false, []],
27 | [['path' => 'abc', 'emptyIsValid' => true], 'abc', true, []],
28 | [['path' => 'abc', 'statuses' => [200, 201]], 'abc', false, [200, 201]],
29 | ];
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Tests/ValidateJsonRequestTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($annotation->getPath(), $expectedPath);
18 | $this->assertEquals($annotation->getEmptyIsValid(), $expectedEmptyIsValid);
19 | $this->assertEquals($annotation->getMethods(), $expectedMethods);
20 | }
21 |
22 | public static function constructorOptionsProvider(): array
23 | {
24 | return [
25 | [['value' => 'abc'], 'abc', false, []],
26 | [['path' => 'abc'], 'abc', false, []],
27 | [['path' => 'abc', 'emptyIsValid' => true], 'abc', true, []],
28 | [['path' => 'abc', 'methods' => ['POST', 'PUT']], 'abc', false, ['POST', 'PUT']],
29 | ];
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Annotation/ValidateJsonRequest.php:
--------------------------------------------------------------------------------
1 | path = $data['value'];
20 | }
21 | if (isset($data['path'])) {
22 | $this->path = $data['path'];
23 | }
24 | if (isset($data['emptyIsValid'])) {
25 | $this->emptyIsValid = $data['emptyIsValid'];
26 | }
27 | if (isset($data['methods'])) {
28 | $this->methods = $data['methods'];
29 | }
30 | }
31 |
32 | public function setPath(string $path): void
33 | {
34 | $this->path = $path;
35 | }
36 |
37 | public function getPath(): string
38 | {
39 | return $this->path;
40 | }
41 |
42 | public function setEmptyIsValid(bool $emptyIsValid): void
43 | {
44 | $this->emptyIsValid = $emptyIsValid;
45 | }
46 |
47 | public function getEmptyIsValid(): bool
48 | {
49 | return $this->emptyIsValid;
50 | }
51 |
52 | public function setMethods($methods): void
53 | {
54 | if (is_string($methods)) {
55 | $methods = [$methods];
56 | }
57 |
58 | $this->methods = $methods;
59 | }
60 |
61 | public function getMethods(): array
62 | {
63 | return $this->methods;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Resources/config/services.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
10 |
11 | %kernel.project_dir%/src
12 |
13 |
15 |
16 |
17 |
19 |
20 |
21 |
22 |
24 |
25 |
26 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/Annotation/ValidateJsonResponse.php:
--------------------------------------------------------------------------------
1 | path = $data['value'];
20 | }
21 | if (isset($data['path'])) {
22 | $this->path = $data['path'];
23 | }
24 | if (isset($data['emptyIsValid'])) {
25 | $this->emptyIsValid = $data['emptyIsValid'];
26 | }
27 | if (isset($data['statuses'])) {
28 | $this->statuses = $data['statuses'];
29 | }
30 | }
31 |
32 | public function setPath(string $path): void
33 | {
34 | $this->path = $path;
35 | }
36 |
37 | public function getPath(): string
38 | {
39 | return $this->path;
40 | }
41 |
42 | public function setEmptyIsValid(bool $emptyIsValid): void
43 | {
44 | $this->emptyIsValid = $emptyIsValid;
45 | }
46 |
47 | public function getEmptyIsValid(): bool
48 | {
49 | return $this->emptyIsValid;
50 | }
51 |
52 | public function setStatuses($statuses): void
53 | {
54 | if (is_string($statuses)) {
55 | $this->statuses = [$statuses];
56 |
57 | return;
58 | }
59 |
60 | $this->statuses = $statuses;
61 | }
62 |
63 | public function getStatuses(): array
64 | {
65 | return $this->statuses;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/DependencyInjection/JsonValidationExtension.php:
--------------------------------------------------------------------------------
1 | load('services.xml');
19 |
20 | $configuration = new Configuration();
21 | $configs = $this->processConfiguration($configuration, $configs);
22 |
23 | if ($configs['enable_request_listener']) {
24 | $container->getDefinition('mrsuh_json_validation.request_listener')
25 | ->addTag('kernel.event_listener', ['event' => 'kernel.controller', 'priority' => -100]);
26 | }
27 |
28 | if ($configs['enable_response_listener']) {
29 | $container->getDefinition('mrsuh_json_validation.response_listener')
30 | ->addTag('kernel.event_listener', ['event' => 'kernel.response', 'priority' => -100]);
31 | }
32 |
33 | if ($configs['enable_exception_listener']) {
34 | $container->getDefinition('mrsuh_json_validation.exception_listener')
35 | ->addTag('kernel.event_listener', ['event' => 'kernel.exception']);
36 | }
37 | }
38 |
39 | /**
40 | * {@inheritDoc}
41 | */
42 | public function getAlias(): string
43 | {
44 | return 'mrsuh_json_validation';
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/EventListener/ValidateJsonResponseListener.php:
--------------------------------------------------------------------------------
1 | jsonValidator = $jsonValidator;
20 | $this->logger = $logger;
21 | }
22 |
23 | /**
24 | * @param ResponseEvent|FilterResponseEvent $event
25 | */
26 | public function onKernelResponse($event): void
27 | {
28 | $request = $event->getRequest();
29 | $response = $event->getResponse();
30 |
31 | $annotation = self::getAnnotation($request, ValidateJsonResponse::class);
32 | if ($annotation === null) {
33 | return;
34 | }
35 |
36 | if (!empty($annotation->getStatuses()) && !in_array($response->getStatusCode(), $annotation->getStatuses())) {
37 | return;
38 | }
39 |
40 | $content = $response->getContent();
41 |
42 | if ($annotation->getEmptyIsValid() && empty($content)) {
43 | return;
44 | }
45 |
46 | $this->jsonValidator->validate(
47 | $content,
48 | $annotation->getPath()
49 | );
50 |
51 | if (!empty($this->jsonValidator->getErrors())) {
52 | $this->logger->warning('Json response validation',
53 | [
54 | 'uri' => $request->getUri(),
55 | 'schemaPath' => $annotation->getPath(),
56 | 'errors' => $this->jsonValidator->getErrors()
57 | ]
58 | );
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/EventListener/ValidateJsonExceptionListener.php:
--------------------------------------------------------------------------------
1 | logger = $logger;
18 | }
19 |
20 | /**
21 | * @param ExceptionEvent|GetResponseForExceptionEvent $event
22 | */
23 | public function onKernelException($event): void
24 | {
25 | if (method_exists($event, 'getThrowable')) {
26 | $exception = $event->getThrowable();
27 | } else {
28 | $exception = $event->getException();
29 | }
30 |
31 | if (!$exception instanceof JsonValidationRequestException) {
32 | return;
33 | }
34 |
35 | $data = [
36 | 'status' => Response::HTTP_BAD_REQUEST,
37 | 'title' => 'Unable to parse/validate JSON',
38 | 'detail' => 'There was a problem with the JSON that was sent with the request',
39 | 'errors' => $this->formatErrors($exception->getErrors()),
40 | ];
41 |
42 | $event->setResponse(
43 | new JsonResponse(
44 | $data,
45 | Response::HTTP_BAD_REQUEST,
46 | ['Content-Type' => 'application/problem+json']
47 | )
48 | );
49 |
50 | $this->logger->error('Json request validation',
51 | [
52 | 'uri' => $exception->getRequest()->getUri(),
53 | 'schemaPath' => $exception->getSchemaPath(),
54 | 'errors' => $exception->getErrors()
55 | ]
56 | );
57 | }
58 |
59 | protected function formatErrors(array $errors): array
60 | {
61 | return array_map('array_filter', $errors);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/JsonValidator/JsonValidator.php:
--------------------------------------------------------------------------------
1 | locator = $locator;
22 | $this->schemaDir = $schemaDir;
23 | }
24 |
25 | public function validate(string $json, string $schemaPath)
26 | {
27 | $this->errors = [];
28 | $schema = null;
29 |
30 | try {
31 | $schema = $this->locator->locate(rtrim($this->schemaDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $schemaPath);
32 | } catch (\InvalidArgumentException $e) {
33 | $this->errors[] = [
34 | 'property' => null,
35 | 'pointer' => null,
36 | 'message' => 'Unable to locate schema ' . $schemaPath,
37 | 'constraint' => null,
38 | ];
39 |
40 | return null;
41 | }
42 |
43 | $data = json_decode($json);
44 |
45 | if ($data === null) {
46 | $this->errors[] = [
47 | 'property' => null,
48 | 'pointer' => null,
49 | 'message' => '[' . json_last_error() . '] ' . json_last_error_msg(),
50 | 'constraint' => null,
51 | ];
52 |
53 | return null;
54 | }
55 |
56 | $validator = new Validator();
57 |
58 | try {
59 | $validator->check($data, (object)['$ref' => 'file://' . $schema]);
60 | } catch (JsonDecodingException $e) {
61 | $this->errors[] = [
62 | 'property' => null,
63 | 'pointer' => null,
64 | 'message' => $e->getMessage(),
65 | 'constraint' => null,
66 | ];
67 |
68 | return null;
69 | }
70 |
71 | if (!$validator->isValid()) {
72 | $this->errors = $validator->getErrors();
73 |
74 | return null;
75 | }
76 |
77 | return $data;
78 | }
79 |
80 | public function getErrors(): array
81 | {
82 | return $this->errors;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/EventListener/ValidateJsonRequestListener.php:
--------------------------------------------------------------------------------
1 | jsonValidator = $jsonValidator;
19 | }
20 |
21 | /**
22 | * @param ControllerEvent|FilterControllerEvent $event
23 | */
24 | public function onKernelController($event): void
25 | {
26 | $request = $event->getRequest();
27 |
28 | $annotation = self::getAnnotation($request, ValidateJsonRequest::class);
29 | if ($annotation === null) {
30 | return;
31 | }
32 |
33 | $httpMethods = array_map(function (string $method): string {
34 | return strtoupper($method);
35 | }, $annotation->getMethods());
36 |
37 | if (!empty($httpMethods) && !in_array($request->getMethod(), $httpMethods)) {
38 | return;
39 | }
40 |
41 | $content = $request->getContent();
42 |
43 | if ($annotation->getEmptyIsValid() && empty($content)) {
44 | return;
45 | }
46 |
47 | $objectData = $this->jsonValidator->validate(
48 | $content,
49 | $annotation->getPath()
50 | );
51 |
52 | if (!empty($this->jsonValidator->getErrors())) {
53 | throw new JsonValidationRequestException($request, $annotation->getPath(), $this->jsonValidator->getErrors());
54 | }
55 |
56 | if ($this->getAsArray($event->getController())) {
57 | $request->attributes->set('validJson', json_decode($content, true));
58 | } else {
59 | $request->attributes->set('validJson', $objectData);
60 | }
61 | }
62 |
63 | /**
64 | * Decide whether the validated JSON should be decoded as an array
65 | *
66 | * This is based upon the type hint for the $validJson argument
67 | *
68 | * @return bool
69 | * @see Sensio\Bundle\FrameworkExtraBundle\EventListener\ParamConverterListener::onKernelController
70 | */
71 | protected function getAsArray($controller): bool
72 | {
73 | $r = null;
74 |
75 | if (is_array($controller)) {
76 | $r = new \ReflectionMethod($controller[0], $controller[1]);
77 | } elseif (is_object($controller) && is_callable($controller, '__invoke')) {
78 | $r = new \ReflectionMethod($controller, '__invoke');
79 | } else {
80 | $r = new \ReflectionFunction($controller);
81 | }
82 |
83 | foreach ($r->getParameters() as $param) {
84 | if ($param->getName() !== 'validJson') {
85 | continue;
86 | }
87 |
88 | return $param->getType() && $param->getType()->getName() === 'array';
89 | }
90 |
91 | return false;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Tests/ValidateJsonResponseListenerTest.php:
--------------------------------------------------------------------------------
1 | 'schema-simple.json', 'statuses' => [200]]);
22 |
23 | $request = Request::create('/');
24 | $response = new Response('', 201);
25 |
26 | $request->attributes->set(ValidateJsonResponse::class, $annotation);
27 |
28 | $event = $this->getResponseEvent($request, $response);
29 |
30 | $resource = fopen('php://memory', 'r+');
31 | $listener = $this->getValidateJsonResponseListener($resource);
32 |
33 | $listener->onKernelResponse($event);
34 |
35 | $this->assertFalse($this->hasResourceStr($resource, 'Json response validation'));
36 | }
37 |
38 | public function testInvalidJson()
39 | {
40 | $annotation = new ValidateJsonResponse(['path' => 'schema-simple.json', 'statuses' => [200]]);
41 |
42 | $request = Request::create('/');
43 | $response = new Response('{invalid', 200);
44 |
45 | $request->attributes->set(ValidateJsonResponse::class, $annotation);
46 |
47 | $event = $this->getResponseEvent($request, $response);
48 |
49 | $resource = fopen('php://memory', 'r+');
50 | $listener = $this->getValidateJsonResponseListener($resource);
51 |
52 | $listener->onKernelResponse($event);
53 |
54 | $this->assertTrue($this->hasResourceStr($resource, 'Json response validation'));
55 | }
56 |
57 | public function testValidJson()
58 | {
59 | $annotation = new ValidateJsonResponse(['path' => 'schema-simple.json', 'statuses' => [200]]);
60 |
61 | $request = Request::create('/');
62 | $response = new Response('{"test": "hello"}', 200);
63 |
64 | $request->attributes->set(ValidateJsonResponse::class, $annotation);
65 |
66 | $event = $this->getResponseEvent($request, $response);
67 |
68 | $resource = fopen('php://memory', 'r+');
69 | $listener = $this->getValidateJsonResponseListener($resource);
70 |
71 | $listener->onKernelResponse($event);
72 |
73 | $this->assertFalse($this->hasResourceStr($resource, 'Json response validation'));
74 | }
75 |
76 | protected function getValidateJsonResponseListener($resource): ValidateJsonResponseListener
77 | {
78 | $locator = new FileLocator([__DIR__]);
79 | $validator = new JsonValidator($locator, __DIR__);
80 | $logger = new Logger(LogLevel::DEBUG, $resource);
81 |
82 | return new ValidateJsonResponseListener($validator, $logger);
83 | }
84 |
85 | protected function getResponseEvent(Request $request, Response $response): ResponseEvent
86 | {
87 | $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock();
88 | $type = HttpKernelInterface::MAIN_REQUEST;
89 |
90 | return new ResponseEvent($kernel, $request, $type, $response);
91 | }
92 |
93 | /**
94 | * @param resource $loggerResource
95 | */
96 | public function hasResourceStr($loggerResource, string $needle)
97 | {
98 | fseek($loggerResource, 0);
99 | while ($buff = fgets($loggerResource)) {
100 | if (mb_strpos($buff, $needle) !== false) {
101 | return true;
102 | }
103 | }
104 |
105 | return false;
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/Tests/ValidateJsonRequestListenerTest.php:
--------------------------------------------------------------------------------
1 | getControllerEvent($request);
24 | $listener = $this->getValidateJsonListener();
25 |
26 | $listener->onKernelController($event);
27 |
28 | $this->assertFalse($request->attributes->has('validJson'));
29 | }
30 |
31 | public function testInvalidMethod()
32 | {
33 | $annotation = new ValidateJsonRequest(['path' => 'schema-simple.json', 'methods' => ['POST']]);
34 |
35 | $request = Request::create('/');
36 | // in the real system this is handled by SensioFrameworkExtraBundle
37 | $request->attributes->set(ValidateJsonRequest::class, $annotation);
38 |
39 | $event = $this->getControllerEvent($request);
40 | $listener = $this->getValidateJsonListener();
41 |
42 | $listener->onKernelController($event);
43 |
44 | $this->assertFalse($request->attributes->has('validJson'));
45 | }
46 |
47 | public function testInvalidJson()
48 | {
49 | $annotation = new ValidateJsonRequest(['path' => 'schema-simple.json']);
50 |
51 | $request = Request::create('/', 'POST', [], [], [], [], '{invalid');
52 | $request->attributes->set(ValidateJsonRequest::class, $annotation);
53 |
54 | $event = $this->getControllerEvent($request);
55 | $listener = $this->getValidateJsonListener();
56 |
57 | $this->expectException(JsonValidationRequestException::class);
58 | $listener->onKernelController($event);
59 | }
60 |
61 | public function testValidJson()
62 | {
63 | $annotation = new ValidateJsonRequest(['path' => 'schema-simple.json']);
64 |
65 | $request = Request::create('/', 'POST', [], [], [], [], '{"test": "hello"}');
66 | $request->attributes->set(ValidateJsonRequest::class, $annotation);
67 |
68 | $event = $this->getControllerEvent($request);
69 | $listener = $this->getValidateJsonListener();
70 |
71 | $listener->onKernelController($event);
72 |
73 | $this->assertTrue($request->attributes->has('validJson'));
74 | $this->assertEquals('hello', $request->attributes->get('validJson')->test);
75 | }
76 |
77 | public function testValidJsonArray()
78 | {
79 | $annotation = new ValidateJsonRequest(['path' => 'schema-simple.json']);
80 | $request = Request::create('/', 'POST', [], [], [], [], '{"test": "hello"}');
81 | $request->attributes->set(ValidateJsonRequest::class, $annotation);
82 |
83 | $kernel = $this->getMockBuilder(HttpKernelInterface::class)
84 | ->getMock();
85 | $controller = function (array $validJson) {
86 | };
87 | $type = HttpKernelInterface::MAIN_REQUEST;
88 | $event = new ControllerEvent($kernel, $controller, $request, $type);
89 |
90 | $listener = $this->getValidateJsonListener();
91 |
92 | $listener->onKernelController($event);
93 |
94 | $this->assertTrue($request->attributes->has('validJson'));
95 | $this->assertTrue(is_array($request->attributes->get('validJson')));
96 | $this->assertEquals('hello', $request->attributes->get('validJson')['test']);
97 | }
98 |
99 | protected function getValidateJsonListener(): ValidateJsonRequestListener
100 | {
101 | $locator = new FileLocator([__DIR__]);
102 | $validator = new JsonValidator($locator, __DIR__);
103 |
104 | return new ValidateJsonRequestListener($validator);
105 | }
106 |
107 | protected function getControllerEvent(Request $request): ControllerEvent
108 | {
109 | $kernel = $this->getMockBuilder(HttpKernelInterface::class)
110 | ->getMock();
111 | $controller = function ($validJson) {
112 | };
113 | $type = HttpKernelInterface::MAIN_REQUEST;
114 |
115 | return new ControllerEvent($kernel, $controller, $request, $type);
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # JSON Validation Bundle
2 |
3 | 
4 | 
5 | 
6 |
7 | A Symfony bundle that provides an annotation to validate request/response JSON against a schema.
8 |
9 | ### Differences from joipolloi/json-validation-bundle
10 | * added `response` validation
11 | * supporting Symfony `>=3.4`, `4.*`, `5.*`, `6.*`
12 | * error/warnings logging
13 | * single validator usage
14 |
15 | ## Versions
16 | * ^3 for Symfony `< 6.*`
17 | * ^4 for Symfony `>= 6.*`
18 |
19 | ## Installation
20 |
21 | ```bash
22 | composer require mrsuh/json-validation-bundle ^4
23 | ```
24 |
25 | ## Usage
26 |
27 | Create validation schemes
28 | See [json-schema](http://json-schema.org/) for more details
29 |
30 | JsonSchema/Request/myAction.json
31 | ```json
32 | {
33 | "description": "Request JSON schema",
34 | "type": "object",
35 | "properties": {
36 | "test": {
37 | "type": "string",
38 | "minLength": 1
39 | }
40 | },
41 | "required": [ "test" ]
42 | }
43 | ```
44 |
45 | JsonSchema/Response/myAction.json
46 | ```json
47 | {
48 | "description": "Response JSON schema",
49 | "type": "object",
50 | "properties": {
51 | "test": {
52 | "type": "string",
53 | "minLength": 1
54 | }
55 | },
56 | "required": [ "test" ]
57 | }
58 | ```
59 |
60 | Create controller with annotations `ValidateJsonRequest` and/or `ValidateJsonResponse`
61 | Specify `$validJson` argument if you want get decoded JSON data from the request
62 | Specify the `array` type of the `$validJson` argument if you want get decoded JSON data as `array`
63 | Specify the `object` type of the `$validJson` argument or don't specify type if you want get decoded JSON data as `object`
64 |
65 | Controller/MyController.php
66 | ```php
67 |
88 | you get response as detailed in [RFC7807](https://tools.ietf.org/html/rfc7807) with header `Content-Type:application/problem+json` and `error` log entry
89 |
90 | ```json
91 | {
92 | "detail": "There was a problem with the JSON that was sent with the request",
93 | "errors": [
94 | {
95 | "constraint": "minLength",
96 | "context": 1,
97 | "message": "Must be at least 1 characters long",
98 | "minLength": 1,
99 | "pointer": "/test",
100 | "property": "test"
101 | }
102 | ],
103 | "status": 400,
104 | "title": "Unable to parse/validate JSON"
105 | }
106 | ```
107 |
108 | ```bash
109 | app.ERROR: Json request validation {"uri":"http://127.0.0.1:8000/my","schemaPath":"JsonSchema/Request/myAction.json","errors":[{"property":"test","pointer":"/test","message":"Must be at least 1 characters long","constraint":"minLength","context":1,"minLength":1}]} []
110 | ```
111 |
112 | ### Invalid JSON passed to response
113 | If invalid JSON passed to response and config `enable_response_listener` enabled
114 | you get `warning` log entry
115 |
116 | ```bash
117 | app.WARNING: Json response validation {"uri":"http://127.0.0.1:8000/my","schemaPath":"JsonSchema/Response/myAction.json","errors":[{"property":"test","pointer":"/test","message":"Must be at least 1 characters long","constraint":"minLength","context":1,"minLength":1}]} []
118 | ```
119 |
120 | ## Configuration
121 |
122 | ```yaml
123 | mrsuh_json_validation:
124 | enable_request_listener: true #default value
125 | enable_response_listener: true #default value
126 | enable_exception_listener: true #default value
127 | ```
128 |
129 | ## Single validator usage
130 | ```php
131 | validate($request->getContent(), 'JsonSchema/Request/myAction.json');
142 | $errors = $validator->getErrors();
143 | if(!empty($errors)) {
144 | // do something with errors
145 | }
146 |
147 | return new Response();
148 | }
149 | }
150 | ```
151 |
--------------------------------------------------------------------------------
/Tests/ValidateJsonExceptionListenerTest.php:
--------------------------------------------------------------------------------
1 | getEvent(new \RuntimeException('Not JsonValidationException'));
19 | $resource = fopen('php://memory', 'r+');
20 | $listener = new ValidateJsonExceptionListener(new Logger(LogLevel::DEBUG, $resource));
21 |
22 | $listener->onKernelException($event);
23 |
24 | $this->assertNull($event->getResponse());
25 | $this->assertFalse($this->hasResourceStr($resource, 'Json request validation'));
26 | }
27 |
28 | public function testEmptyErrors()
29 | {
30 | $event = $this->getEvent($this->createJsonValidationRequestException([]));
31 | $resource = fopen('php://memory', 'r+');
32 | $listener = new ValidateJsonExceptionListener(new Logger(LogLevel::DEBUG, $resource));
33 |
34 | $listener->onKernelException($event);
35 |
36 | $this->assertInstanceOf(Response::class, $event->getResponse());
37 | $this->assertTrue($event->getResponse()->headers->contains('Content-Type', 'application/problem+json'));
38 |
39 | $json = json_decode($event->getResponse()->getContent());
40 | $this->assertEquals([], $json->errors);
41 | $this->assertEquals(400, $json->status);
42 | $this->assertEquals('Unable to parse/validate JSON', $json->title);
43 | $this->assertEquals('There was a problem with the JSON that was sent with the request', $json->detail);
44 | $this->assertTrue($this->hasResourceStr($resource, 'Json request validation'));
45 | }
46 |
47 | public function testMessageOnlyError()
48 | {
49 | $event = $this->getEvent($this->createJsonValidationRequestException([['message' => 'Test message'],]));
50 |
51 | $resource = fopen('php://memory', 'r+');
52 | $listener = new ValidateJsonExceptionListener(new Logger(LogLevel::DEBUG, $resource));
53 | $listener->onKernelException($event);
54 |
55 | $json = json_decode($event->getResponse()->getContent(), true);
56 |
57 | $this->assertEquals([['message' => 'Test message']], $json['errors']);
58 | $this->assertTrue($this->hasResourceStr($resource, 'Json request validation'));
59 | }
60 |
61 | public function testConstraintError()
62 | {
63 | $event = $this->getEvent($this->createJsonValidationRequestException([
64 | [
65 | 'constraint' => 'a',
66 | 'property' => 'b',
67 | 'pointer' => 'c',
68 | 'message' => 'd',
69 | ]
70 | ]));
71 |
72 | $listener = new ValidateJsonExceptionListener(new Logger());
73 | $listener->onKernelException($event);
74 |
75 | $json = json_decode($event->getResponse()->getContent(), true);
76 |
77 | $this->assertEquals([
78 | [
79 | 'constraint' => 'a',
80 | 'property' => 'b',
81 | 'pointer' => 'c',
82 | 'message' => 'd',
83 | ]
84 | ], $json['errors']);
85 | }
86 |
87 | public function testMixedErrors()
88 | {
89 | $event = $this->getEvent($this->createJsonValidationRequestException([
90 | ['message' => 'Test message'],
91 | [
92 | 'constraint' => 'a',
93 | 'property' => 'b',
94 | 'pointer' => 'c',
95 | 'message' => 'd',
96 | ]
97 | ]));
98 |
99 | $listener = new ValidateJsonExceptionListener(new Logger());
100 | $listener->onKernelException($event);
101 |
102 | $json = json_decode($event->getResponse()->getContent(), true);
103 |
104 | $this->assertEquals([
105 | ['message' => 'Test message'],
106 | [
107 | 'constraint' => 'a',
108 | 'property' => 'b',
109 | 'pointer' => 'c',
110 | 'message' => 'd',
111 | ]
112 | ], $json['errors']);
113 | }
114 |
115 | protected function getEvent(\Throwable $exception): ExceptionEvent
116 | {
117 | $kernel = $this->getMockBuilder(HttpKernelInterface::class)
118 | ->getMock();
119 | $request = Request::create('/');
120 | $requestType = HttpKernelInterface::MASTER_REQUEST;
121 |
122 | return new ExceptionEvent($kernel, $request, $requestType, $exception);
123 | }
124 |
125 | protected function createJsonValidationRequestException(array $errors = [])
126 | {
127 | return new JsonValidationRequestException(Request::create('/'), '/', $errors);
128 | }
129 |
130 | /**
131 | * @param resource $loggerResource
132 | */
133 | public function hasResourceStr($loggerResource, string $needle)
134 | {
135 | fseek($loggerResource, 0);
136 | while ($buff = fgets($loggerResource)) {
137 | if (mb_strpos($buff, $needle) !== false) {
138 | return true;
139 | }
140 | }
141 |
142 | return false;
143 | }
144 | }
145 |
--------------------------------------------------------------------------------