├── .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 | ![](https://github.com/mrsuh/json-validation-bundle/actions/workflows/tests.yml/badge.svg) 4 | ![](https://img.shields.io/github/license/json-validation-bundle.svg) 5 | ![](https://img.shields.io/github/v/release/mrsuh/json-validation-bundle) 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 | --------------------------------------------------------------------------------