Completed
Push — feature/other-validation ( ed39e0...f4e6d4 )
by Narcotic
13:20
created

RestUtils::getLastJsonErrorMessage()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 10
rs 9.4285
ccs 0
cts 6
cp 0
cc 2
eloc 5
nc 2
nop 0
crap 6
1
<?php
2
/**
3
 * service for RESTy stuff
4
 */
5
6
namespace Graviton\RestBundle\Service;
7
8
use Graviton\ExceptionBundle\Exception\InvalidJsonPatchException;
9
use Graviton\ExceptionBundle\Exception\MalformedInputException;
10
use Graviton\ExceptionBundle\Exception\NoInputException;
11
use Graviton\JsonSchemaBundle\Validator\Validator;
12
use Graviton\RestBundle\Model\DocumentModel;
13
use Graviton\SchemaBundle\SchemaUtils;
14
use Psr\Log\LoggerInterface;
15
use Symfony\Component\DependencyInjection\ContainerInterface;
16
use Symfony\Component\HttpFoundation\Request;
17
use Symfony\Component\HttpFoundation\Response;
18
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
19
use Symfony\Component\Routing\Route;
20
use Symfony\Component\Routing\Router;
21
use JMS\Serializer\Serializer;
22
use JMS\Serializer\SerializationContext;
23
use Graviton\RestBundle\Controller\RestController;
24
25
/**
26
 * A service (meaning symfony service) providing some convenience stuff when dealing with our RestController
27
 * based services (meaning rest services).
28
 *
29
 * @author   List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
30
 * @license  http://opensource.org/licenses/gpl-license.php GNU Public License
31
 * @link     http://swisscom.ch
32
 */
