Completed
Push — master ( 123852...550d67 )
by
unknown
09:16
created

RestController::putAction()   B

Complexity

Conditions 4
Paths 5

Size

Total Lines 35
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
dl 0
loc 35
ccs 0
cts 22
cp 0
rs 8.5806
c 0
b 0
f 0
cc 4
eloc 17
nc 5
nop 2
crap 20
1
<?php
2
/**
3
 * basic rest controller
4
 */
5
6
namespace Graviton\RestBundle\Controller;
7
8
use Graviton\ExceptionBundle\Exception\DeserializationException;
9
use Graviton\ExceptionBundle\Exception\InvalidJsonPatchException;
10
use Graviton\ExceptionBundle\Exception\MalformedInputException;
11
use Graviton\ExceptionBundle\Exception\NotFoundException;
12
use Graviton\ExceptionBundle\Exception\SerializationException;
13
use Graviton\JsonSchemaBundle\Exception\ValidationException;
14
use Graviton\RestBundle\Model\DocumentModel;
15
use Graviton\SchemaBundle\SchemaUtils;
16
use Graviton\RestBundle\Service\RestUtilsInterface;
17
use Graviton\SecurityBundle\Entities\SecurityUser;
18
use Graviton\SecurityBundle\Service\SecurityUtils;
19
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
20
use Symfony\Component\DependencyInjection\ContainerInterface;
21
use Symfony\Component\HttpFoundation\Request;
22
use Symfony\Component\HttpFoundation\Response;
23
use Symfony\Component\Routing\Exception\RouteNotFoundException;
24
use Symfony\Bundle\FrameworkBundle\Routing\Router;
25
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
26
use Symfony\Bundle\FrameworkBundle\Templating\EngineInterface;
27
use Rs\Json\Patch;
28
use Rs\Json\Patch\InvalidPatchDocumentJsonException;
29
use Rs\Json\Patch\InvalidTargetDocumentJsonException;
30
use Rs\Json\Patch\InvalidOperationException;
31
use Rs\Json\Patch\FailedTestException;
32
use Graviton\RestBundle\Service\JsonPatchValidator;
33
34
/**
35
 * This is a basic rest controller. It should fit the most needs but if you need to add some
36
 * extra functionality you can extend it and overwrite single/all actions.
37
 * You can also extend the model class to add some extra logic before save
38
 *
39
 * @author   List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
40
 * @license  http://opensource.org/licenses/gpl-license.php GNU Public License
41
 * @link     http://swisscom.ch
42
 */
