Completed
Push — master ( 8cb42d...e9bfab )
by
unknown
19:56
created

RestUtils::serialize()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 21
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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