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

validateJsonAgainstSchema()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 9
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 16
rs 9.9666
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\Constraints\Constraint;
17
use JsonSchema\Validator;
18
use Nijens\OpenapiBundle\ExceptionHandling\Exception\InvalidContentTypeProblemException;
19
use Nijens\OpenapiBundle\ExceptionHandling\Exception\InvalidRequestBodyProblemException;
20
use Nijens\OpenapiBundle\ExceptionHandling\Exception\InvalidRequestParameterProblemException;
21
use Nijens\OpenapiBundle\ExceptionHandling\Exception\ProblemException;
22
use Nijens\OpenapiBundle\ExceptionHandling\Exception\Violation;
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 Validator
48
     */
49
    private $jsonValidator;
50
51
    public static function getSubscribedEvents(): array
52
    {
53
        return [
54
            KernelEvents::REQUEST => [
55
                ['validateRequest', 28],
56
            ],
57
        ];
58
    }
59
60
    public function __construct(JsonParser $jsonParser, Validator $jsonValidator)
61
    {
62
        $this->jsonParser = $jsonParser;
63
        $this->jsonValidator = $jsonValidator;
64
    }
65
66
    public function validateRequest(RequestEvent $event): void
67
    {
68
        $request = $event->getRequest();
69
        if ($this->isManagedRoute($request) === false) {
70
            return;
71
        }
72
73
        $this->validateRequestParameters($request);
74
        $this->validateRequestContentType($request);
75
        $this->validateJsonRequestBody($request);
76
    }
77
78
    private function validateRequestParameters(Request $request): void
79
    {
80
        $routeContext = $this->getRouteContext($request);
81
82
        $violations = [];
83
        foreach ($routeContext[RouteContext::REQUEST_VALIDATE_QUERY_PARAMETERS] as $parameterName => $parameter) {
84
            $parameter = json_decode($parameter);
85
            if ($request->query->has($parameterName) === false && $parameter->required ?? false) {
86
                $violations[] = new Violation(
87
                    'required_query_parameter',
88
                    sprintf('Query parameter %s is required.', $parameterName),
89
                    $parameterName
90
                );
91
92
                continue;
93
            }
94
95
            if ($request->query->has($parameterName) === false) {
96
                continue;
97
            }
98
99
            $parameterValue = $request->query->get($parameterName);
100
101
            $this->jsonValidator->validate($parameterValue, $parameter->schema, Constraint::CHECK_MODE_COERCE_TYPES);
102
            if ($this->jsonValidator->isValid() === false) {
103
                $validationErrors = $this->jsonValidator->getErrors();
104
                $this->jsonValidator->reset();
105
106
                $violations = array_merge(
107
                    $violations,
108
                    array_map(
109
                        function (array $validationError) use ($parameterName): Violation {
110
                            $validationError['property'] = $parameterName;
111
112
                            return Violation::fromArray($validationError);
113
                        },
114
                        $validationErrors
115
                    )
116
                );
117
            }
118
        }
119
120
        if (count($violations) > 0) {
121
            $this->throwInvalidRequestParameterProblemException($violations);
122
        }
123
    }
124
125
    private function validateRequestContentType(Request $request): void
126
    {
127
        $requestContentType = $this->getRequestContentType($request);
128
        $routeContext = $this->getRouteContext($request);
129
130
        if ($requestContentType === '' && $routeContext[RouteContext::REQUEST_BODY_REQUIRED] === false) {
131
            return;
132
        }
133
134
        if (empty($routeContext[RouteContext::REQUEST_ALLOWED_CONTENT_TYPES])) {
135
            return;
136
        }
137
138
        if (in_array($requestContentType, $routeContext[RouteContext::REQUEST_ALLOWED_CONTENT_TYPES])) {
139
            return;
140
        }
141
142
        $exception = new InvalidContentTypeProblemException(
143
            ProblemException::DEFAULT_TYPE_URI,
144
            ProblemException::DEFAULT_TITLE,
145
            Response::HTTP_UNSUPPORTED_MEDIA_TYPE,
146
            sprintf(
147
                "The request content-type '%s' is not supported. (Supported: %s)",
148
                $requestContentType,
149
                implode(', ', $routeContext[RouteContext::REQUEST_ALLOWED_CONTENT_TYPES])
150
            )
151
        );
152
153
        throw $exception;
154
    }
