Passed
Push — master ( 247e64...08f14c )
by Chauncey
02:53
created

GenericRoute::pathResolvable()   B

Complexity

Conditions 7
Paths 5

Size

Total Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 24
rs 8.6026
c 0
b 0
f 0
cc 7
nc 5
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 \Charcoal\Object\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
     * The class name of the object route model.
83
     *
84
     * Must be a fully-qualified PHP namespace and an implementation of
85
     * {@see \Charcoal\Object\ObjectRouteInterface}. Used by the model factory.
86
     *
87
     * @var string
88
     */
89
    protected $objectRouteClass = ObjectRoute::class;
90
91
    /**
92
     * Store the available templates.
93
     *
94
     * @var array
95
     */
96
    protected $availableTemplates = [];
97
98
    /**
99
     * Returns new template route object.
100
     *
101
     * @param array $data Class depdendencies.
102
     */
103
    public function __construct(array $data)
104
    {
105
        parent::__construct($data);
106
107
        $this->setPath(ltrim($data['path'], '/'));
108
    }
109
110
    /**
111
     * Determine if the URI path resolves to an object.
112
     *
113
     * @param  Container $container A DI (Pimple) container.
114
     * @return boolean
115
     */
116
    public function pathResolvable(Container $container)
117
    {
118
        $this->setDependencies($container);
119
120
        $object = $this->getObjectRouteFromPath();
121
        if (!$object || !$object->id()) {
122
            return false;
123
        }
124
125
        $contextObject = $this->getContextObject();
126
        if (!$contextObject || !$contextObject->id()) {
127
            return false;
128
        }
129
130
        if ($contextObject instanceof RoutableInterface) {
131
            return $contextObject->isActiveRoute();
132
        }
133
134
        if (isset($contextObject['active'])) {
135
            return (bool)$contextObject['active'];
136
        }
137
138
        return true;
139
    }
140
141
    /**
142
     * Resolve the dynamic route.
143
     *
144
     * @param  Container         $container A DI (Pimple) container.
145
     * @param  RequestInterface  $request   A PSR-7 compatible Request instance.
146
     * @param  ResponseInterface $response  A PSR-7 compatible Response instance.
147
     * @return ResponseInterface
148
     */
149
    public function __invoke(
150
        Container $container,
151
        RequestInterface $request,
152
        ResponseInterface $response
153
    ) {
154
        $response = $this->resolveLatestObjectRoute($request, $response);
155
156
        if (!$response->isRedirect()) {
157
            $this->resolveTemplateContextObject();
158
159
            $templateContent = $this->templateContent($container, $request);
160
161
            $response->write($templateContent);
162
        }
163
164
        return $response;
165
    }
166
167
    /**
168
     * Create a route object.
169
     *
170
     * @return ObjectRouteInterface
171
     */
172
    public function createRouteObject()
173
    {
174
        $route = $this->modelFactory()->create($this->objectRouteClass());
175
176
        return $route;
177
    }
178
179
    /**
180
     * Retrieve the class name of the object route model.
181
     *
182
     * @return string
183
     */
184
    public function objectRouteClass()
185
    {
186
        return $this->objectRouteClass;
187
    }
188
189
    /**
190
     * Inject dependencies from a DI Container.
191
     *
192
     * @param  Container $container A dependencies container instance.
193
     * @return void
194
     */
195
    protected function setDependencies(Container $container)
196
    {
197
        $this->setTranslator($container['translator']);
198
        $this->setModelFactory($container['model/factory']);
199
        $this->setCollectionLoader($container['model/collection/loader']);
200
201
        if (isset($container['config']['templates'])) {
202
            $this->availableTemplates = $container['config']['templates'];
203
        }
204
    }
205
206
    /**
207
     * Determine if the current object route is the latest object route.
208
     *
209
     * This method loads the latest object route from the datasource and compares
210
     * their creation dates. Both instances could be the same object (ID).
211
     *
212
     * @param  RequestInterface  $request  A PSR-7 compatible Request instance.
213
     * @param  ResponseInterface $response A PSR-7 compatible Response instance.
214
     * @return ResponseInterface
215
     */
