Completed
Push — develop ( e92646...02469b )
by Henry
08:15
created

RecordsRequestHandler::handleRequest()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 17
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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