Completed
Push — develop ( 37a591...b589d7 )
by Narcotic
19s
created

RestUtils   F

Complexity

Total Complexity 57

Size/Duplication

Total Lines 484
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 20

Test Coverage

Coverage 6.06%

Importance

Changes 0
Metric Value
wmc 57
c 0
b 0
f 0
lcom 2
cbo 20
dl 0
loc 484
ccs 10
cts 165
cp 0.0606
rs 2.1568

18 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 19 1
B getServiceRoutingMap() 0 25 5
A serializeContent() 0 19 3
A deserializeContent() 0 10 1
A validateContent() 0 11 2
C checkJsonRequest() 0 50 12
B checkJsonPatchRequest() 0 11 5
A getLastJsonErrorMessage() 0 10 2
A getSerializer() 0 4 1
A getSerializerContext() 0 4 1
B getOptionRoutes() 0 26 6
A getRoutesByBasename() 0 16 4
A getModelFromRoute() 0 11 2
A getControllerFromRoute() 0 11 2
A getRouteName() 0 12 2
A serialize() 0 21 4
A deserialize() 0 13 2
A validateRequest() 0 8 2

How to fix   Complexity   

Complex Class

Complex classes like RestUtils often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RestUtils, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * service for RESTy stuff
4
 */
5
6
namespace Graviton\RestBundle\Service;
7
8
use Graviton\ExceptionBundle\Exception\DeserializationException;
9
use Graviton\ExceptionBundle\Exception\InvalidJsonPatchException;
10
use Graviton\ExceptionBundle\Exception\MalformedInputException;
11
use Graviton\ExceptionBundle\Exception\NoInputException;
12
use Graviton\ExceptionBundle\Exception\SerializationException;
13
use Graviton\JsonSchemaBundle\Exception\ValidationException;
14
use Graviton\JsonSchemaBundle\Exception\ValidationExceptionError;
15
use Graviton\JsonSchemaBundle\Validator\Validator;
16
use Graviton\RestBundle\Model\DocumentModel;
17
use Graviton\SchemaBundle\SchemaUtils;
18
use Psr\Log\LoggerInterface;
19
use Symfony\Component\DependencyInjection\ContainerInterface;
20
use Symfony\Component\HttpFoundation\Request;
21
use Symfony\Component\HttpFoundation\Response;
22
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
23
use Symfony\Component\Routing\Route;
24
use Symfony\Component\Routing\Router;
25
use JMS\Serializer\Serializer;
26
use JMS\Serializer\SerializationContext;
27
use Graviton\RestBundle\Controller\RestController;
28
use Doctrine\Common\Cache\CacheProvider;
29
30
/**
31
 * A service (meaning symfony service) providing some convenience stuff when dealing with our RestController
32
 * based services (meaning rest services).
33
 *
34
 * @author   List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
35
 * @license  http://opensource.org/licenses/gpl-license.php GNU Public License
36
 * @link     http://swisscom.ch
37
 */
