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

validateRequestParameters()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 39
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

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