Completed
Push — feature/other-validation ( b701a5...b5bedb )
by Narcotic
63:49
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 0
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 2
     * @var LoggerInterface
57
     */
58
    private $logger;
59
60
    /**
61
     * @var SchemaUtils
62
     */
63 2
    private $schemaUtils;
64 2
65 2
    /**
66 2
     * @var Validator
67 2
     */
68 2
    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
    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
        $this->container = $container;
89
        $this->serializer = $serializer;
90
        $this->serializerContext = $serializerContext;
91
        $this->router = $router;
92
        $this->logger = $logger;
93
        $this->schemaUtils = $schemaUtils;
94
        $this->schemaValidator = $schemaValidator;
95
    }
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
    public function serializeContent($content, $format = 'json')
137
    {
138
        try {
139
            return $this->getSerializer()->serialize(
140
                $content,
141
                $format,
142
                $this->getSerializerContext()
143
            );
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
    public function deserializeContent($content, $documentClass, $format = 'json')
168
    {
169
        $record = $this->getSerializer()->deserialize(
170
            $content,
171
            $documentClass,
172
            $format
173
        );
174
175
        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
    public function validateContent($content, DocumentModel $model)
189
    {
190
        $schema = $this->serializeContent(
191
            $this->schemaUtils->getModelSchema(null, $model, true, true)
192
        );
193
194
        return $this->schemaValidator->validate(json_decode($content), json_decode($schema));
195
    }
196
197
    /**
198
     * validate raw json input
199
     *
200
     * @param Request       $request  request
201
     * @param Response      $response response
202
     * @param DocumentModel $model    model
203
     * @param string        $content  Alternative request content.
204
     *
205
     * @return void
206
     */
207
    public function checkJsonRequest(Request $request, Response $response, DocumentModel $model, $content = '')
208
    {
209
        if (empty($content)) {
210
            $content = $request->getContent();
211
        }
212
213
        if (is_resource($content)) {
214
            throw new BadRequestHttpException('unexpected resource in validation');
215
        }
216
217
        // is request body empty
218
        if ($content === '') {
219
            $e = new NoInputException();
220
            $e->setResponse($response);
221
            throw $e;
222
        }
223
224
        $input = json_decode($content, true);
225
        if (JSON_ERROR_NONE !== json_last_error()) {
226
            $e = new MalformedInputException($this->getLastJsonErrorMessage());
227
            $e->setErrorType(json_last_error());
228
            $e->setResponse($response);
229
            throw $e;
230
        }
231
        if (!is_array($input)) {
232
            $e = new MalformedInputException('JSON request body must be an object');
233
            $e->setResponse($response);
234
            throw $e;
235
        }
236
237
        if ($request->getMethod() == 'PUT' && array_key_exists('id', $input)) {
238
            // we need to check for id mismatches....
239
            if ($request->attributes->get('id') != $input['id']) {
240
                $e = new MalformedInputException('Record ID in your payload must be the same');
241
                $e->setResponse($response);
242
                throw $e;
243
            }
244
        }
245
246
        if ($request->getMethod() == 'POST' &&
247
            array_key_exists('id', $input) &&
248
            !$model->isIdInPostAllowed()
249
        ) {
250
            $e = new MalformedInputException(
251
                '"id" can not be given on a POST request. Do a PUT request instead to update an existing record.'
252
            );
253
            $e->setResponse($response);
254
            throw $e;
255
        }
256
    }
257
258
    /**
259
     * Validate JSON patch for any object
260
     *
261
     * @param array $jsonPatch json patch as array
262
     *
263
     * @throws InvalidJsonPatchException
264
     * @return void
265
     */
266
    public function checkJsonPatchRequest(array $jsonPatch)
267
    {
268
        foreach ($jsonPatch as $operation) {
269
            if (!is_array($operation)) {
270
                throw new InvalidJsonPatchException('Patch request should be an array of operations.');
271
            }
272
            if (array_key_exists('path', $operation) && trim($operation['path']) == '/id') {
273
                throw new InvalidJsonPatchException('Change/remove of ID not allowed');
274
            }
275
        }
276
    }
277
278
    /**
279
     * Used for backwards compatibility to PHP 5.4
280
     *
281
     * @return string
282
     */
283
    private function getLastJsonErrorMessage()
284
    {
285
        $message = 'Unable to decode JSON string';
286
287
        if (function_exists('json_last_error_msg')) {
288
            $message = json_last_error_msg();
289
        }
290
291
        return $message;
292
    }
293
294
    /**
295
     * Get the serializer
296
     *
297
     * @return Serializer
298
     */
299
    public function getSerializer()
300
    {
301
        return $this->serializer;
302
    }
303
304
    /**
305
     * Get the serializer context
306
     *
307
     * @return SerializationContext
308
     */
309
    public function getSerializerContext()
310
    {
311
        return clone $this->serializerContext;
312
    }
313
314
    /**
315
     * It has been deemed that we search for OPTION routes in order to detect our
316
     * service routes and then derive the rest from them.
317
     *
318
     * @return array An array with option routes
319
     */
320
    public function getOptionRoutes()
321
    {
322
        $router = $this->router;
323
        $ret = array_filter(
324
            $router->getRouteCollection()
325
                   ->all(),
326
            function ($route) {
327
                if (!in_array('OPTIONS', $route->getMethods())) {
328
                    return false;
329
                }
330
                // ignore all schema routes
331
                if (strpos($route->getPath(), '/schema') === 0) {
332
                    return false;
333
                }
334
                if ($route->getPath() == '/' || $route->getPath() == '/core/version') {
335
                    return false;
336
                }
337
338
                return is_null($route->getRequirement('id'));
339
            }
340
        );
341
342
        return $ret;
343
    }
344
345
    /**
346
     * Based on $baseName, this function returns all routes that match this basename..
347
     * So if you pass graviton.cont.action; it will return all route names that start with the same.
348
     * In our routing naming schema, this means all the routes from the same controller.
349
     *
350
     * @param string $baseName basename
351
     *
352
     * @return array array with matching routes
353
     */
354
    public function getRoutesByBasename($baseName)
355
    {
356
        $ret = array();
357
        foreach ($this->router
358
                      ->getRouteCollection()
359
                      ->all() as $routeName => $route) {
360
            if (preg_match('/^' . $baseName . '/', $routeName)) {
361
                $ret[$routeName] = $route;
362
            }
363
        }
364
365
        return $ret;
366
    }
367
368
    /**
369
     * Gets the Model assigned to the RestController
370
     *
371
     * @param Route $route Route
372
     *
373
     * @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...
374
     * @throws \Exception
375
     */
376
    public function getModelFromRoute(Route $route)
377
    {
378
        $ret = false;
379
        $controller = $this->getControllerFromRoute($route);
380
381
        if ($controller instanceof RestController) {
382
            $ret = $controller->getModel();
383
        }
384
385
        return $ret;
386
    }
387
388
    /**
389
     * Gets the controller from a Route
390
     *
391
     * @param Route $route Route
392
     *
393
     * @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...
394
     */
395
    public function getControllerFromRoute(Route $route)
396
    {
397
        $ret = false;
398
        $actionParts = explode(':', $route->getDefault('_controller'));
399
400
        if (count($actionParts) == 2) {
401
            $ret = $this->container->get($actionParts[0]);
402
        }
403
404
        return $ret;
405
    }
406
}
407