Passed
Push — develop ( 33032a...666b96 )
by Henry
02:03
created

RecordsRequestHandler   F

Complexity

Total Complexity 110

Size/Duplication

Total Lines 608
Duplicated Lines 0 %

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 271
dl 0
loc 608
rs 2
c 9
b 0
f 0
wmc 110

33 Methods

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

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