Passed
Pull Request — main (#71)
by Niels
02:42
created

validateJsonAgainstSchema()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 12
nc 2
nop 3
dl 0
loc 21
rs 9.8666
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the OpenapiBundle package.
7
 *
8
 * (c) Niels Nijens <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Nijens\OpenapiBundle\Validation\EventSubscriber;
15
16
use JsonSchema\Validator;
17
use Nijens\OpenapiBundle\ExceptionHandling\Exception\InvalidContentTypeProblemException;
18
use Nijens\OpenapiBundle\ExceptionHandling\Exception\InvalidRequestBodyProblemException;
19
use Nijens\OpenapiBundle\ExceptionHandling\Exception\ProblemException;
20
use Nijens\OpenapiBundle\ExceptionHandling\Exception\Violation;
21
use Nijens\OpenapiBundle\Json\JsonPointer;
22
use Nijens\OpenapiBundle\Json\SchemaLoaderInterface;
23
use Nijens\OpenapiBundle\Routing\RouteContext;
24
use Nijens\OpenapiBundle\Validation\ValidationContext;
25
use Seld\JsonLint\JsonParser;
26
use stdClass;
27
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
28
use Symfony\Component\HttpFoundation\HeaderUtils;
29
use Symfony\Component\HttpFoundation\Request;
30
use Symfony\Component\HttpFoundation\Response;
31
use Symfony\Component\HttpKernel\Event\RequestEvent;
32
use Symfony\Component\HttpKernel\KernelEvents;
33
34
/**
35
 * Validates a request for routes loaded through the OpenAPI specification.
36
 *
37
 * @author Niels Nijens <[email protected]>
38
 */
39
class RequestValidationSubscriber implements EventSubscriberInterface
40
{
41
    /**
42
     * @var JsonParser
43
     */
44
    private $jsonParser;
45
46
    /**
47
     * @var SchemaLoaderInterface
48
     */
49
    private $schemaLoader;
50
51
    /**
52
     * @var Validator
53
     */
54
    private $jsonValidator;
55
56
    public static function getSubscribedEvents(): array
57
    {
58
        return [
59
            KernelEvents::REQUEST => [
60
                ['validateRequest', 28],
61
            ],
62
        ];
63
    }
64
65
    public function __construct(JsonParser $jsonParser, SchemaLoaderInterface $schemaLoader, Validator $jsonValidator)
66
    {
67
        $this->jsonParser = $jsonParser;
68
        $this->schemaLoader = $schemaLoader;
69
        $this->jsonValidator = $jsonValidator;
70
    }
71
72
    public function validateRequest(RequestEvent $event): void
73
    {
74
        $request = $event->getRequest();
75
        if ($this->isManagedRoute($request) === false) {
76
            return;
77
        }
78
79
        $this->validateRequestContentType($request);
80
        $this->validateJsonRequestBody($request);
81
    }
82
83
    private function validateRequestContentType(Request $request): void
84
    {
85
        $requestContentType = $this->getRequestContentType($request);
86
        $routeContext = $this->getRouteContext($request);
87
88
        if ($requestContentType === '' && $routeContext[RouteContext::REQUEST_BODY_REQUIRED] === false) {
89
            return;
90
        }
91
92
        if (empty($routeContext[RouteContext::REQUEST_ALLOWED_CONTENT_TYPES])) {
93
            return;
94
        }
95
96
        if (in_array($requestContentType, $routeContext[RouteContext::REQUEST_ALLOWED_CONTENT_TYPES])) {
97
            return;
98
        }
99
100
        $exception = new InvalidContentTypeProblemException(
101
            ProblemException::DEFAULT_TYPE_URI,
102
            ProblemException::DEFAULT_TITLE,
103
            Response::HTTP_UNSUPPORTED_MEDIA_TYPE,
104
            sprintf(
105
                "The request content-type '%s' is not supported. (Supported: %s)",
106
                $requestContentType,
107
                implode(', ', $routeContext[RouteContext::REQUEST_ALLOWED_CONTENT_TYPES])
108
            )
109
        );
110
111
        throw $exception;
112
    }
113
114
    private function validateJsonRequestBody(Request $request): void
115
    {
116
        $requestContentType = $this->getRequestContentType($request);
117
        if ($requestContentType !== 'application/json') {
118
            return;
119
        }
120
121
        $routeContext = $this->getRouteContext($request);
122
        if (isset($routeContext[RouteContext::JSON_REQUEST_VALIDATION_POINTER]) === false) {
123
            return;
124
        }
125
126
        $requestBody = $request->getContent();
127
        $decodedJsonRequestBody = $this->validateJsonSyntax($requestBody);
128
129
        $this->validateJsonAgainstSchema(
130
            $routeContext[RouteContext::RESOURCE],
131
            $routeContext[RouteContext::JSON_REQUEST_VALIDATION_POINTER],
132
            $decodedJsonRequestBody
133
        );
134
135
        $request->attributes->set(
136
            ValidationContext::REQUEST_ATTRIBUTE,
137
            [
138
                ValidationContext::VALIDATED => true,
139
                ValidationContext::REQUEST_BODY => json_encode($decodedJsonRequestBody),
140
            ]
141
        );
142
    }
143
144
    /**
145
     * Validates if the request body is valid JSON.
146
     *
147
     * @return mixed
148
     */
149
    private function validateJsonSyntax(string $requestBody)
150
    {
151
        $decodedJsonRequestBody = json_decode($requestBody);
152
        if ($decodedJsonRequestBody !== null || $requestBody === 'null') {
153
            return $decodedJsonRequestBody;
154
        }
155
156
        $exception = $this->jsonParser->lint($requestBody);
157
158
        $this->throwInvalidRequestBodyProblemException([
159
            new Violation('valid_json', $exception->getMessage()),
160
        ]);
161
    }
162
163
    /**
164
     * Validates the JSON request body against the JSON Schema within the OpenAPI document.
165
     *
166
     * @param array|stdClass|string|int|float|bool|null $decodedJsonRequestBody
167
     */
168
    private function validateJsonAgainstSchema(string $openApiResource, string $openApiValidationPointer, &$decodedJsonRequestBody): void
169
    {
170
        $schema = $this->schemaLoader->load($openApiResource);
171
172
        $jsonPointer = new JsonPointer($schema);
173
        $jsonSchema = $jsonPointer->get($openApiValidationPointer);
174
175
        $this->jsonValidator->validate($decodedJsonRequestBody, $jsonSchema);
176
177
        if ($this->jsonValidator->isValid() === false) {
178
            $validationErrors = $this->jsonValidator->getErrors();
179
            $this->jsonValidator->reset();
180
181
            $violations = array_map(
182
                function (array $validationError): Violation {
183
                    return Violation::fromArray($validationError);
184
                },
185
                $validationErrors
186
            );
187
188
            $this->throwInvalidRequestBodyProblemException($violations);
189
        }
190
    }
191
192
    /**
193
     * @param Violation[] $violations
194
     */
195
    private function throwInvalidRequestBodyProblemException(array $violations): void
196
    {
197
        $exception = new InvalidRequestBodyProblemException(
198
            'about:blank',
199
            'The request body contains errors.',
200
            Response::HTTP_BAD_REQUEST,
201
            'Validation of JSON request body failed.'
202
        );
203
204
        throw $exception->withViolations($violations);
205
    }
206
207
    private function isManagedRoute(Request $request): bool
208
    {
209
        $routeContext = $this->getRouteContext($request);
210
211
        return isset($routeContext[RouteContext::RESOURCE]);
212
    }
213
214
    private function getRouteContext(Request $request): ?array
215
    {
216
        return $request->attributes->get(RouteContext::REQUEST_ATTRIBUTE);
217
    }
218
219
    private function getRequestContentType(Request $request): string
220
    {
221
        return current(HeaderUtils::split($request->headers->get('Content-Type', ''), ';')) ?: '';
222
    }
223
}
224