216
    protected function resolveLatestObjectRoute(
217
        RequestInterface $request,
218
        ResponseInterface $response
219
    ) {
220
        $current = $this->getObjectRouteFromPath();
221
        $latest  = $this->getLatestObjectPathHistory($current);
222
223
        // Redirect if latest route is newer
224
        if ($latest && $latest->getCreationDate() > $current->getCreationDate()) {
225
            $redirect = $this->parseRedirect($latest->getSlug(), $request);
226
            $response = $response->withRedirect($redirect, 301);
227
        }
228
229
        return $response;
230
    }
231
232
    /**
233
     * @return self
234
     */
235
    protected function resolveTemplateContextObject()
236
    {
237
        $config = $this->config();
238
239
        $objectRoute   = $this->getObjectRouteFromPath();
240
        $contextObject = $this->getContextObject();
241
        $currentLang   = $objectRoute->getLang();
242
243
        // Set language according to the route's language
244
        $this->setLocale($currentLang);
245
246
        $templateChoice = [];
247
248
        // Templateable Objects have specific methods
249
        if ($contextObject instanceof TemplateableInterface) {
250
            $identProperty   = $contextObject->property('templateIdent');
251
            $templateIdent   = $contextObject['templateIdent'] ?: $objectRoute->getRouteTemplate();
252
            $controllerIdent = $contextObject['controllerIdent'] ?: $templateIdent;
253
            $templateChoice  = $identProperty->choice($templateIdent);
254
        } else {
255
            // Use global templates to verify for custom paths
256
            $templateIdent   = $objectRoute->getRouteTemplate();
257
            $controllerIdent = $templateIdent;
258
            foreach ($this->availableTemplates as $templateKey => $templateData) {
259
                if (!isset($templateData['value'])) {
260
                    $templateData['value'] = $templateKey;
261
                }
262
263
                if ($templateData['value'] === $templateIdent) {
264
                    $templateChoice = $templateData;
265
                    break;
266
                }
267
            }
268
        }
269
270
        // Template ident defined in template global config
271
        // Check for custom path / controller
272
        if (isset($templateChoice['template'])) {
273
            $templatePath = $templateChoice['template'];
274
            $templateController = $templateChoice['template'];
275
        } else {
276
            $templatePath = $templateIdent;
277
            $templateController = $controllerIdent;
278
        }
279
280
        // Template controller defined in choices, affect it.
281
        if (isset($templateChoice['controller'])) {
282
            $templateController = $templateChoice['controller'];
283
        }
284
285
        $config['template'] = $templatePath;
286
        $config['controller'] = $templateController;
287
288
        // Always be an array
289
        $templateOptions = [];
290
291
        // Custom template options
292
        if (isset($templateChoice['template_options'])) {
293
            $templateOptions = $templateChoice['template_options'];
294
        }
295
296
        // Overwrite from custom object template_options
297
        if ($contextObject instanceof TemplateableInterface) {
298
            if (!empty($contextObject['templateOptions'])) {
299
                $templateOptions = $contextObject['templateOptions'];
300
            }
301
        }
302
303
        if (isset($templateOptions) && $templateOptions) {
304
            // Not sure what this was about?
305
            $config['template_data'] = array_merge($config['template_data'], $templateOptions);
306
        }
307
308
        // Merge Route options from object-route
309
        $routeOptions = $objectRoute->getRouteOptions();
310
        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...
311
            $config['template_data'] = array_merge($config['template_data'], $routeOptions);
312
        }
313
314
        $this->setConfig($config);
315
316
        return $this;
317
    }
318
319
    /**
320
     * @param  Container        $container A DI (Pimple) container.
321
     * @param  RequestInterface $request   The request to intialize the template with.
322
     * @return string
323
     */
324
    protected function createTemplate(Container $container, RequestInterface $request)
325
    {
326
        $template = parent::createTemplate($container, $request);
327
328
        $contextObject = $this->getContextObject();
329
        $template['contextObject'] = $contextObject;
330
331
        return $template;
332
    }