43
class RestController
44
{
45
    /**
46
     * @var DocumentModel
47
     */
48
    private $model;
49
50
    /**
51
     * @var ContainerInterface service_container
52
     */
53
    private $container;
54
55
    /**
56
     * @var Response
57
     */
58
    private $response;
59
60
    /**
61
     * @var RestUtilsInterface
62
     */
63
    private $restUtils;
64
65
    /**
66
     * @var SchemaUtils
67
     */
68
    private $schemaUtils;
69
70
    /**
71
     * @var Router
72
     */
73
    private $router;
74
75
    /**
76
     * @var EngineInterface
77
     */
78
    private $templating;
79
80
    /**
81
     * @var JsonPatchValidator
82
     */
83
    private $jsonPatchValidator;
84
85
    /**
86
     * @var SecurityUtils
87
     */
88
    protected $securityUtils;
89
90
    /**
91
     * @param Response           $response    Response
92
     * @param RestUtilsInterface $restUtils   Rest utils
93
     * @param Router             $router      Router
94
     * @param EngineInterface    $templating  Templating
95
     * @param ContainerInterface $container   Container
96
     * @param SchemaUtils        $schemaUtils Schema utils
97
     */
98
    public function __construct(
99
        Response $response,
100
        RestUtilsInterface $restUtils,
101
        Router $router,
102
        EngineInterface $templating,
103
        ContainerInterface $container,
104
        SchemaUtils $schemaUtils
105
    ) {
106
        $this->response = $response;
107
        $this->restUtils = $restUtils;
108
        $this->router = $router;
109
        $this->templating = $templating;
110
        $this->container = $container;
111
        $this->schemaUtils = $schemaUtils;
112
    }
113
114
    /**
115
     * Setter for the SecurityUtils
116
     *
117
     * @param SecurityUtils $securityUtils The securityUtils service
118
     * @return void
119
     */
120
    public function setSecurityUtils(SecurityUtils $securityUtils)
121
    {
122
        $this->securityUtils = $securityUtils;
123
    }
124
125
    /**
126
     * @param JsonPatchValidator $jsonPatchValidator Service for validation json patch
127
     * @return void
128
     */
129
    public function setJsonPatchValidator(JsonPatchValidator $jsonPatchValidator)
130
    {
131
        $this->jsonPatchValidator = $jsonPatchValidator;
132
    }
133
134
    /**
135
     * Get the container object
136
     *
137
     * @return \Symfony\Component\DependencyInjection\ContainerInterface
138
     *
139
     * @obsolete
140
     */
141
    public function getContainer()
142
    {
143
        return $this->container;
144
    }
145
146
    /**
147
     * Returns a single record
148
     *
149
     * @param Request $request Current http request
150
     * @param string  $id      ID of record
151
     *
152
     * @return \Symfony\Component\HttpFoundation\Response $response Response with result or error
153
     */
154 View Code Duplication
    public function getAction(Request $request, $id)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
155
    {
156
        $response = $this->getResponse()
157
            ->setStatusCode(Response::HTTP_OK)
158
            ->setContent($this->serialize($this->findRecord($id, $request)));
159
160
        return $response;
161
    }
162
163
    /**
164
     * Get the response object
165
     *
166
     * @return \Symfony\Component\HttpFoundation\Response $response Response object
167
     */
168
    public function getResponse()
169
    {
170
        return $this->response;
171
    }
172
173
    /**
174
     * Get a single record from database or throw an exception if it doesn't exist
175
     *
176
     * @param mixed   $id      Record id
177
     * @param Request $request request
0 ignored issues
show
Documentation introduced by
Should the type for parameter $request not be null|Request?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
178
     *
179
     * @throws \Graviton\ExceptionBundle\Exception\NotFoundException
180
     *
181
     * @return object $record Document object
182
     */
183
    protected function findRecord($id, Request $request = null)
184
    {
185
        $response = $this->getResponse();
186
187
        if (!($this->getModel()->recordExists($id))) {
188
            $e = new NotFoundException("Entry with id " . $id . " not found!");
189
            $e->setResponse($response);
190
            throw $e;
191
        }
192
193
        return $this->getModel()->find($id, $request);
194
    }
195
196
    /**
197
     * Return the model
198
     *
199
     * @throws \Exception in case no model was defined.
200
     *
201
     * @return DocumentModel $model Model
202
     */
203
    public function getModel()
204
    {
205
        if (!$this->model) {
206
            throw new \Exception('No model is set for this controller');
207
        }
208
209
        return $this->model;
210
    }
211
212
    /**
213
     * Set the model class
214
     *
215
     * @param DocumentModel $model Model class
216
     *
217
     * @return self
218
     */
219
    public function setModel(DocumentModel $model)
220
    {
221
        $this->model = $model;
222
223
        return $this;
224
    }
225
226
    /**
227
     * Serialize the given record and throw an exception if something went wrong
228
     *
229
     * @param object|object[] $result Record(s)
230
     *
231
     * @throws \Graviton\ExceptionBundle\Exception\SerializationException
232
     *
233
     * @return string $content Json content
234
     */
235
    protected function serialize($result)
236
    {
237
        $response = $this->getResponse();
238
239
        try {
240
            // array is serialized as an object {"0":{...},"1":{...},...} when data contains an empty objects
241
            // we serialize each item because we can assume this bug affects only root array element
242
            if (is_array($result) && array_keys($result) === range(0, count($result) - 1)) {
243
                $result = array_map(
244
                    function ($item) {
245
                        return $this->getRestUtils()->serializeContent($item);
246
                    },
247
                    $result
248
                );
249
250
                return '['.implode(',', array_filter($result)).']';
251
            }
252
253
            return $this->getRestUtils()->serializeContent($result);
0 ignored issues
show
Bug introduced by
It seems like $result defined by parameter $result on line 235 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...
254
        } catch (\Exception $e) {
255
            $exception = new SerializationException($e);
256
            $exception->setResponse($response);
257
            throw $exception;
258
        }
259
    }
260
261
    /**
262
     * Get RestUtils service
263
     *
264
     * @return \Graviton\RestBundle\Service\RestUtils
265
     */
266
    public function getRestUtils()
267
    {
268
        return $this->restUtils;
269
    }
270
271
    /**
272
     * Returns all records
273
     *
274
     * @param Request $request Current http request
275
     *
276
     * @return \Symfony\Component\HttpFoundation\Response $response Response with result or error
277
     */
278 View Code Duplication
    public function allAction(Request $request)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
279
    {
280
        $model = $this->getModel();
281
282
        $response = $this->getResponse()
283
            ->setStatusCode(Response::HTTP_OK)
284
            ->setContent($this->serialize($model->findAll($request)));
285
286
        return $response;
287
    }
288
289
    /**
290
     * Writes a new Entry to the database
291
     *
292
     * @param Request $request Current http request
293
     *
294
     * @return \Symfony\Component\HttpFoundation\Response $response Result of action with data (if successful)
295
     */
296
    public function postAction(Request $request)
297
    {
298
        // Get the response object from container
299
        $response = $this->getResponse();
300
        $model = $this->getModel();
301
302
        $this->restUtils->checkJsonRequest($request, $response, $this->getModel());
303
304
        $record = $this->validateRequest($request->getContent(), $model);
0 ignored issues
show
Bug introduced by
It seems like $request->getContent() targeting Symfony\Component\HttpFo...n\Request::getContent() can also be of type resource; however, Graviton\RestBundle\Cont...ller::validateRequest() does only seem to accept object|string, maybe add an additional type check?

This check looks at variables that 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...
305
306
        // Insert the new record
307
        $record = $this->getModel()->insertRecord($record);
308
309
        // store id of new record so we dont need to reparse body later when needed
310
        $request->attributes->set('id', $record->getId());
311
312
        // Set status code
313
        $response->setStatusCode(Response::HTTP_CREATED);
314
315
        $response->headers->set(
316
            'Location',
317
            $this->getRouter()->generate($this->getRouteName($request), array('id' => $record->getId()))
318
        );
319
320
        return $response;
321
    }
322
323
    /**
324
     * Validates the current request on schema violations. If there are errors,
325
     * the exception is thrown. If not, the deserialized record is returned.
326
     *
327
     * @param object|string $content \stdClass of the request content
328
     * @param DocumentModel $model   the model to check the schema for
329
     *
330
     * @return \Graviton\JsonSchemaBundle\Exception\ValidationExceptionError[]
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...
331
     * @throws \Exception
332
     */
333
    protected function validateRequest($content, DocumentModel $model)
334
    {
335
        $errors = $this->restUtils->validateContent($content, $model);
336
        if (!empty($errors)) {
337
            throw new ValidationException($errors);
338
        }
339
        return $this->deserialize($content, $model->getEntityClass());
0 ignored issues
show
Bug introduced by
It seems like $content defined by parameter $content on line 333 can also be of type object; however, Graviton\RestBundle\Cont...ntroller::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...
340
    }
341
342
    /**
343
     * Deserialize the given content throw an exception if something went wrong
344
     *
345
     * @param string $content       Request content
346
     * @param string $documentClass Document class
347
     *
348
     * @throws DeserializationException
349
     *
350
     * @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...
351
     */
352
    protected function deserialize($content, $documentClass)
353
    {
354
        $response = $this->getResponse();
355
356
        try {
357
            $record = $this->getRestUtils()->deserializeContent(
358
                $content,
359
                $documentClass
360
            );
361
        } catch (\Exception $e) {
362
            // pass the previous exception in this case to get the error message in the handler
363
            // http://php.net/manual/de/exception.getprevious.php
364
            $exception = new DeserializationException("Deserialization failed", $e);
365
366
            // at the moment, the response has to be set on the exception object.
367
            // try to refactor this and return the graviton.rest.response if none is set...
368
            $exception->setResponse($response);
369
            throw $exception;
370
        }
371
372
        return $record;
373
    }
374
375
    /**
376
     * Get the router from the dic
377
     *
378
     * @return Router
379
     */
380
    public function getRouter()
381
    {
382
        return $this->router;
383
    }
384
385
    /**
386
     * Update a record
387
     *
388
     * @param Number  $id      ID of record
389
     * @param Request $request Current http request
390
     *
391
     * @throws MalformedInputException
392
     *
393
     * @return Response $response Result of action with data (if successful)
394
     */
395
    public function putAction($id, Request $request)
396
    {
397
        $response = $this->getResponse();
398
        $model = $this->getModel();
399
400
        $this->restUtils->checkJsonRequest($request, $response, $this->getModel());
401
402
        $record = $this->validateRequest($request->getContent(), $model);
0 ignored issues
show
Bug introduced by
It seems like $request->getContent() targeting Symfony\Component\HttpFo...n\Request::getContent() can also be of type resource; however, Graviton\RestBundle\Cont...ller::validateRequest() does only seem to accept object|string, maybe add an additional type check?

This check looks at variables that 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...
403
404
        // handle missing 'id' field in input to a PUT operation
405
        // if it is settable on the document, let's set it and move on.. if not, inform the user..
406
        if ($record->getId() != $id) {
407
            // try to set it..
408
            if (is_callable(array($record, 'setId'))) {
409
                $record->setId($id);
410
            } else {
411
                throw new MalformedInputException('No ID was supplied in the request payload.');
412
            }
413
        }
414
415
        // And update the record, if everything is ok
416
        if (!$this->getModel()->recordExists($id)) {
417
            $this->getModel()->insertRecord($record, false);
418
        } else {
419
            $this->getModel()->updateRecord($id, $record, false);
420
        }
421
422
        // Set status code
423
        $response->setStatusCode(Response::HTTP_NO_CONTENT);
424
425
        // store id of new record so we dont need to reparse body later when needed
426
        $request->attributes->set('id', $record->getId());
427
428
        return $response;
429
    }
430
431
    /**
432
     * Patch a record
433
     *
434
     * @param Number  $id      ID of record
435
     * @param Request $request Current http request
436
     *
437
     * @throws MalformedInputException
438
     *
439
     * @return Response $response Result of action with data (if successful)
440
     */
441
    public function patchAction($id, Request $request)
442
    {
443
        $response = $this->getResponse();
444
445
        // Check JSON Patch request
446
        $this->restUtils->checkJsonRequest($request, $response, $this->getModel());
447
        $this->restUtils->checkJsonPatchRequest(json_decode($request->getContent(), 1));
448
449
        // Find record && apply $ref converter
450
        $record = $this->findRecord($id);
451
        $jsonDocument = $this->serialize($record);
452
453
        // Check/validate JSON Patch
454
        if (!$this->jsonPatchValidator->validate($jsonDocument, $request->getContent())) {
0 ignored issues
show
Bug introduced by
It seems like $request->getContent() targeting Symfony\Component\HttpFo...n\Request::getContent() can also be of type resource; however, Graviton\RestBundle\Serv...chValidator::validate() does only seem to accept string, maybe add an additional type check?

This check looks at variables that 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
            throw new InvalidJsonPatchException($this->jsonPatchValidator->getException()->getMessage());
456
        }
457
458
        try {
459
            // Apply JSON patches
460
            $patch = new Patch($jsonDocument, $request->getContent());
0 ignored issues
show
Bug introduced by
It seems like $request->getContent() targeting Symfony\Component\HttpFo...n\Request::getContent() can also be of type resource; however, Rs\Json\Patch::__construct() does only seem to accept string, maybe add an additional type check?

This check looks at variables that 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...
461
            $patchedDocument = $patch->apply();
462
        } catch (InvalidPatchDocumentJsonException $e) {
463
            throw new InvalidJsonPatchException($e->getMessage());
464
        } catch (InvalidTargetDocumentJsonException $e) {
465
            throw new InvalidJsonPatchException($e->getMessage());
466
        } catch (InvalidOperationException $e) {
467
            throw new InvalidJsonPatchException($e->getMessage());
468
        } catch (FailedTestException $e) {
469
            throw new InvalidJsonPatchException($e->getMessage());
470
        }
471
472
        // Validate result object
473
        $model = $this->getModel();
474
        $record = $this->validateRequest($patchedDocument, $model);
475
476
        // Update object
477
        $this->getModel()->updateRecord($id, $record);
478
479
        // Set status code
480
        $response->setStatusCode(Response::HTTP_OK);
481
482
        // Set Content-Location header
483
        $response->headers->set(
484
            'Content-Location',
485
            $this->getRouter()->generate($this->getRouteName($request), array('id' => $record->getId()))
486
        );
487
488
        return $response;
489
    }
490
491
    /**
492
     * Deletes a record
493
     *
494
     * @param Number $id ID of record
495
     *
496
     * @return Response $response Result of the action
497
     */
498
    public function deleteAction($id)
499
    {
500
        $response = $this->getResponse();
501
502
        // does this record exist?
503
        $this->findRecord($id);
504
505
        $this->getModel()->deleteRecord($id);
506
        $response->setStatusCode(Response::HTTP_NO_CONTENT);
507
508
        return $response;
509
    }
510
511
    /**
512
     * Return OPTIONS results.
513
     *
514
     * @param Request $request Current http request
515
     *
516
     * @throws SerializationException
517
     * @return \Symfony\Component\HttpFoundation\Response $response Result of the action
518
     */
519
    public function optionsAction(Request $request)
520
    {
521
        list($app, $module, , $modelName) = explode('.', $request->attributes->get('_route'));
522
523
        $response = $this->response;
524
        $response->setStatusCode(Response::HTTP_NO_CONTENT);
525
526
        // enabled methods for CorsListener
527
        $corsMethods = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
528
        try {
529
            $router = $this->getRouter();
530
            // if post route is available we assume everything is readable
531
            $router->generate(implode('.', array($app, $module, 'rest', $modelName, 'post')));
532
        } catch (RouteNotFoundException $exception) {
533
            // only allow read methods
534
            $corsMethods = 'GET, OPTIONS';
535
        }
536
        $request->attributes->set('corsMethods', $corsMethods);
537
538
        return $response;
539
    }
540
541
542
    /**
543
     * Return schema GET results.
544
     *
545
     * @param Request $request Current http request
546
     * @param string  $id      ID of record
0 ignored issues
show
Documentation introduced by
Should the type for parameter $id not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
547
     *
548
     * @throws SerializationException
549
     * @return \Symfony\Component\HttpFoundation\Response $response Result of the action
550
     */
551
    public function schemaAction(Request $request, $id = null)
552
    {
553
        $request->attributes->set('schemaRequest', true);
554
555
        list($app, $module, , $modelName, $schemaType) = explode('.', $request->attributes->get('_route'));
556
557
        $response = $this->response;
558
        $response->setStatusCode(Response::HTTP_OK);
559
        $response->setPublic();
560
561
        if (!$id && $schemaType != 'canonicalIdSchema') {
562
            $schema = $this->schemaUtils->getCollectionSchema($modelName, $this->getModel());
563
        } else {
564
            $schema = $this->schemaUtils->getModelSchema($modelName, $this->getModel());
565
        }
566
567
        // enabled methods for CorsListener
568
        $corsMethods = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
569
        try {
570
            $router = $this->getRouter();
571
            // if post route is available we assume everything is readable
572
            $router->generate(implode('.', array($app, $module, 'rest', $modelName, 'post')));
573
        } catch (RouteNotFoundException $exception) {
574
            // only allow read methods
575
            $corsMethods = 'GET, OPTIONS';
576
        }
577
        $request->attributes->set('corsMethods', $corsMethods);
578
579
580
        return $this->render(
581
            'GravitonRestBundle:Main:index.json.twig',
582
            ['response' => $this->serialize($schema)],
583
            $response
584
        );
585
    }
586
587
    /**
588
     * Renders a view.
589
     *
590
     * @param string   $view       The view name
591
     * @param array    $parameters An array of parameters to pass to the view
592
     * @param Response $response   A response instance
0 ignored issues
show
Documentation introduced by
Should the type for parameter $response not be null|Response?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
593
     *
594
     * @return Response A Response instance
595
     */
596
    public function render($view, array $parameters = array(), Response $response = null)
597
    {
598
        return $this->templating->renderResponse($view, $parameters, $response);
599
    }
600
601
    /**
602
     * @param Request $request request
603
     * @return string
604
     */
605
    private function getRouteName(Request $request)
606
    {
607
        $routeName = $request->get('_route');
608
        $routeParts = explode('.', $routeName);
609
        $routeType = end($routeParts);
610
611
        if ($routeType == 'post') {
612
            $routeName = substr($routeName, 0, -4) . 'get';
613
        }
614
615
        return $routeName;
616
    }
617
618
    /**
619
     * Security needs to be enabled to get Object.
620
     *
621
     * @return SecurityUser
622
     * @throws UsernameNotFoundException
623
     */
624
    public function getSecurityUser()
625
    {
626
        return $this->securityUtils->getSecurityUser();
627
    }
628
}
629