RecordsRequestHandler   F
last analyzed

Complexity

Total Complexity 111

Size/Duplication

Total Lines 606
Duplicated Lines 0 %

Test Coverage

Coverage 99.36%

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 271
c 9
b 0
f 0
dl 0
loc 606
ccs 311
cts 313
cp 0.9936
rs 2
wmc 111

33 Methods

Rating   Name   Duplication   Size   Complexity  
B handleMultiDestroyRequest() 0 40 8
A checkWriteAccess() 0 3 1
A onBeforeRecordSaved() 0 2 1
A onRecordDeleted() 0 2 1
B handleRecordsRequest() 0 30 8
A prepareBrowseConditions() 0 9 3
A handle() 0 20 4
A onRecordRequestNotHandled() 0 6 1
A processDatumSave() 0 29 5
A throwNotFoundError() 0 6 1
A handleCreateRequest() 0 14 2
A getDatumRecord() 0 13 3
A checkAPIAccess() 0 3 1
A applyRecordDelta() 0 6 2
B prepareDefaultBrowseOptions() 0 20 8
A __construct() 0 3 1
A checkBrowseAccess() 0 3 1
A handleDeleteRequest() 0 25 3
B handleRecordRequest() 0 31 7
A prepareResponseModeJSON() 0 6 4
A onBeforeRecordValidated() 0 2 1
A throwUnauthorizedError() 0 6 1
B processDatumDestroy() 0 27 7
C handleBrowseRequest() 0 57 12
A onRecordSaved() 0 2 1
A throwRecordNotFoundError() 0 3 1
A onRecordCreated() 0 2 1
A checkReadAccess() 0 3 1
B handleMultiSaveRequest() 0 42 8
A getRecordByHandle() 0 5 2
A throwAPIUnAuthorizedError() 0 6 1
A getTemplateName() 0 5 1
B handleEditRequest() 0 62 9

How to fix   Complexity   

Complex Class

Complex classes like RecordsRequestHandler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RecordsRequestHandler, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * This file is part of the Divergence package.
4
 *
5
 * (c) Henry Paradiz <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace Divergence\Controllers;
12
13
use Exception;
14
use Divergence\Helpers\JSON;
15
use Divergence\Responders\JsonBuilder;
16
use Divergence\Responders\TwigBuilder;
17
use Divergence\IO\Database\MySQL as DB;
18
use Divergence\Responders\JsonpBuilder;
19
use Psr\Http\Message\ResponseInterface;
20
use Psr\Http\Message\ServerRequestInterface;
21
use Divergence\Models\ActiveRecord as ActiveRecord;
22
23
/**
24
 * RecordsRequestHandler - A REST API for Divergence ActiveRecord
25
 *
26
 * @package Divergence
27
 * @author  Henry Paradiz <[email protected]>
28
 * @author  Chris Alfano <[email protected]>
29
 */
