Completed
Push — master ( 714b6a...f9d007 )
by Narcotic
06:13
created

RestUtils::getRoutesByBasename()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 16
ccs 0
cts 11
cp 0
rs 9.2
cc 4
eloc 11
nc 4
nop 1
crap 20
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 4
    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 4
        $this->container = $container;
93 4
        $this->serializer = $serializer;
94 4
        $this->router = $router;
95 4
        $this->logger = $logger;
96 4
        $this->schemaUtils = $schemaUtils;
97 4
        $this->schemaValidator = $schemaValidator;
98 4
        $this->cacheProvider = $cacheProvider;
99 4
    }
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
        $cached = $this->cacheProvider->fetch('cached_restutils_route_basename');
358
        if ($cached) {
359
            return $cached;
360
        }
361
        $ret = array();
362
        $collections = $this->router->getRouteCollection()->all();
363
        foreach ($collections as $routeName => $route) {
364
            if (preg_match('/^' . $baseName . '/', $routeName)) {
365
                $ret[$routeName] = $route;
366
            }
367
        }
368
        $this->cacheProvider->save('cached_restutils_route_basename', $ret);
369
        return $ret;
370
    }
371
372
    /**
373
     * Gets the Model assigned to the RestController
374
     *
375
     * @param Route $route Route
376
     *
377
     * @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...
378
     * @throws \Exception
379
     */
380
    public function getModelFromRoute(Route $route)
381
    {
382
        $ret = false;
383
        $controller = $this->getControllerFromRoute($route);
384
385
        if ($controller instanceof RestController) {
386
            $ret = $controller->getModel();
387
        }
388
389
        return $ret;
390
    }
391
392
    /**
393
     * Gets the controller from a Route
394
     *
395
     * @param Route $route Route
396
     *
397
     * @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...
398
     */
399
    public function getControllerFromRoute(Route $route)
400
    {
401
        $ret = false;
402
        $actionParts = explode(':', $route->getDefault('_controller'));
403
404
        if (count($actionParts) == 2) {
405
            $ret = $this->container->get($actionParts[0]);
406
        }
407
408
        return $ret;
409
    }
410
411
    /**
412
     * @param Request $request request
413
     * @return string
414
     */
415
    public function getRouteName(Request $request)
416
    {
417
        $routeName = $request->get('_route');
418
        $routeParts = explode('.', $routeName);
419
        $routeType = end($routeParts);
420
421
        if ($routeType == 'post') {
422
            $routeName = substr($routeName, 0, -4) . 'get';
423
        }
424
425
        return $routeName;
426
    }
427
428
    /**
429
     * Serialize the given record and throw an exception if something went wrong
430
     *
431
     * @param object|object[] $result Record(s)
432
     *
433
     * @throws \Graviton\ExceptionBundle\Exception\SerializationException
434
     *
435
     * @return string $content Json content
436
     */
437
    public function serialize($result)
438
    {
439
        try {
440
            // array is serialized as an object {"0":{...},"1":{...},...} when data contains an empty objects
441
            // we serialize each item because we can assume this bug affects only root array element
442
            if (is_array($result) && array_keys($result) === range(0, count($result) - 1)) {
443
                $result = array_map(
444
                    function ($item) {
445
                        return $this->serializeContent($item);
446
                    },
447
                    $result
448
                );
449
450
                return '['.implode(',', array_filter($result)).']';
451
            }
452
453
            return $this->serializeContent($result);
0 ignored issues
show
Bug introduced by
It seems like $result defined by parameter $result on line 437 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...
454
        } catch (\Exception $e) {
455
            throw new SerializationException($e);
456
        }
457
    }
458
459
    /**
460
     * Deserialize the given content throw an exception if something went wrong
461
     *
462
     * @param string $content       Request content
463
     * @param string $documentClass Document class
464
     *
465
     * @throws DeserializationException
466
     *
467
     * @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...
468
     */
469
    public function deserialize($content, $documentClass)
470
    {
471
        try {
472
            $record = $this->deserializeContent(
473
                $content,
474
                $documentClass
475
            );
476
        } catch (\Exception $e) {
477
            throw new DeserializationException("Deserialization failed", $e);
478
        }
479
480
        return $record;
481
    }
482
483
    /**
484
     * Validates the current request on schema violations. If there are errors,
485
     * the exception is thrown. If not, the deserialized record is returned.
486
     *
487
     * @param object|string $content \stdClass of the request content
488
     * @param DocumentModel $model   the model to check the schema for
489
     *
490
     * @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...
491
     * @throws \Exception
492
     */
493
    public function validateRequest($content, DocumentModel $model)
494
    {
495
        $errors = $this->validateContent($content, $model);
0 ignored issues
show
Bug introduced by
It seems like $content defined by parameter $content on line 493 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...
496
        if (!empty($errors)) {
497
            throw new ValidationException($errors);
498
        }
499
        return $this->deserialize($content, $model->getEntityClass());
0 ignored issues
show
Bug introduced by
It seems like $content defined by parameter $content on line 493 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...
500
    }
501
}
502