Test Setup Failed
Push — master ( da4039...f0d7ef )
by Chauncey
14:28
created

GenericRoute::assertValidContextObject()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 1
1
<?php
2
3
namespace Charcoal\Cms\Route;
4
5
use RuntimeException;
6
use InvalidArgumentException;
7
8
// From Pimple
9
use Pimple\Container;
10
11
// From PSR-7
12
use Psr\Http\Message\RequestInterface;
13
use Psr\Http\Message\ResponseInterface;
14
15
// From 'charcoal-factory'
16
use Charcoal\Factory\FactoryInterface;
17
18
// From 'charcoal-translator'
19
use Charcoal\Translator\TranslatorAwareTrait;
20
21
// From 'charcoal-core'
22
use Charcoal\Model\ModelInterface;
23
use Charcoal\Loader\CollectionLoader;
24
25
// From 'charcoal-app'
26
use Charcoal\App\Route\TemplateRoute;
27
28
// From 'charcoal-object'
29
use Charcoal\Object\ObjectRoute;
30
use Charcoal\Object\ObjectRouteInterface;
31
use Charcoal\Object\RoutableInterface;
32
33
// From 'charcoal-cms'
34
use Charcoal\Cms\TemplateableInterface;
35
36
/**
37
 * Generic Object Route Handler
38
 *
39
 * Uses implementations of {@see ObjectRouteInterface}
40
 * to match routes for catch-all routing patterns.
41
 */