155
156
    private function validateJsonRequestBody(Request $request): void
157
    {
158
        $requestContentType = $this->getRequestContentType($request);
159
        if ($requestContentType !== 'application/json') {
160
            return;
161
        }
162
163
        $routeContext = $this->getRouteContext($request);
164
        if (isset($routeContext[RouteContext::REQUEST_BODY_SCHEMA]) === false) {
165
            return;
166
        }
167
168
        $requestBody = $request->getContent();
169
        $decodedJsonRequestBody = $this->validateJsonSyntax($requestBody);
170
171
        $this->validateJsonAgainstSchema(
172
            json_decode($routeContext[RouteContext::REQUEST_BODY_SCHEMA]),
173
            $decodedJsonRequestBody
174
        );
175
176
        $request->attributes->set(
177
            ValidationContext::REQUEST_ATTRIBUTE,
178
            [
179
                ValidationContext::VALIDATED => true,
180
                ValidationContext::REQUEST_BODY => json_encode($decodedJsonRequestBody),
181
            ]
182
        );
183
    }
184
185
    /**
186
     * Validates if the request body is valid JSON.
187
     *
188
     * @return mixed
189
     */
190
    private function validateJsonSyntax(string $requestBody)
191
    {
192
        $decodedJsonRequestBody = json_decode($requestBody);
193
        if ($decodedJsonRequestBody !== null || $requestBody === 'null') {
194
            return $decodedJsonRequestBody;
195
        }
196
197
        $exception = $this->jsonParser->lint($requestBody);
198
199
        $this->throwInvalidRequestBodyProblemException([
200
            new Violation('valid_json', $exception->getMessage()),
201
        ]);
202
    }
203
204
    /**
205
     * Validates the JSON request body against the JSON Schema within the OpenAPI document.
206
     *
207
     * @param array|stdClass|string|int|float|bool|null $decodedJsonRequestBody
208
     */
209
    private function validateJsonAgainstSchema(stdClass $jsonSchema, &$decodedJsonRequestBody): void
210
    {
211
        $this->jsonValidator->validate($decodedJsonRequestBody, $jsonSchema);
212
213
        if ($this->jsonValidator->isValid() === false) {
214
            $validationErrors = $this->jsonValidator->getErrors();
215
            $this->jsonValidator->reset();
216
217
            $violations = array_map(
218
                function (array $validationError): Violation {
219
                    return Violation::fromArray($validationError);
220
                },
221
                $validationErrors
222
            );
223
224
            $this->throwInvalidRequestBodyProblemException($violations);
225
        }
226
    }
227
228
    private function throwInvalidRequestParameterProblemException(array $violations): void
229
    {
230
        $exception = new InvalidRequestParameterProblemException(
231
            'about:blank',
232
            'The request contains errors.',
233
            Response::HTTP_BAD_REQUEST,
234
            'Validation of query parameters failed.'
235
        );
236
237
        throw $exception->withViolations($violations);
238
    }
239
240
    /**
241
     * @param Violation[] $violations
242
     */
243
    private function throwInvalidRequestBodyProblemException(array $violations): void
244
    {
245
        $exception = new InvalidRequestBodyProblemException(
246
            'about:blank',
247
            'The request body contains errors.',
248
            Response::HTTP_BAD_REQUEST,
249
            'Validation of JSON request body failed.'
250
        );
251
252
        throw $exception->withViolations($violations);
253
    }
254
255
    private function isManagedRoute(Request $request): bool
256
    {
257
        return $request->attributes->has(RouteContext::REQUEST_ATTRIBUTE);
258
    }
259
260
    private function getRouteContext(Request $request): ?array
261
    {
262
        return $request->attributes->get(RouteContext::REQUEST_ATTRIBUTE);
263
    }
264
265
    private function getRequestContentType(Request $request): string
266
    {
267
        return current(HeaderUtils::split($request->headers->get('Content-Type', ''), ';')) ?: '';
268
    }
269
}
270