Completed
Pull Request — develop (#430)
by Narcotic
15:11 queued 09:56
created

RestUtils::getOptionRoutes()   B

Complexity

Conditions 5
Paths 1

Size

Total Lines 24
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
dl 0
loc 24
ccs 0
cts 16
cp 0
rs 8.5125
c 0
b 0
f 0
cc 5
eloc 14
nc 1
nop 0
crap 30
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
     * @var LoggerInterface
57
     */
58
    private $logger;
59
60
    /**
61
     * @var SchemaUtils
62
     */
63
    private $schemaUtils;
64
65
    /**
66
     * @var Validator
67
     */
68
    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 6
    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 6
        $this->container = $container;
89 6
        $this->serializer = $serializer;
90 6
        $this->serializerContext = $serializerContext;
91 6
        $this->router = $router;
92 6
        $this->logger = $logger;
93 6
        $this->schemaUtils = $schemaUtils;
94 6
        $this->schemaValidator = $schemaValidator;
95 6
    }
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 4
    public function serializeContent($content, $format = 'json')
137
    {
138
        try {
139 4
            return $this->getSerializer()->serialize(
140 2
                $content,
141 2
                $format,
142 4
                $this->getSerializerContext()
143 2
            );
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 4
    public function deserializeContent($content, $documentClass, $format = 'json')
168
    {
169 4
        $record = $this->getSerializer()->deserialize(
170 2
            $content,
171 2
            $documentClass,
172
            $format
173 2
        );
174
175 4
        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 4
    public function validateContent($content, DocumentModel $model)
189
    {
190 4
        $schema = $this->serializeContent(
191 4
            $this->schemaUtils->getModelSchema(null, $model, true, true)
192 2
        );
193
194 4
        if (is_string($content)) {
195 4
            $content = json_decode($content);
196 2
        }
197
198 4
        return $this->schemaValidator->validate(
199 2
            $content,
200 2
            json_decode($schema)
201 2
        );
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 4
    public function checkJsonRequest(Request $request, Response $response, DocumentModel $model, $content = '')
215
    {
216 4
        if (empty($content)) {
217 4
            $content = $request->getContent();
218 2
        }
219
220 4
        if (is_resource($content)) {
221
            throw new BadRequestHttpException('unexpected resource in validation');
222
        }
223
224
        // is request body empty
225 4
        if ($content === '') {
226
            $e = new NoInputException();
227
            $e->setResponse($response);
228
            throw $e;
229
        }
230
231 4
        $input = json_decode($content, true);
232 4
        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 4
        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 4
        if ($request->getMethod() == 'PUT' && array_key_exists('id', $input)) {
245
            // we need to check for id mismatches....
246 4
            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 2
        }
252
253 4
        if ($request->getMethod() == 'POST' &&
254 4
            array_key_exists('id', $input) &&
255 2
            !$model->isIdInPostAllowed()
256 2
        ) {
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 4
    }
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 4
    public function getSerializer()
307
    {
308 4
        return $this->serializer;
309
    }
310
311
    /**
312
     * Get the serializer context
313
     *
314
     * @return SerializationContext
315
     */
316 4
    public function getSerializerContext()
317
    {
318 4
        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