42
class GenericRoute extends TemplateRoute
43
{
44
    use TranslatorAwareTrait;
45
46
    /**
47
     * The URI path.
48
     *
49
     * @var string
50
     */
51
    private $path;
52
53
    /**
54
     * The object route.
55
     *
56
     * @var ObjectRouteInterface
57
     */
58
    private $objectRoute;
59
60
    /**
61
     * The target object of the {@see GenericRoute Chainable::$objectRoute}.
62
     *
63
     * @var ModelInterface|RoutableInterface
64
     */
65
    private $contextObject;
66
67
    /**
68
     * Store the factory instance for the current class.
69
     *
70
     * @var FactoryInterface
71
     */
72
    private $modelFactory;
73
74
    /**
75
     * Store the collection loader for the current class.
76
     *
77
     * @var CollectionLoader
78
     */
79
    private $collectionLoader;
80
81
    /**
82
     * Track the state of required dependencies.
83
     *
84
     * @var boolean
85
     */
86
    protected $hasDependencies = false;
87
88
    /**
89
     * The class name of the object route model.
90
     *
91
     * Must be a fully-qualified PHP namespace and an implementation of
92
     * {@see ObjectRouteInterface}. Used by the model factory.
93
     *
94
     * @var string
95
     */
96
    protected $objectRouteClass = ObjectRoute::class;
97
98
    /**
99
     * Store the available templates.
100
     *
101
     * @var array
102
     */
103
    protected $availableTemplates = [];
104
105
    /**
106
     * Returns new template route object.
107
     *
108
     * @param array $data Class depdendencies.
109
     */
110
    public function __construct(array $data)
111
    {
112
        parent::__construct($data);
113
114
        $this->setPath(ltrim($data['path'], '/'));
115
    }
116
117
    /**
118
     * Determine if the URI path resolves to an object.
119
     *
120
     * @param  Container $container A DI (Pimple) container.
121
     * @return boolean
122
     */
123
    public function pathResolvable(Container $container)
124
    {
125
        if ($this->hasDependencies === false) {
126
            $this->setDependencies($container);
127
        }
128
129
        $objectRoute = $this->getObjectRouteFromPath();
130
        if (!$objectRoute || !$this->isValidObjectRoute($objectRoute)) {
131
            return false;
132
        }
133
134
        $contextObject = $this->getContextObject();
135
        if (!$contextObject || !$this->isValidContextObject($contextObject)) {
136
            return false;
137
        }
138
139
        return true;
140
    }
141
142
    /**
143
     * Resolve the dynamic route.
144
     *
145
     * @param  Container         $container A DI (Pimple) container.
146
     * @param  RequestInterface  $request   A PSR-7 compatible Request instance.
147
     * @param  ResponseInterface $response  A PSR-7 compatible Response instance.
148
     * @return ResponseInterface
149
     */
150
    public function __invoke(
151
        Container $container,
152
        RequestInterface $request,
153
        ResponseInterface $response
154
    ) {
155
        if ($this->hasDependencies === false) {
156
            $this->setDependencies($container);
157
        }
158
159
        $response = $this->resolveLatestObjectRoute($request, $response);
160
        if (!$response->isRedirect()) {
161
            $objectRoute = $this->getObjectRouteFromPath();
162
            if (!$objectRoute || !$this->isValidObjectRoute($objectRoute)) {
163
                // If the ObjectRoute is invalid, it probably does not exist
164
                // which also means a Model does not exist.
165
                return $response->withStatus(404);
166
            }
167
168
            $this->resolveTemplateContextObject();
169
170
            $contextObject = $this->getContextObject();
171
            if (!$contextObject || !$this->isValidContextObject($contextObject)) {
172
                // If the Model is invalid, it probably does not exist.
173
                return $response->withStatus(404);
174
            }
175
176
            $templateContent = $this->templateContent($container, $request);
177
178
            $config = $this->config();
179
            $templateIdent = $config['template'];
180
181
            if ($templateContent === $templateIdent || $templateContent === '') {
182
                $container['logger']->warning(sprintf(
183
                    '[%s] Missing or bad template identifier on model [%s] for ID [%s]',
184
                    get_class($this),
185
                    get_class($this->getContextObject()),
186
                    $templateIdent
187
                ));
188
                return $response->withStatus(500);
189
            }
190
191
            $response->write($templateContent);
192
        }
193
194
        return $response;
195
    }
196
197
    /**
198
     * Create a route object.
199
     *
200
     * @return ObjectRouteInterface
201
     */
202
    public function createRouteObject()
203
    {
204
        $route = $this->modelFactory()->create($this->objectRouteClass());
205
206
        return $route;
207
    }
208
209
    /**
210
     * Retrieve the class name of the object route model.
211
     *
212
     * @return string
213
     */
214
    public function objectRouteClass()
215
    {
216
        return $this->objectRouteClass;
217
    }
218
219
    /**
220
     * Inject dependencies from a DI Container.
221
     *
222
     * @param  Container $container A dependencies container instance.
223
     * @return void
224
     */
225
    protected function setDependencies(Container $container)
226
    {
227
        $this->setTranslator($container['translator']);
228
        $this->setModelFactory($container['model/factory']);
229
        $this->setCollectionLoader($container['model/collection/loader']);
230
231
        if (isset($container['config']['templates'])) {
232
            $this->availableTemplates = $container['config']['templates'];
233
        }
234
235
        $this->hasDependencies = true;
236
    }
237
238
    /**
239
     * Determine if the current object route is the latest object route.
240
     *
241
     * This method loads the latest object route from the datasource and compares
242
     * their creation dates. Both instances could be the same object (ID).
243
     *
244
     * @param  RequestInterface  $request  A PSR-7 compatible Request instance.
245
     * @param  ResponseInterface $response A PSR-7 compatible Response instance.
246
     * @return ResponseInterface
247
     */
248
    protected function resolveLatestObjectRoute(
249
        RequestInterface $request,
250
        ResponseInterface $response
251
    ) {
252
        $current = $this->getObjectRouteFromPath();
253
        $latest  = $this->getLatestObjectPathHistory($current);
254
255
        // Redirect if latest route is newer
256
        if ($latest && $latest->getCreationDate() > $current->getCreationDate()) {
257
            $redirect = $this->parseRedirect($latest->getSlug(), $request);
258
            $response = $response->withRedirect($redirect, 301);
259
        }
260
261
        return $response;
262
    }
263
264
    /**
265
     * @return self
266
     */
267
    protected function resolveTemplateContextObject()
268
    {
269
        $config = $this->config();
270
271
        $objectRoute   = $this->getObjectRouteFromPath();
272
        $contextObject = $this->getContextObject();
273
274
        $currentLang   = $objectRoute->getLang();
275
        if ($currentLang) {
276
            // Set language according to the route's language
277
            $this->setLocale($currentLang);
278
        }
279
280
        $templateChoice = [];
281
282
        // Templateable Objects have specific methods
283
        if ($contextObject instanceof TemplateableInterface) {
284
            $identProperty   = $contextObject->property('templateIdent');
285
            $templateIdent   = $contextObject['templateIdent'] ?: $objectRoute->getRouteTemplate();
286
            $controllerIdent = $contextObject['controllerIdent'] ?: $templateIdent;
287
            $templateChoice  = $identProperty->choice($templateIdent);
288
        } else {
289
            // Use global templates to verify for custom paths
290
            $templateIdent   = $objectRoute->getRouteTemplate();
291
            $controllerIdent = $templateIdent;
292
            foreach ($this->availableTemplates as $templateKey => $templateData) {
293
                if (!isset($templateData['value'])) {
294
                    $templateData['value'] = $templateKey;
295
                }
296
297
                if ($templateData['value'] === $templateIdent) {
298
                    $templateChoice = $templateData;
299
                    break;
300
                }
301
            }
302
        }
303
304
        // Template ident defined in template global config
305
        // Check for custom path / controller
306
        if (isset($templateChoice['template'])) {
307
            $templatePath = $templateChoice['template'];
308
            $templateController = $templateChoice['template'];
309
        } else {
310
            $templatePath = $templateIdent;
311
            $templateController = $controllerIdent;
312
        }
313
314
        // Template controller defined in choices, affect it.
315
        if (isset($templateChoice['controller'])) {
316
            $templateController = $templateChoice['controller'];
317
        }
318
319
        $config['template'] = $templatePath;
320
        $config['controller'] = $templateController;
321
322
        // Always be an array
323
        $templateOptions = [];
324
325
        // Custom template options
326
        if (isset($templateChoice['template_options'])) {
327
            $templateOptions = $templateChoice['template_options'];
328
        }
329
330
        // Overwrite from custom object template_options
331
        if ($contextObject instanceof TemplateableInterface) {
332
            if (!empty($contextObject['templateOptions'])) {
333
                $templateOptions = $contextObject['templateOptions'];
334
            }
335
        }
336
337
        if (isset($templateOptions) && $templateOptions) {
338
            // Not sure what this was about?
339
            $config['template_data'] = array_merge($config['template_data'], $templateOptions);
340
        }
341
342
        // Merge Route options from object-route
343
        $routeOptions = $objectRoute->getRouteOptions();
344
        if ($routeOptions && count($routeOptions)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $routeOptions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
345
            $config['template_data'] = array_merge($config['template_data'], $routeOptions);
346
        }
347
348
        $this->setConfig($config);
349
350
        return $this;
351
    }
352
353
    /**
354
     * @param  Container        $container A DI (Pimple) container.
355
     * @param  RequestInterface $request   The request to intialize the template with.
356
     * @return string
357
     */
358
    protected function createTemplate(Container $container, RequestInterface $request)
359
    {
360
        $template = parent::createTemplate($container, $request);
361
362
        $contextObject = $this->getContextObject();
363
        $template['contextObject'] = $contextObject;
364
365
        return $template;
366
    }
367
368
    /**
369
     * Set the class name of the object route model.
370
     *
371
     * @param  string $className The class name of the object route model.
372
     * @throws InvalidArgumentException If the class name is not a string.
373
     * @return self
374
     */
375
    protected function setObjectRouteClass($className)
376
    {
377
        if (!is_string($className)) {
378
            throw new InvalidArgumentException(
379
                'Route class name must be a string.'
380
            );
381
        }
382
383
        $this->objectRouteClass = $className;
384
385
        return $this;
386
    }
387
388
    /**
389
     * Asserts that the object route is valid, throws an Exception if not.
390
     *
391
     * @param  RoutableInterface $contextObject A context object to test.
392
     * @throws InvalidArgumentException If the context object is invalid.
393
     * @return void
394
     */
395
    protected function assertValidContextObject(RoutableInterface $contextObject)
396
    {
397
        if (!$this->isValidContextObject($contextObject)) {
398
            throw new InvalidArgumentException('Invalid context object');
399
        }
400
    }
401
402
    /**
403
     * Determine if the object route is valid.
404
     *
405
     * @param  RoutableInterface $contextObject A context object to test.
406
     * @return boolean
407
     */
408
    protected function isValidContextObject(RoutableInterface $contextObject)
409
    {
410
        if (!$contextObject->id()) {
411
            return false;
412
        }
413
414
        if ($contextObject instanceof RoutableInterface) {
415
            return $contextObject->isActiveRoute();
416
        }
417
418
        if (isset($contextObject['active'])) {
419
            return (bool)$contextObject['active'];
420
        }
421
422
        return true;
423
    }
424
425
    /**
426
     * Get the object associated with the matching object route.
427
     *
428
     * @return RoutableInterface
429
     */
430
    protected function getContextObject()
431
    {
432
        if ($this->contextObject === null) {
433
            $this->contextObject = $this->loadContextObject();
434
        }
435
436
        return $this->contextObject;
437
    }
438
439
    /**
440
     * Load the object associated with the matching object route.
441
     *
442
     * Validating if the object ID exists is delegated to the
443
     * {@see GenericRoute Chainable::pathResolvable()} method.
444
     *
445
     * @throws RuntimeException If the object route is incomplete.
446
     * @return RoutableInterface
447
     */
448
    protected function loadContextObject()
449
    {
450
        $route = $this->getObjectRouteFromPath();
451
452
        $this->assertValidObjectRoute($route);
453
454
        $obj = $this->modelFactory()->create($route->getRouteObjType());
455
        $obj->load($route->getRouteObjId());
456
457
        return $obj;
458
    }
459
460
    /**
461
     * Asserts that the object route is valid, throws an Exception if not.
462
     *
463
     * @param  ObjectRouteInterface $route An object route to test.
464
     * @throws InvalidArgumentException If the object route is incomplete.
465
     * @return void
466
     */
467
    protected function assertValidObjectRoute(ObjectRouteInterface $route)
468
    {
469
        if (!$this->isValidObjectRoute($route)) {
470
            throw new InvalidArgumentException('Incomplete object route');
471
        }
472
    }
473
474
    /**
475
     * Determine if the object route is valid.
476
     *
477
     * @param  ObjectRouteInterface $route An object route to test.
478
     * @return boolean
479
     */
480
    protected function isValidObjectRoute(ObjectRouteInterface $route)
481
    {
482
        return ($route->id() && $route->getRouteObjType() && $route->getRouteObjId());
483
    }
484
485
    /**
486
     * Get the object route matching the URI path.
487
     *
488
     * @return ObjectRouteInterface
489
     */
490
    protected function getObjectRouteFromPath()
491
    {
492
        if ($this->objectRoute === null) {
493
            $this->objectRoute = $this->loadObjectRouteFromPath();
494
        }
495
496
        return $this->objectRoute;
497
    }
498
499
    /**
500
     * Load the object route matching the URI path.
501
     *
502
     * @return ObjectRouteInterface
503
     */
504
    protected function loadObjectRouteFromPath()
505
    {
506
        // Load current slug
507
        // Slugs are unique
508
        // Slug can be duplicated by adding the front "/" to it hence the order by last_modification_date
509
        $route = $this->createRouteObject();
510
        $table = '`'.$route->source()->table().'`';
511
        $where = '`lang` = :lang AND (`slug` = :route1 OR `slug` = :route2)';
512
        $order = '`last_modification_date` DESC';
513
        $query = 'SELECT * FROM '.$table.' WHERE '.$where.' ORDER BY '.$order.' LIMIT 1';
514
515
        $route->loadFromQuery($query, [
516
            'route1' => '/'.$this->path(),
517
            'route2' => $this->path(),
518
            'lang'   => $this->translator()->getLocale(),
519
        ]);
520
521
        return $route;
522
    }
523
524
    /**
525
     * Retrieve the latest object route from the given object route's
526
     * associated object.
527
     *
528
     * The object routes are ordered by descending creation date (latest first).
529
     * Should never MISS, the given object route should exist.
530
     *
531
     * @param  ObjectRouteInterface $route Routable Object.
532
     * @return ObjectRouteInterface|null
533
     */
534
    public function getLatestObjectPathHistory(ObjectRouteInterface $route)
535
    {
536
        if (!$this->isValidObjectRoute($route)) {
537
            return null;
538
        }
539
540
        $loader = $this->collectionLoader();
541
        $loader
542
            ->setModel($route)
0 ignored issues
show
Documentation introduced by
$route is of type object<Charcoal\Object\ObjectRouteInterface>, but the function expects a string|object<Charcoal\Model\ModelInterface>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
543
            ->addFilter('active', true)
544
            ->addFilter('route_obj_type', $route->getRouteObjType())
545
            ->addFilter('route_obj_id', $route->getRouteObjId())
546
            ->addFilter('lang', $route->getLang())
547
            ->addOrder('creation_date', 'desc')
548
            ->setPage(1)
549
            ->setNumPerPage(1);
550
551
        if ($route->getRouteOptionsIdent()) {
552
            $loader->addFilter('route_options_ident', $route->getRouteOptionsIdent());
553
        } else {
554
            $loader->addFilter('route_options_ident', '', [ 'operator' => 'IS NULL' ]);
555
        }
556
557
        return $loader->load()->first();
558
    }
559
560
    /**
561
     * SETTERS
562
     */
563
564
    /**
565
     * Set the specified URI path.
566
     *
567
     * @param string $path The path to use for route resolution.
568
     * @return self
569
     */
570
    protected function setPath($path)
571
    {
572
        $this->path = $path;
573
574
        return $this;
575
    }
576
577
    /**
578
     * Set an object model factory.
579
     *
580
     * @param FactoryInterface $factory The model factory, to create objects.
581
     * @return self
582
     */
583
    protected function setModelFactory(FactoryInterface $factory)
584
    {
585
        $this->modelFactory = $factory;
586
587
        return $this;
588
    }
589
590
    /**
591
     * Set a model collection loader.
592
     *
593
     * @param CollectionLoader $loader The collection loader.
594
     * @return self
595
     */
596
    public function setCollectionLoader(CollectionLoader $loader)
597
    {
598
        $this->collectionLoader = $loader;
599
600
        return $this;
601
    }
602
603
    /**
604
     * Sets the environment's current locale.
605
     *
606
     * @param  string $langCode The locale's language code.
607
     * @return void
608
     */
609
    protected function setLocale($langCode)
610
    {
611
        $translator = $this->translator();
612
        $translator->setLocale($langCode);
613
614
        $available = $translator->locales();
615
        $fallbacks = $translator->getFallbackLocales();
616
617
        array_unshift($fallbacks, $langCode);
618
        $fallbacks = array_unique($fallbacks);
619
620
        $locales = [];
621
        foreach ($fallbacks as $code) {
622
            if (isset($available[$code])) {
623
                $locale = $available[$code];
624
                if (isset($locale['locales'])) {
625
                    $choices = (array)$locale['locales'];
626
                    array_push($locales, ...$choices);
627
                } elseif (isset($locale['locale'])) {
628
                    array_push($locales, $locale['locale']);
629
                }
630
            }
631
        }
632
633
        $locales = array_unique($locales);
634
635
        if ($locales) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $locales of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
636
            setlocale(LC_ALL, $locales);
637
        }
638
    }