333
334
    /**
335
     * Set the class name of the object route model.
336
     *
337
     * @param  string $className The class name of the object route model.
338
     * @throws InvalidArgumentException If the class name is not a string.
339
     * @return self
340
     */
341
    protected function setObjectRouteClass($className)
342
    {
343
        if (!is_string($className)) {
344
            throw new InvalidArgumentException(
345
                'Route class name must be a string.'
346
            );
347
        }
348
349
        $this->objectRouteClass = $className;
350
351
        return $this;
352
    }
353
354
    /**
355
     * Get the object associated with the matching object route.
356
     *
357
     * @return RoutableInterface
358
     */
359
    protected function getContextObject()
360
    {
361
        if ($this->contextObject === null) {
362
            $this->contextObject = $this->loadContextObject();
363
        }
364
365
        return $this->contextObject;
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->contextObject; of type Charcoal\Model\ModelInte...bject\RoutableInterface adds the type Charcoal\Model\ModelInterface to the return on line 365 which is incompatible with the return type documented by Charcoal\Cms\Route\GenericRoute::getContextObject of type Charcoal\Object\RoutableInterface.
Loading history...
366
    }
367
368
    /**
369
     * Load the object associated with the matching object route.
370
     *
371
     * Validating if the object ID exists is delegated to the
372
     * {@see GenericRoute Chainable::pathResolvable()} method.
373
     *
374
     * @return RoutableInterface
375
     */
376
    protected function loadContextObject()
377
    {
378
        $route = $this->getObjectRouteFromPath();
379
380
        $obj = $this->modelFactory()->create($route->getRouteObjType());
381
        $obj->load($route->getRouteObjId());
382
383
        return $obj;
384
    }
385
386
    /**
387
     * Get the object route matching the URI path.
388
     *
389
     * @return \Charcoal\Object\ObjectRouteInterface
390
     */
391
    protected function getObjectRouteFromPath()
392
    {
393
        if ($this->objectRoute === null) {
394
            $this->objectRoute = $this->loadObjectRouteFromPath();
395
        }
396
397
        return $this->objectRoute;
398
    }
399
400
    /**
401
     * Load the object route matching the URI path.
402
     *
403
     * @return \Charcoal\Object\ObjectRouteInterface
404
     */
405
    protected function loadObjectRouteFromPath()
406
    {
407
        // Load current slug
408
        // Slugs are unique
409
        // Slug can be duplicated by adding the front "/" to it hence the order by last_modification_date
410
        $route = $this->createRouteObject();
411
        $table = '`'.$route->source()->table().'`';
412
        $where = '`lang` = :lang AND (`slug` = :route1 OR `slug` = :route2)';
413
        $order = '`last_modification_date` DESC';
414
        $query = 'SELECT * FROM '.$table.' WHERE '.$where.' ORDER BY '.$order.' LIMIT 1';
415
416
        $route->loadFromQuery($query, [
417
            'route1' => '/'.$this->path(),
418
            'route2' => $this->path(),
419
            'lang'   => $this->translator()->getLocale(),
420
        ]);
421
422
        return $route;
423
    }
424
425
    /**
426
     * Retrieve the latest object route from the given object route's
427
     * associated object.
428
     *
429
     * The object routes are ordered by descending creation date (latest first).
430
     * Should never MISS, the given object route should exist.
431
     *
432
     * @param  ObjectRouteInterface $route Routable Object.
433
     * @return ObjectRouteInterface|null
434
     */
435
    public function getLatestObjectPathHistory(ObjectRouteInterface $route)
436
    {
437
        if (!$route->id()) {
438
            return null;
439
        }
440
441
        $loader = $this->collectionLoader();
442
        $loader
443
            ->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...
444
            ->addFilter('active', true)
445
            ->addFilter('route_obj_type', $route->getRouteObjType())
446
            ->addFilter('route_obj_id', $route->getRouteObjId())
447
            ->addFilter('lang', $route->getLang())
448
            ->addOrder('creation_date', 'desc')
449
            ->setPage(1)
450
            ->setNumPerPage(1);
451
452
        if ($route->getRouteOptionsIdent()) {
453
            $loader->addFilter('route_options_ident', $route->getRouteOptionsIdent());
454
        } else {
455
            $loader->addFilter('route_options_ident', '', [ 'operator' => 'IS NULL' ]);
456
        }
457
458
        return $loader->load()->first();
459
    }