38
final class RestUtils implements RestUtilsInterface
39
{
40
    /**
41
     * @var ContainerInterface
42
     */
43
    private $container;
44
45
    /**
46
     * @var Serializer
47
     */
48
    private $serializer;
49
50
    /**
51
     * @var null|SerializationContext
52
     */
53
    private $serializerContext;
54
55
    /**
56
     * @var Router
57
     */
58
    private $router;
59
60
    /**
61
     * @var LoggerInterface
62
     */
63
    private $logger;
64
65
    /**
66
     * @var SchemaUtils
67
     */
68
    private $schemaUtils;
69
70
    /**
71
     * @var Validator
72
     */
73
    private $schemaValidator;
74
75
    /**
76
     * @var CacheProvider
77
     */
78
    private $cacheProvider;
79
80
    /**
81
     * @param ContainerInterface   $container         container
82
     * @param Router               $router            router
83
     * @param Serializer           $serializer        serializer
84
     * @param LoggerInterface      $logger            PSR logger (e.g. Monolog)
85
     * @param SerializationContext $serializerContext context for serializer
86
     * @param SchemaUtils          $schemaUtils       schema utils
87
     * @param Validator            $schemaValidator   schema validator
88
     * @param CacheProvider        $cacheProvider     Cache service
89
     */
90 4
    public function __construct(
91
        ContainerInterface $container,
92
        Router $router,
93
        Serializer $serializer,
94
        LoggerInterface $logger,
95
        SerializationContext $serializerContext,
96
        SchemaUtils $schemaUtils,
97
        Validator $schemaValidator,
98
        CacheProvider $cacheProvider
99
    ) {
100 4
        $this->container = $container;
101 4
        $this->serializer = $serializer;
102 4
        $this->serializerContext = $serializerContext;
103 4
        $this->router = $router;
104 4
        $this->logger = $logger;
105 4
        $this->schemaUtils = $schemaUtils;
106 4
        $this->schemaValidator = $schemaValidator;
107 4
        $this->cacheProvider = $cacheProvider;
108 4
    }
109
110
    /**
111
     * Builds a map of baseroutes (controllers) to its relevant route to the actions.
112
     * ignores schema stuff.
113
     *
114
     * @return array grouped array of basenames and actions..
115
     */
116
    public function getServiceRoutingMap()
117
    {
118
        $ret = array();
119
        $optionRoutes = $this->getOptionRoutes();
120
121
        foreach ($optionRoutes as $routeName => $optionRoute) {
122
            // get base name from options action
123
            $routeParts = explode('.', $routeName);
124
            if (count($routeParts) < 3) {
125
                continue;
126
            }
127
            array_pop($routeParts); // get rid of last part
128
            $baseName = implode('.', $routeParts);
129
130
            // get routes from same controller
131
            foreach ($this->getRoutesByBasename($baseName) as $routeName => $route) {
132
                // don't put schema stuff
133
                if (strpos('schema', strtolower($routeName)) === false) {
134
                    $ret[$baseName][$routeName] = $route;
135
                }
136
            }
137
        }
138
139
        return $ret;
140
    }
141
142
    /**
143
     * Public function to serialize stuff according to the serializer rules.
144
     *
145
     * @param object $content Any content to serialize
146
     * @param string $format  Which format to serialize into
147
     *
148
     * @throws \Exception
149
     *
150
     * @return string $content Json content
151
     */
152
    public function serializeContent($content, $format = 'json')
153
    {
154
        try {
155
            return $this->getSerializer()->serialize(
156
                $content,
157
                $format,
158
                $this->getSerializerContext()
159
            );
160
        } catch (\Exception $e) {
161
            $msg = sprintf(
162
                'Cannot serialize content class: %s; with id: %s; Message: %s',
163
                get_class($content),
164
                method_exists($content, 'getId') ? $content->getId() : '-no id-',
165
                str_replace('MongoDBODMProxies\__CG__\GravitonDyn', '', $e->getMessage())
166
            );
167
            $this->logger->alert($msg);
168
            throw new \Exception($msg, $e->getCode());
169
        }
170
    }
171
172
    /**
173
     * Deserialize the given content throw an exception if something went wrong
174
     *
175
     * @param string $content       Request content
176
     * @param string $documentClass Document class
177
     * @param string $format        Which format to deserialize from
178
     *
179
     * @throws \Exception
180
     *
181
     * @return object|array|integer|double|string|boolean
182
     */
183
    public function deserializeContent($content, $documentClass, $format = 'json')
184
    {
185
        $record = $this->getSerializer()->deserialize(
186
            $content,
187
            $documentClass,
188
            $format
189
        );
190
191
        return $record;
192
    }
193
194
    /**
195
     * Validates content with the given schema, returning an array of errors.
196
     * If all is good, you will receive an empty array.
197
     *
198
     * @param object        $content \stdClass of the request content
199
     * @param DocumentModel $model   the model to check the schema for
200
     *
201
     * @return \Graviton\JsonSchemaBundle\Exception\ValidationExceptionError[]
202
     * @throws \Exception
203
     */
204
    public function validateContent($content, DocumentModel $model)
205
    {
206
        if (is_string($content)) {
207
            $content = json_decode($content);
208
        }
209
210
        return $this->schemaValidator->validate(
211
            $content,
212
            $this->schemaUtils->getModelSchema(null, $model, true, true, true)
213
        );
214
    }
215
216
    /**
217
     * validate raw json input
218
     *
219
     * @param Request       $request  request
220
     * @param Response      $response response
221
     * @param DocumentModel $model    model
222
     * @param string        $content  Alternative request content.
223
     *
224
     * @return void
225
     */
226
    public function checkJsonRequest(Request $request, Response $response, DocumentModel $model, $content = '')
227
    {
228
        if (empty($content)) {
229
            $content = $request->getContent();
230
        }
231
232
        if (is_resource($content)) {
233
            throw new BadRequestHttpException('unexpected resource in validation');
234
        }
235
236
        // is request body empty
237
        if ($content === '') {
238
            $e = new NoInputException();
239
            $e->setResponse($response);
240
            throw $e;
241
        }
242
243
        $input = json_decode($content, true);
244
        if (JSON_ERROR_NONE !== json_last_error()) {
245
            $e = new MalformedInputException($this->getLastJsonErrorMessage());
246
            $e->setErrorType(json_last_error());
247
            $e->setResponse($response);
248
            throw $e;
249
        }
250
        if (!is_array($input)) {
251
            $e = new MalformedInputException('JSON request body must be an object');
252
            $e->setResponse($response);
253
            throw $e;
254
        }
255
256
        if ($request->getMethod() == 'PUT' && array_key_exists('id', $input)) {
257
            // we need to check for id mismatches....
258
            if ($request->attributes->get('id') != $input['id']) {
259
                $e = new MalformedInputException('Record ID in your payload must be the same');
260
                $e->setResponse($response);
261
                throw $e;
262
            }
263
        }
264
265
        if ($request->getMethod() == 'POST' &&
266
            array_key_exists('id', $input) &&
267
            !$model->isIdInPostAllowed()
268
        ) {
269
            $e = new MalformedInputException(
270
                '"id" can not be given on a POST request. Do a PUT request instead to update an existing record.'
271
            );
272
            $e->setResponse($response);
273
            throw $e;
274
        }
275
    }
276
277
    /**
278
     * Validate JSON patch for any object
279
     *
280
     * @param array $jsonPatch json patch as array
281
     *
282
     * @throws InvalidJsonPatchException
283
     * @return void
284
     */
285
    public function checkJsonPatchRequest(array $jsonPatch)
286
    {
287
        foreach ($jsonPatch as $operation) {
288
            if (!is_array($operation)) {
289
                throw new InvalidJsonPatchException('Patch request should be an array of operations.');
290
            }
291
            if (array_key_exists('path', $operation) && trim($operation['path']) == '/id') {
292
                throw new InvalidJsonPatchException('Change/remove of ID not allowed');
293
            }
294
        }
295
    }
296
297
    /**
298
     * Used for backwards compatibility to PHP 5.4
299
     *
300
     * @return string
301
     */
302
    private function getLastJsonErrorMessage()
303
    {
304
        $message = 'Unable to decode JSON string';
305
306
        if (function_exists('json_last_error_msg')) {
307
            $message = json_last_error_msg();
308
        }
309
310
        return $message;
311
    }
312
313
    /**
314
     * Get the serializer
315
     *
316
     * @return Serializer
317
     */
318
    public function getSerializer()
319
    {
320
        return $this->serializer;
321
    }
322
323
    /**
324
     * Get the serializer context
325
     *
326
     * @return SerializationContext
327
     */
328
    public function getSerializerContext()
329
    {
330
        return clone $this->serializerContext;
331
    }
332
333
    /**
334
     * It has been deemed that we search for OPTION routes in order to detect our
335
     * service routes and then derive the rest from them.
336
     *
337
     * @return array An array with option routes
338
     */
339
    public function getOptionRoutes()
340
    {
341
        $cached = $this->cacheProvider->fetch('cached_restutils_route_options');
342
        if ($cached) {
343
            return $cached;
344
        }
345
        $ret = array_filter(
346
            $this->router->getRouteCollection()->all(),
347
            function ($route) {
348
                if (!in_array('OPTIONS', $route->getMethods())) {
349
                    return false;
350
                }
351
                // ignore all schema routes
352
                if (strpos($route->getPath(), '/schema') === 0) {
353
                    return false;
354
                }
355
                if ($route->getPath() == '/' || $route->getPath() == '/core/version') {
356
                    return false;
357
                }
358
359
                return is_null($route->getRequirement('id'));
360
            }
361
        );
362
        $this->cacheProvider->save('cached_restutils_route_options', $ret);
363
        return $ret;
364
    }
365
366
    /**
367
     * Based on $baseName, this function returns all routes that match this basename..
368
     * So if you pass graviton.cont.action; it will return all route names that start with the same.
369
     * In our routing naming schema, this means all the routes from the same controller.
370
     *
371
     * @param string $baseName basename
372
     *
373
     * @return array array with matching routes
374
     */
375
    public function getRoutesByBasename($baseName)
376
    {
377
        $cached = $this->cacheProvider->fetch('cached_restutils_route_basename');
378
        if ($cached) {
379
            return $cached;
380
        }
381
        $ret = array();
382
        $collections = $this->router->getRouteCollection()->all();
383
        foreach ($collections as $routeName => $route) {
384
            if (preg_match('/^' . $baseName . '/', $routeName)) {
385
                $ret[$routeName] = $route;
386
            }
387
        }
388
        $this->cacheProvider->save('cached_restutils_route_basename', $ret);
389
        return $ret;
390
    }
391
392
    /**
393
     * Gets the Model assigned to the RestController
394
     *
395
     * @param Route $route Route
396
     *
397
     * @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...
398
     * @throws \Exception
399
     */
400
    public function getModelFromRoute(Route $route)
401
    {
402
        $ret = false;
403
        $controller = $this->getControllerFromRoute($route);
404
405
        if ($controller instanceof RestController) {
406
            $ret = $controller->getModel();
407
        }
408
409
        return $ret;
410
    }
411
412
    /**
413
     * Gets the controller from a Route
414
     *
415
     * @param Route $route Route
416
     *
417
     * @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...
418
     */
419
    public function getControllerFromRoute(Route $route)
420
    {
421
        $ret = false;
422
        $actionParts = explode(':', $route->getDefault('_controller'));
423
424
        if (count($actionParts) == 2) {
425
            $ret = $this->container->get($actionParts[0]);
426
        }
427
428
        return $ret;
429
    }
430
431
    /**
432
     * @param Request $request request
433
     * @return string
434
     */
435
    public function getRouteName(Request $request)
436
    {
437
        $routeName = $request->get('_route');
438
        $routeParts = explode('.', $routeName);
439
        $routeType = end($routeParts);
440
441
        if ($routeType == 'post') {
442
            $routeName = substr($routeName, 0, -4) . 'get';
443
        }
444
445
        return $routeName;
446
    }
447
448
    /**
449
     * Serialize the given record and throw an exception if something went wrong
450
     *
451
     * @param object|object[] $result Record(s)
452
     *
453
     * @throws \Graviton\ExceptionBundle\Exception\SerializationException
454
     *
455
     * @return string $content Json content
456
     */
457
    public function serialize($result)
458
    {
459
        try {
460
            // array is serialized as an object {"0":{...},"1":{...},...} when data contains an empty objects
461
            // we serialize each item because we can assume this bug affects only root array element
462
            if (is_array($result) && array_keys($result) === range(0, count($result) - 1)) {
463
                $result = array_map(
464
                    function ($item) {
465
                        return $this->serializeContent($item);
466
                    },
467
                    $result
468
                );
469
470
                return '['.implode(',', array_filter($result)).']';
471
            }
472
473
            return $this->serializeContent($result);
0 ignored issues
show
Bug introduced by
It seems like $result defined by parameter $result on line 457 can also be of type array; however, Graviton\RestBundle\Serv...ils::serializeContent() does only seem to accept object, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
474
        } catch (\Exception $e) {
475
            throw new SerializationException($e);
476
        }
477
    }
478
479
    /**
480
     * Deserialize the given content throw an exception if something went wrong
481
     *
482
     * @param string $content       Request content
483
     * @param string $documentClass Document class
484
     *
485
     * @throws DeserializationException
486
     *
487
     * @return object $record Document
0 ignored issues
show
Documentation introduced by
Should the return type not be object|array|integer|double|string|boolean? Also, consider making the array more specific, something like array<String>, or String[].

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

If the return type contains the type array, this check recommends the use of a more specific type like String[] or array<String>.

Loading history...
488
     */
489
    public function deserialize($content, $documentClass)
490
    {
491
        try {
492
            $record = $this->deserializeContent(
493
                $content,
494
                $documentClass
495
            );
496
        } catch (\Exception $e) {
497
            throw new DeserializationException("Deserialization failed", $e);
498
        }
499
500
        return $record;
501
    }
502
503
    /**
504
     * Validates the current request on schema violations. If there are errors,
505
     * the exception is thrown. If not, the deserialized record is returned.
506
     *
507
     * @param object|string $content \stdClass of the request content
508
     * @param DocumentModel $model   the model to check the schema for
509
     *
510
     * @return ValidationExceptionError|Object
0 ignored issues
show
Documentation introduced by
Should the return type not be object|array|integer|double|string|boolean? Also, consider making the array more specific, something like array<String>, or String[].

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

If the return type contains the type array, this check recommends the use of a more specific type like String[] or array<String>.

Loading history...
511
     * @throws \Exception
512
     */
513
    public function validateRequest($content, DocumentModel $model)
514
    {
515
        $errors = $this->validateContent($content, $model);
0 ignored issues
show
Bug introduced by
It seems like $content defined by parameter $content on line 513 can also be of type string; however, Graviton\RestBundle\Serv...tils::validateContent() does only seem to accept object, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
516
        if (!empty($errors)) {
517
            throw new ValidationException($errors);
518
        }
519
        return $this->deserialize($content, $model->getEntityClass());
0 ignored issues
show
Bug introduced by
It seems like $content defined by parameter $content on line 513 can also be of type object; however, Graviton\RestBundle\Serv...estUtils::deserialize() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
520
    }
521
}
522