33
final class RestUtils implements RestUtilsInterface
34
{
35
    /**
36
     * @var ContainerInterface
37
     */
38
    private $container;
39
40
    /**
41
     * @var Serializer
42
     */
43
    private $serializer;
44
45
    /**
46
     * @var null|SerializationContext
47
     */
48
    private $serializerContext;
49
50
    /**
51
     * @var Router
52
     */
53
    private $router;
54
55
    /**
56
     * @var LoggerInterface
57
     */
58
    private $logger;
59
60
    /**
61
     * @var SchemaUtils
62
     */
63
    private $schemaUtils;
64
65
    /**
66
     * @var Validator
67
     */
68
    private $schemaValidator;
69
70
    /**
71
     * @param ContainerInterface   $container         container
72
     * @param Router               $router            router
73
     * @param Serializer           $serializer        serializer
74
     * @param LoggerInterface      $logger            PSR logger (e.g. Monolog)
75
     * @param SerializationContext $serializerContext context for serializer
76
     * @param SchemaUtils          $schemaUtils       schema utils
77
     * @param Validator            $schemaValidator   schema validator
78
     */
79 6
    public function __construct(
80
        ContainerInterface $container,
81
        Router $router,
82
        Serializer $serializer,
83
        LoggerInterface $logger,
84
        SerializationContext $serializerContext,
85
        SchemaUtils $schemaUtils,
86
        Validator $schemaValidator
87
    ) {
88 6
        $this->container = $container;
89 6
        $this->serializer = $serializer;
90 6
        $this->serializerContext = $serializerContext;
91 6
        $this->router = $router;
92 6
        $this->logger = $logger;
93 6
        $this->schemaUtils = $schemaUtils;
94 6
        $this->schemaValidator = $schemaValidator;
95 6
    }
96
97
    /**
98
     * Builds a map of baseroutes (controllers) to its relevant route to the actions.
99
     * ignores schema stuff.
100
     *
101
     * @return array grouped array of basenames and actions..
102
     */
103
    public function getServiceRoutingMap()
104
    {
105
        $ret = array();
106
        $optionRoutes = $this->getOptionRoutes();
107
108
        foreach ($optionRoutes as $routeName => $optionRoute) {
109
            // get base name from options action
110
            $routeParts = explode('.', $routeName);
111
            array_pop($routeParts); // get rid of last part
112
            $baseName = implode('.', $routeParts);
113
114
            // get routes from same controller
115
            foreach ($this->getRoutesByBasename($baseName) as $routeName => $route) {
116
                // don't put schema stuff
117
                if (strpos('schema', strtolower($routeName)) === false) {
118
                    $ret[$baseName][$routeName] = $route;
119
                }
120
            }
121
        }
122
123
        return $ret;
124
    }
125
126
    /**
127
     * Public function to serialize stuff according to the serializer rules.
128
     *
129
     * @param object $content Any content to serialize
130
     * @param string $format  Which format to serialize into
131
     *
132
     * @throws \Exception
133
     *
134
     * @return string $content Json content
135
     */
136 4
    public function serializeContent($content, $format = 'json')
137
    {
138
        try {
139 4
            return $this->getSerializer()->serialize(
140 2
                $content,
141 2
                $format,
142 4
                $this->getSerializerContext()
143 2
            );
144
        } catch (\Exception $e) {
145
            $msg = sprintf(
146
                'Cannot serialize content class: %s; with id: %s; Message: %s',
147
                get_class($content),
148
                method_exists($content, 'getId') ? $content->getId() : '-no id-',
149
                str_replace('MongoDBODMProxies\__CG__\GravitonDyn', '', $e->getMessage())
150
            );
151
            $this->logger->alert($msg);
152
            throw new \Exception($msg, $e->getCode());
153
        }
154
    }
155
156
    /**
157
     * Deserialize the given content throw an exception if something went wrong
158
     *
159
     * @param string $content       Request content
160
     * @param string $documentClass Document class
161
     * @param string $format        Which format to deserialize from
162
     *
163
     * @throws \Exception
164
     *
165
     * @return object|array|integer|double|string|boolean
166
     */
167 4
    public function deserializeContent($content, $documentClass, $format = 'json')
168
    {
169 4
        $record = $this->getSerializer()->deserialize(
170 2
            $content,
171 2
            $documentClass,
172
            $format
173 2
        );
174
175 4
        return $record;
176
    }
177
178
    /**
179
     * Validates content with the given schema, returning an array of errors.
180
     * If all is good, you will receive an empty array.
181
     *
182
     * @param object        $content \stdClass of the request content
183
     * @param DocumentModel $model   the model to check the schema for
184
     *
185
     * @return \Graviton\JsonSchemaBundle\Exception\ValidationExceptionError[]
186
     * @throws \Exception
187
     */
188 4
    public function validateContent($content, DocumentModel $model)
189
    {
190 4
        if (is_string($content)) {
191 4
            $content = json_decode($content);
192 2
        }
193
194 4
        return $this->schemaValidator->validate(
195 2
            $content,
196 4
            $this->schemaUtils->getModelSchema(null, $model, true, true, true)
197 2
        );
198
    }
199
200
    /**
201
     * validate raw json input
202
     *
203
     * @param Request       $request  request
204
     * @param Response      $response response
205
     * @param DocumentModel $model    model
206
     * @param string        $content  Alternative request content.
207
     *
208
     * @return void
209
     */
210 4
    public function checkJsonRequest(Request $request, Response $response, DocumentModel $model, $content = '')
211
    {
212 4
        if (empty($content)) {
213 4
            $content = $request->getContent();
214 2
        }
215
216 4
        if (is_resource($content)) {
217
            throw new BadRequestHttpException('unexpected resource in validation');
218
        }
219
220
        // is request body empty
221 4
        if ($content === '') {
222
            $e = new NoInputException();
223
            $e->setResponse($response);
224
            throw $e;
225
        }
226
227 4
        $input = json_decode($content, true);
228 4
        if (JSON_ERROR_NONE !== json_last_error()) {
229
            $e = new MalformedInputException($this->getLastJsonErrorMessage());
230
            $e->setErrorType(json_last_error());
231
            $e->setResponse($response);
232
            throw $e;
233
        }
234 4
        if (!is_array($input)) {
235
            $e = new MalformedInputException('JSON request body must be an object');
236
            $e->setResponse($response);
237
            throw $e;
238
        }
239
240 4
        if ($request->getMethod() == 'PUT' && array_key_exists('id', $input)) {
241
            // we need to check for id mismatches....
242 4
            if ($request->attributes->get('id') != $input['id']) {
243
                $e = new MalformedInputException('Record ID in your payload must be the same');
244
                $e->setResponse($response);
245
                throw $e;
246
            }
247 2
        }
248
249 4
        if ($request->getMethod() == 'POST' &&
250 4
            array_key_exists('id', $input) &&
251 2
            !$model->isIdInPostAllowed()
252 2
        ) {
253
            $e = new MalformedInputException(
254
                '"id" can not be given on a POST request. Do a PUT request instead to update an existing record.'
255
            );
256
            $e->setResponse($response);
257
            throw $e;
258
        }
259 4
    }
260
261
    /**
262
     * Validate JSON patch for any object
263
     *
264
     * @param array $jsonPatch json patch as array
265
     *
266
     * @throws InvalidJsonPatchException
267
     * @return void
268
     */
269
    public function checkJsonPatchRequest(array $jsonPatch)
270
    {
271
        foreach ($jsonPatch as $operation) {
272
            if (!is_array($operation)) {
273
                throw new InvalidJsonPatchException('Patch request should be an array of operations.');
274
            }
275
            if (array_key_exists('path', $operation) && trim($operation['path']) == '/id') {
276
                throw new InvalidJsonPatchException('Change/remove of ID not allowed');
277
            }
278
        }
279
    }
280
281
    /**
282
     * Used for backwards compatibility to PHP 5.4
283
     *
284
     * @return string
285
     */
286
    private function getLastJsonErrorMessage()
287
    {
288
        $message = 'Unable to decode JSON string';
289
290
        if (function_exists('json_last_error_msg')) {
291
            $message = json_last_error_msg();
292
        }
293
294
        return $message;
295
    }
296
297
    /**
298
     * Get the serializer
299
     *
300
     * @return Serializer
301
     */
302 4
    public function getSerializer()
303
    {
304 4
        return $this->serializer;
305
    }
306
307
    /**
308
     * Get the serializer context
309
     *
310
     * @return SerializationContext
311
     */
312 4
    public function getSerializerContext()
313
    {
314 4
        return clone $this->serializerContext;
315
    }
316
317
    /**
318
     * It has been deemed that we search for OPTION routes in order to detect our
319
     * service routes and then derive the rest from them.
320
     *
321
     * @return array An array with option routes
322
     */
323
    public function getOptionRoutes()
324
    {
325
        $router = $this->router;
326
        $ret = array_filter(
327
            $router->getRouteCollection()
328
                   ->all(),
329
            function ($route) {
330
                if (!in_array('OPTIONS', $route->getMethods())) {
331
                    return false;
332
                }
333
                // ignore all schema routes
334
                if (strpos($route->getPath(), '/schema') === 0) {
335
                    return false;
336
                }
337
                if ($route->getPath() == '/' || $route->getPath() == '/core/version') {
338
                    return false;
339
                }
340
341
                return is_null($route->getRequirement('id'));
342
            }
343
        );
344
345
        return $ret;
346
    }
347
348
    /**
349
     * Based on $baseName, this function returns all routes that match this basename..
350
     * So if you pass graviton.cont.action; it will return all route names that start with the same.
351
     * In our routing naming schema, this means all the routes from the same controller.
352
     *
353
     * @param string $baseName basename
354
     *
355
     * @return array array with matching routes
356
     */
357
    public function getRoutesByBasename($baseName)
358
    {
359
        $ret = array();
360
        foreach ($this->router
361
                      ->getRouteCollection()
362
                      ->all() as $routeName => $route) {
363
            if (preg_match('/^' . $baseName . '/', $routeName)) {
364
                $ret[$routeName] = $route;
365
            }
366
        }
367
368
        return $ret;
369
    }
370
371
    /**
372
     * Gets the Model assigned to the RestController
373
     *
374
     * @param Route $route Route
375
     *
376
     * @return bool|object The model or false
0 ignored issues
show
Documentation introduced by
Consider making the return type a bit more specific; maybe use DocumentModel|false.

This check looks for the generic type array as a return type and suggests a more specific type. This type is inferred from the actual code.

Loading history...
377
     * @throws \Exception
378
     */
379
    public function getModelFromRoute(Route $route)
380
    {
381
        $ret = false;
382
        $controller = $this->getControllerFromRoute($route);
383
384
        if ($controller instanceof RestController) {
385
            $ret = $controller->getModel();
386
        }
387
388
        return $ret;
389
    }
390
391
    /**
392
     * Gets the controller from a Route
393
     *
394
     * @param Route $route Route
395
     *
396
     * @return bool|object The controller or false
0 ignored issues
show
Documentation introduced by
Consider making the return type a bit more specific; maybe use object|false.

This check looks for the generic type array as a return type and suggests a more specific type. This type is inferred from the actual code.

Loading history...
397
     */
398
    public function getControllerFromRoute(Route $route)
399
    {
400
        $ret = false;
401
        $actionParts = explode(':', $route->getDefault('_controller'));
402
403
        if (count($actionParts) == 2) {
404
            $ret = $this->container->get($actionParts[0]);
405
        }
406
407
        return $ret;
408
    }
409
}
410