30
abstract class RecordsRequestHandler extends RequestHandler
31
{
32
    public $config;
33
34
    public static $recordClass;
35
    public $accountLevelRead = false;
36
    public $accountLevelBrowse = 'Staff';
37
    public $accountLevelWrite = 'Staff';
38
    public $accountLevelAPI = false;
39
    public $browseOrder = false;
40
    public $browseConditions = false;
41
    public $browseLimitDefault = false;
42
    public $editableFields = false;
43
    public $searchConditions = false;
44
    public $calledClass = __CLASS__;
45
46 73
    public function __construct()
47
    {
48 73
        $this->responseBuilder = TwigBuilder::class;
49
    }
50
51
    /**
52
     * Start of routing for this controller.
53
     * Methods in this execution path will always respond either as an error or a normal response.
54
     * Responsible for detecting JSON or JSONP response modes.
55
     */
56 38
    public function handle(ServerRequestInterface $request): ResponseInterface
57
    {
58
        // save static class
59 38
        $this->calledClass = get_called_class();
60
61
        // handle JSON requests
62 38
        if ($this->peekPath() == 'json') {
63 36
            $this->shiftPath();
64
65
            // check access for API response modes
66 36
            $this->responseBuilder = JsonBuilder::class;
67
68 36
            if (in_array($this->responseBuilder, [JsonBuilder::class,JsonpBuilder::class])) {
69 36
                if (!$this->checkAPIAccess()) {
70 3
                    return $this->throwAPIUnAuthorizedError();
71
                }
72
            }
73
        }
74
75 35
        return $this->handleRecordsRequest();
76
    }
77
78 35
    public function handleRecordsRequest($action = false): ResponseInterface
79
    {
80 35
        switch ($action ? $action : $action = $this->shiftPath()) {
81 35
            case 'save':
82
                {
83 5
                    return $this->handleMultiSaveRequest();
84
                }
85
86 30
            case 'destroy':
87
                {
88 3
                    return $this->handleMultiDestroyRequest();
89
                }
90
91 27
            case 'create':
92
                {
93 3
                    return $this->handleCreateRequest();
94
                }
95
96 24
            case '':
97
            case false:
98
                {
99 11
                    return $this->handleBrowseRequest();
100
                }
101
102
            default:
103
                {
104 13
                    if ($Record = $this->getRecordByHandle($action)) {
105 11
                        return $this->handleRecordRequest($Record);
106
                    } else {
107 2
                        return $this->throwRecordNotFoundError();
108
                    }
109
                }
110
        }
111
    }
112
113 14
    public function getRecordByHandle($handle)
114
    {
115 14
        $className = static::$recordClass;
116 14
        if (method_exists($className, 'getByHandle')) {
117 13
            return $className::getByHandle($handle);
118
        }
119
    }
120
121 11
    public function prepareBrowseConditions($conditions = [])
122
    {
123 11
        if ($this->browseConditions) {
124 2
            if (!is_array($this->browseConditions)) {
0 ignored issues
show
introduced by
The condition is_array($this->browseConditions) is always false.
Loading history...
125 1
                $this->browseConditions = [$this->browseConditions];
126
            }
127 2
            $conditions = array_merge($this->browseConditions, $conditions);
128
        }
129 11
        return $conditions;
130
    }
131
132 11
    public function prepareDefaultBrowseOptions()
133
    {
134 11
        if (!isset($_REQUEST['offset'])) {
135 10
            if (isset($_REQUEST['start'])) {
136 1
                if (is_numeric($_REQUEST['start'])) {
137 1
                    $_REQUEST['offset'] = $_REQUEST['start'];
138
                }
139
            }
140
        }
141
142 11
        $limit = !empty($_REQUEST['limit']) && is_numeric($_REQUEST['limit']) ? $_REQUEST['limit'] : $this->browseLimitDefault;
143 11
        $offset = !empty($_REQUEST['offset']) && is_numeric($_REQUEST['offset']) ? $_REQUEST['offset'] : false;
144
145 11
        $options = [
146 11
            'limit' =>  $limit,
147 11
            'offset' => $offset,
148 11
            'order' => $this->browseOrder,
149 11
        ];
150
151 11
        return $options;
152
    }
153
154 12
    public function handleBrowseRequest($options = [], $conditions = [], $responseID = null, $responseData = []): ResponseInterface
155
    {
156 12
        if (!$this->checkBrowseAccess(func_get_args())) {
157 1
            return $this->throwUnauthorizedError();
158
        }
159
160 11
        $conditions = $this->prepareBrowseConditions($conditions);
161
162 11
        $options = $this->prepareDefaultBrowseOptions();
163
164
        // process sorter
165 11
        if (!empty($_REQUEST['sort'])) {
166 3
            $sort = json_decode($_REQUEST['sort'], true);
167 3
            if (!$sort || !is_array($sort)) {
168 1
                return $this->respond('error', [
169 1
                    'success' => false,
170 1
                    'failed' => [
171 1
                        'errors'	=>	'Invalid sorter.',
172 1
                    ],
173 1
                ]);
174
            }
175
176 2
            if (is_array($sort)) {
0 ignored issues
show
introduced by
The condition is_array($sort) is always true.
Loading history...
177 2
                foreach ($sort as $field) {
178 2
                    $options['order'][$field['property']] = $field['direction'];
179
                }
180
            }
181
        }
182
183
        // process filter
184 10
        if (!empty($_REQUEST['filter'])) {
185 2
            $filter = json_decode($_REQUEST['filter'], true);
186 2
            if (!$filter || !is_array($filter)) {
187 1
                return $this->respond('error', [
188 1
                    'success' => false,
189 1
                    'failed' => [
190 1
                        'errors'	=>	'Invalid filter.',
191 1
                    ],
192 1
                ]);
193
            }
194
195 1
            foreach ($filter as $field) {
196 1
                $conditions[$field['property']] = $field['value'];
197
            }
198
        }
199
200 9
        $className = static::$recordClass;
201
202 9
        return $this->respond(
203 9
            isset($responseID) ? $responseID : $this->getTemplateName($className::$pluralNoun),
204 9
            array_merge($responseData, [
205 9
                'success' => true,
206 9
                'data' => $className::getAllByWhere($conditions, $options),
207 9
                'conditions' => $conditions,
208 9
                'total' => DB::foundRows(),
209 9
                'limit' => $options['limit'],
210 9
                'offset' => $options['offset'],
211 9
            ])
212 9
        );
213
    }
214
215
216 12
    public function handleRecordRequest(ActiveRecord $Record, $action = false)
217
    {
218 12
        if (!$this->checkReadAccess($Record)) {
219 2
            return $this->throwUnauthorizedError();
220
        }
221
222 10
        switch ($action ? $action : $action = $this->shiftPath()) {
223 10
            case '':
224
            case false:
225
                {
226 3
                    $className = static::$recordClass;
227
228 3
                    return $this->respond($this->getTemplateName($className::$singularNoun), [
229 3
                        'success' => true,
230 3
                        'data' => $Record,
231 3
                    ]);
232
                }
233
234 7
            case 'edit':
235
                {
236 4
                    return $this->handleEditRequest($Record);
237
                }
238
239 3
            case 'delete':
240
                {
241 2
                    return $this->handleDeleteRequest($Record);
242
                }
243
244
            default:
245
                {
246 1
                    return $this->onRecordRequestNotHandled($Record, $action);
247
                }
248
        }
249
    }
250
251
252 8
    public function prepareResponseModeJSON($methods = [])
253
    {
254 8
        if ($this->responseBuilder === JsonBuilder::class && in_array($_SERVER['REQUEST_METHOD'], $methods)) {
255 8
            $JSONData = JSON::getRequestData();
256 8
            if (is_array($JSONData)) {
257 4
                $_REQUEST = $JSONData;
258
            }
259
        }
260
    }
261
262 6
    public function getDatumRecord($datum)
263
    {
264 6
        $className = static::$recordClass;
265 6
        $PrimaryKey = $className::getPrimaryKey();
266 6
        if (empty($datum[$PrimaryKey])) {
267 4
            $record = new $className::$defaultClass();
268 4
            $this->onRecordCreated($record, $datum);
269
        } else {
270 2
            if (!$record = $className::getByID($datum[$PrimaryKey])) {
271 1
                throw new Exception('Record not found');
272
            }
273
        }
274 6
        return $record;
275
    }
276
277 6
    public function processDatumSave($datum)
278
    {
279
        // get record
280 6
        $Record = $this->getDatumRecord($datum);
281
282
        // check write access
283 6
        if (!$this->checkWriteAccess($Record)) {
284 1
            throw new Exception('Write access denied');
285
        }
286
287
        // apply delta
288 5
        $this->applyRecordDelta($Record, $datum);
289
290
        // call template function
291 5
        $this->onBeforeRecordValidated($Record, $datum);
292
293
        // try to save record
294
        try {
295
            // call template function
296 5
            $this->onBeforeRecordSaved($Record, $datum);
297
298 5
            $Record->save();
299
300
            // call template function
301 4
            $this->onRecordSaved($Record, $datum);
302
303 4
            return (!$Record::fieldExists('Class') || get_class($Record) == $Record->Class) ? $Record : $Record->changeClass();
304 1
        } catch (Exception $e) {
305 1
            throw $e;
306
        }
307
    }
308
309 5
    public function handleMultiSaveRequest(): ResponseInterface
310
    {
311 5
        $className = static::$recordClass;
312
313 5
        $this->prepareResponseModeJSON(['POST','PUT']);
314
315 5
        if (!empty($_REQUEST['data'])) {
316 4
            if ($className::fieldExists(key($_REQUEST['data']))) {
317 1
                $_REQUEST['data'] = [$_REQUEST['data']];
318
            }
319
        }
320
321
322 5
        if (empty($_REQUEST['data']) || !is_array($_REQUEST['data'])) {
323 1
            return $this->respond('error', [
324 1
                'success' => false,
325 1
                'failed' => [
326 1
                    'errors'	=>	'Save expects "data" field as array of records.',
327 1
                ],
328 1
            ]);
329
        }
330
331 4
        $results = [];
332 4
        $failed = [];
333
334 4
        foreach ($_REQUEST['data'] as $datum) {
335
            try {
336 4
                $results[] = $this->processDatumSave($datum);
337 1
            } catch (Exception $e) {
338 1
                $failed[] = [
339 1
                    'record' => $datum,
340 1
                    'errors' => $e->getMessage(),
341 1
                ];
342 1
                continue;
343
            }
344
        }
345
346
347 4
        return $this->respond($this->getTemplateName($className::$pluralNoun).'Saved', [
348 4
            'success' => count($results) || !count($failed),
349 4
            'data' => $results,
350 4
            'failed' => $failed,
351 4
        ]);
352
    }
353
354 5
    public function processDatumDestroy($datum)
355
    {
356 5
        $className = static::$recordClass;
357 5
        $PrimaryKey = $className::getPrimaryKey();
358
359
        // get record
360 5
        if (is_numeric($datum)) {
361 1
            $recordID = $datum;
362 4
        } elseif (!empty($datum[$PrimaryKey]) && is_numeric($datum[$PrimaryKey])) {
363 3
            $recordID = $datum[$PrimaryKey];
364
        } else {
365 1
            throw new Exception($PrimaryKey.' missing');
366
        }
367
368 4
        if (!$Record = $className::getByField($PrimaryKey, $recordID)) {
369 2
            throw new Exception($PrimaryKey.' not found');
370
        }
371
372
        // check write access
373 4
        if (!$this->checkWriteAccess($Record)) {
374 1
            throw new Exception('Write access denied');
375
        }
376
377 3
        if ($Record->destroy()) {
378 2
            return $Record;
379
        } else {
380
            throw new Exception('Destroy failed');
381
        }
382
    }
383
384 3
    public function handleMultiDestroyRequest(): ResponseInterface
385
    {
386 3
        $className = static::$recordClass;
387
388 3
        $this->prepareResponseModeJSON(['POST','PUT','DELETE']);
389
390 3
        if (!empty($_REQUEST['data'])) {
391 2
            if ($className::fieldExists(key($_REQUEST['data']))) {
392
                $_REQUEST['data'] = [$_REQUEST['data']];
393
            }
394
        }
395
396 3
        if (empty($_REQUEST['data']) || !is_array($_REQUEST['data'])) {
397 1
            return $this->respond('error', [
398 1
                'success' => false,
399 1
                'failed' => [
400 1
                    'errors'	=>	'Save expects "data" field as array of records.',
401 1
                ],
402 1
            ]);
403
        }
404
405 2
        $results = [];
406 2
        $failed = [];
407
408 2
        foreach ($_REQUEST['data'] as $datum) {
409
            try {
410 2
                $results[] = $this->processDatumDestroy($datum);
411 2
            } catch (Exception $e) {
412 2
                $failed[] = [
413 2
                    'record' => $datum,
414 2
                    'errors' => $e->getMessage(),
415 2
                ];
416 2
                continue;
417
            }
418
        }
419
420 2
        return $this->respond($this->getTemplateName($className::$pluralNoun).'Destroyed', [
421 2
            'success' => count($results) || !count($failed),
422 2
            'data' => $results,
423 2
            'failed' => $failed,
424 2
        ]);
425
    }
426
427
428 3
    public function handleCreateRequest(ActiveRecord $Record = null): ResponseInterface
429
    {
430
        // save static class
431 3
        $this->calledClass = get_called_class();
432
433 3
        if (!$Record) {
434 3
            $className = static::$recordClass;
435 3
            $Record = new $className::$defaultClass();
436
        }
437
438
        // call template function
439 3
        $this->onRecordCreated($Record, $_REQUEST);
440
441 3
        return $this->handleEditRequest($Record);
442
    }
443
444 7
    public function handleEditRequest(ActiveRecord $Record): ResponseInterface
445
    {
446 7
        $className = static::$recordClass;
447
448 7
        if (!$this->checkWriteAccess($Record)) {
449 1
            return $this->throwUnauthorizedError();
450
        }
451
452 6
        if (in_array($_SERVER['REQUEST_METHOD'], ['POST','PUT'])) {
453 5
            if ($this->responseBuilder === JsonBuilder::class) {
454 5
                $_REQUEST = JSON::getRequestData();
455 5
                if (isset($_REQUEST['data']) && is_array($_REQUEST['data'])) {
456 2
                    $_REQUEST = $_REQUEST['data'];
457
                }
458
            }
459 5
            $_REQUEST = $_REQUEST ? $_REQUEST : $_POST;
460
461
            // apply delta
462 5
            $this->applyRecordDelta($Record, $_REQUEST);
463
464
            // call template function
465 5
            $this->onBeforeRecordValidated($Record, $_REQUEST);
466
467
            // validate
468 5
            if ($Record->validate()) {
469
                // call template function
470 5
                $this->onBeforeRecordSaved($Record, $_REQUEST);
471
472
                try {
473
                    // save session
474 5
                    $Record->save();
475 1
                } catch (Exception $e) {
476 1
                    return $this->respond('Error', [
477 1
                        'success' => false,
478 1
                        'failed' => [
479 1
                            'errors'	=>	$e->getMessage(),
480 1
                        ],
481 1
                    ]);
482
                }
483
484
                // call template function
485 4
                $this->onRecordSaved($Record, $_REQUEST);
486
487
                // fire created response
488 4
                $responseID = $this->getTemplateName($className::$singularNoun).'Saved';
489 4
                $responseData = [
490 4
                    'success' => true,
491 4
                    'data' => $Record,
492 4
                ];
493 4
                return $this->respond($responseID, $responseData);
494
            }
495
496
            // fall through back to form if validation failed
497
        }
498
499 1
        $responseID = $this->getTemplateName($className::$singularNoun).'Edit';
500 1
        $responseData = [
501 1
            'success' => false,
502 1
            'data' => $Record,
503 1
        ];
504
505 1
        return $this->respond($responseID, $responseData);
506
    }
507
508
509 3
    public function handleDeleteRequest(ActiveRecord $Record): ResponseInterface
510
    {
511 3
        $className = static::$recordClass;
512
513 3
        if (!$this->checkWriteAccess($Record)) {
514 1
            return $this->throwUnauthorizedError();
515
        }
516
517 2
        if ($_SERVER['REQUEST_METHOD'] == 'POST') {
518 1
            $data = $Record->data;
519 1
            $Record->destroy();
520
521
            // call cleanup function after delete
522 1
            $this->onRecordDeleted($Record, $data);
523
524
            // fire created response
525 1
            return $this->respond($this->getTemplateName($className::$singularNoun).'Deleted', [
526 1
                'success' => true,
527 1
                'data' => $Record,
528 1
            ]);
529
        }
530
531 1
        return $this->respond('confirm', [
532 1
            'question' => 'Are you sure you want to delete this '.$className::$singularNoun.'?',
533 1
            'data' => $Record,
534 1
        ]);
535
    }
536
537
    // access control template functions
538 11
    public function checkBrowseAccess($arguments)
539
    {
540 11
        return true;
541
    }
542
543 22
    public function checkReadAccess(ActiveRecord $Record)
544
    {
545 22
        return true;
546
    }
547
548 16
    public function checkWriteAccess(ActiveRecord $Record)
549
    {
550 16
        return true;
551
    }
552
553 29
    public function checkAPIAccess()
554
    {
555 29
        return true;
556
    }
557
558 6
    public function throwUnauthorizedError(): ResponseInterface
559
    {
560 6
        return $this->respond('Unauthorized', [
561 6
            'success' => false,
562 6
            'failed' => [
563 6
                'errors'	=>	'Login required.',
564 6
            ],
565 6
        ]);
566
    }
567
568 3
    public function throwAPIUnAuthorizedError(): ResponseInterface
569
    {
570 3
        return $this->respond('Unauthorized', [
571 3
            'success' => false,
572 3
            'failed' => [
573 3
                'errors'	=>	'API access required.',
574 3
            ],
575 3
        ]);
576
    }
577
578 3
    public function throwNotFoundError(): ResponseInterface
579
    {
580 3
        return $this->respond('error', [
581 3
            'success' => false,
582 3
            'failed' => [
583 3
                'errors'	=>	'Record not found.',
584 3
            ],
585 3
        ]);
586
    }
587
588 1
    public function onRecordRequestNotHandled(ActiveRecord $Record, $action): ResponseInterface
589
    {
590 1
        return $this->respond('error', [
591 1
            'success' => false,
592 1
            'failed' => [
593 1
                'errors'	=>	'Malformed request.',
594 1
            ],
595 1
        ]);
596
    }
597
598
599
600 25
    public function getTemplateName($noun)
601
    {
602 25
        return preg_replace_callback('/\s+([a-zA-Z])/', function ($matches) {
603 3
            return strtoupper($matches[1]);
604 25
        }, $noun);
605
    }
606
607 11
    public function applyRecordDelta(ActiveRecord $Record, $data)
608
    {
609 11
        if (is_array($this->editableFields)) {
0 ignored issues
show
introduced by
The condition is_array($this->editableFields) is always false.
Loading history...
610 1
            $Record->setFields(array_intersect_key($data, array_flip($this->editableFields)));
611
        } else {
612 10
            $Record->setFields($data);
613
        }
614
    }
615
616
    // event template functions
617 7
    protected function onRecordCreated(ActiveRecord $Record, $data)
618
    {
619 7
    }
620 10
    protected function onBeforeRecordValidated(ActiveRecord $Record, $data)
621
    {
622 10
    }
623 10
    protected function onBeforeRecordSaved(ActiveRecord $Record, $data)
624
    {
625 10
    }
626 1
    protected function onRecordDeleted(ActiveRecord $Record, $data)
627
    {
628 1
    }
629 8
    protected function onRecordSaved(ActiveRecord $Record, $data)
630
    {
631 8
    }
632
633 2
    protected function throwRecordNotFoundError()
634
    {
635 2
        return $this->throwNotFoundError();
636
    }
637
}
638