460
461
    /**
462
     * SETTERS
463
     */
464
465
    /**
466
     * Set the specified URI path.
467
     *
468
     * @param string $path The path to use for route resolution.
469
     * @return self
470
     */
471
    protected function setPath($path)
472
    {
473
        $this->path = $path;
474
475
        return $this;
476
    }
477
478
    /**
479
     * Set an object model factory.
480
     *
481
     * @param FactoryInterface $factory The model factory, to create objects.
482
     * @return self
483
     */
484
    protected function setModelFactory(FactoryInterface $factory)
485
    {
486
        $this->modelFactory = $factory;
487
488
        return $this;
489
    }
490
491
    /**
492
     * Set a model collection loader.
493
     *
494
     * @param CollectionLoader $loader The collection loader.
495
     * @return self
496
     */
497
    public function setCollectionLoader(CollectionLoader $loader)
498
    {
499
        $this->collectionLoader = $loader;
500
501
        return $this;
502
    }
503
504
    /**
505
     * Sets the environment's current locale.
506
     *
507
     * @param  string $langCode The locale's language code.
508
     * @return void
509
     */
510
    protected function setLocale($langCode)
511
    {
512
        $translator = $this->translator();
513
        $translator->setLocale($langCode);
514
515
        $available = $translator->locales();
516
        $fallbacks = $translator->getFallbackLocales();
517
518
        array_unshift($fallbacks, $langCode);
519
        $fallbacks = array_unique($fallbacks);
520
521
        $locales = [];
522
        foreach ($fallbacks as $code) {
523
            if (isset($available[$code])) {
524
                $locale = $available[$code];
525
                if (isset($locale['locales'])) {
526
                    $choices = (array)$locale['locales'];
527
                    array_push($locales, ...$choices);
528
                } elseif (isset($locale['locale'])) {
529
                    array_push($locales, $locale['locale']);
530
                }
531
            }
532
        }
533
534
        $locales = array_unique($locales);
535
536
        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...
537
            setlocale(LC_ALL, $locales);
538
        }
539
    }
540
541
    /**
542
     * GETTERS
543
     */
544
545
    /**
546
     * Retrieve the URI path.
547
     *
548
     * @return string
549
     */
550
    protected function path()
551
    {
552
        return $this->path;
553
    }
554
555
    /**
556
     * Retrieve the object model factory.
557
     *
558
     * @throws RuntimeException If the model factory was not previously set.
559
     * @return FactoryInterface
560
     */
561 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...
562
    {
563
        if (!isset($this->modelFactory)) {
564
            throw new RuntimeException(
565
                sprintf('Model Factory is not defined for "%s"', get_class($this))
566
            );
567
        }
568
569
        return $this->modelFactory;
570
    }
571
572
    /**
573
     * Retrieve the model collection loader.
574
     *
575
     * @throws RuntimeException If the collection loader was not previously set.
576
     * @return CollectionLoader
577
     */
578 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...
579
    {
580
        if (!isset($this->collectionLoader)) {
581
            throw new RuntimeException(
582
                sprintf('Collection Loader is not defined for "%s"', get_class($this))
583
            );
584
        }
585
586
        return $this->collectionLoader;
587
    }
588
589
    /**
590
     * @return boolean
591
     */
592
    protected function cacheEnabled()
593
    {
594
        $obj = $this->getContextObject();
595
        return $obj['cache'] ?: false;
596
    }
597
598
    /**
599
     * @return integer
600
     */
601
    protected function cacheTtl()
602
    {
603
        $obj = $this->getContextObject();
604
        return $obj['cache_ttl'] ?: 0;
605
    }
606
607
    /**
608
     * @return string
609
     */
610
    protected function cacheIdent()
611
    {
612
        $obj = $this->getContextObject();
613
        return $obj->objType().'.'.$obj->id();
614
    }
615
}
616