639
640
    /**
641
     * GETTERS
642
     */
643
644
    /**
645
     * Retrieve the URI path.
646
     *
647
     * @return string
648
     */
649
    protected function path()
650
    {
651
        return $this->path;
652
    }
653
654
    /**
655
     * Retrieve the object model factory.
656
     *
657
     * @throws RuntimeException If the model factory was not previously set.
658
     * @return FactoryInterface
659
     */
660 View Code Duplication
    public function modelFactory()
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...
661
    {
662
        if (!isset($this->modelFactory)) {
663
            throw new RuntimeException(
664
                sprintf('Model Factory is not defined for "%s"', get_class($this))
665
            );
666
        }
667
668
        return $this->modelFactory;
669
    }
670
671
    /**
672
     * Retrieve the model collection loader.
673
     *
674
     * @throws RuntimeException If the collection loader was not previously set.
675
     * @return CollectionLoader
676
     */
677 View Code Duplication
    protected function collectionLoader()
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...
678
    {
679
        if (!isset($this->collectionLoader)) {
680
            throw new RuntimeException(
681
                sprintf('Collection Loader is not defined for "%s"', get_class($this))
682
            );
683
        }
684
685
        return $this->collectionLoader;
686
    }
687
688
    /**
689
     * @return boolean
690
     */
691
    protected function cacheEnabled()
692
    {
693
        $obj = $this->getContextObject();
694
        return $obj['cache'] ?: false;
695
    }
696
697
    /**
698
     * @return integer
699
     */
700
    protected function cacheTtl()
701
    {
702
        $obj = $this->getContextObject();
703
        return $obj['cache_ttl'] ?: 0;
704
    }
705
706
    /**
707
     * @return string
708
     */
709
    protected function cacheIdent()
710
    {
711
        $obj = $this->getContextObject();
712
        return $obj->objType().'.'.$obj->id();
713
    }
714
}
715