Completed
Push — develop ( fe1513...ed9012 )
by
unknown
13s
created

RestUtils   F

Complexity

Total Complexity 56

Size/Duplication

Total Lines 466
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 20

Test Coverage

Coverage 5.56%

Importance

Changes 0
Metric Value
wmc 56
lcom 2
cbo 20
dl 0
loc 466
ccs 9
cts 162
cp 0.0556
rs 2.1568
c 0
b 0
f 0

17 Methods

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