Passed
Push — master ( 74afda...384b66 )
by Dante
10:19 queued 08:13
created

SchemaComponent::fetchRelationData()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 36
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 24
nc 4
nop 0
dl 0
loc 36
rs 9.536
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2020 ChannelWeb Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
namespace App\Controller\Component;
14
15
use App\Utility\CacheTools;
16
use BEdita\SDK\BEditaClientException;
17
use BEdita\WebTools\ApiClientProvider;
18
use Cake\Cache\Cache;
19
use Cake\Controller\Component;
20
use Cake\Core\Configure;
21
use Cake\Utility\Hash;
22
use Psr\Log\LogLevel;
23
24
/**
25
 * Handles model schema of objects and resources.
26
 *
27
 * @property \App\Controller\Component\FlashComponent $Flash
28
 */
29
class SchemaComponent extends Component
30
{
31
    /**
32
     * @inheritDoc
33
     */
34
    public array $components = ['Flash'];
35
36
    /**
37
     * Cache config name for type schemas.
38
     *
39
     * @var string
40
     */
41
    public const CACHE_CONFIG = '_schema_types_';
42
43
    /**
44
     * @inheritDoc
45
     */
46
    protected array $_defaultConfig = [
47
        'type' => null, // resource or object type name
48
        'internalSchema' => false, // use internal schema
49
    ];
50
51
    /**
52
     * Read type JSON Schema from API using internal cache.
53
     *
54
     * @param string|null $type Type to get schema for. By default, configured type is used.
55
     * @param string|null $revision Schema revision.
56
     * @return array|bool JSON Schema.
57
     */
58
    public function getSchema(?string $type = null, ?string $revision = null): array|bool
59
    {
60
        if ($type === null) {
61
            $type = $this->getConfig('type');
62
        }
63
64
        if ($this->getConfig('internalSchema')) {
65
            return $this->loadInternalSchema($type);
66
        }
67
68
        $schema = $this->loadWithRevision($type, $revision);
69
        if (!empty($schema)) {
70
            return $schema;
71
        }
72
73
        try {
74
            $schema = Cache::remember(
75
                CacheTools::cacheKey($type),
76
                function () use ($type) {
77
                    return $this->fetchSchema($type);
78
                },
79
                self::CACHE_CONFIG,
80
            );
81
        } catch (BEditaClientException $e) {
82
            // Something bad happened. Booleans **ARE** valid JSON Schemas: returning `false` instead.
83
            // The exception is being caught _outside_ of `Cache::remember()` to avoid caching the fallback.
84
            $this->log($e->getMessage(), LogLevel::ERROR);
85
86
            return false;
87
        }
88
89
        return $schema;
90
    }
91
92
    /**
93
     * Get schemas by types and return them group by type
94
     *
95
     * @param array $types The types
96
     * @return array
97
     */
98
    public function getSchemasByType(array $types): array
99
    {
100
        $schemas = [];
101
        foreach ($types as $type) {
102
            $schemas[$type] = $this->getSchema($type);
103
        }
104
105
        return $schemas;
106
    }
107
108
    /**
109
     * Load schema from cache with revision check.
110
     * If cached revision don't match cache is removed.
111
     *
112
     * @param string $type Type to get schema for. By default, configured type is used.
113
     * @param string|null $revision Schema revision.
114
     * @return array|null Cached schema if revision match, null otherwise
115
     */
116
    protected function loadWithRevision(string $type, ?string $revision = null): ?array
117
    {
118
        $key = CacheTools::cacheKey($type);
119
        $schema = Cache::read($key, self::CACHE_CONFIG);
120
        if (empty($schema)) {
121
            return null;
122
        }
123
        $cacheRevision = empty($schema['revision']) ? null : $schema['revision'];
124
        if ($revision === null || $cacheRevision === $revision) {
125
            return $schema;
126
        }
127
        // remove from cache if revision don't match
128
        Cache::delete($key, self::CACHE_CONFIG);
129
130
        return null;
131
    }
132
133
    /**
134
     * Fetch JSON Schema via API.
135
     *
136
     * @param string $type Type to get schema for.
137
     * @return array|bool JSON Schema.
138
     */
139
    protected function fetchSchema(string $type): array|bool
140
    {
141
        if (in_array($type, ['translations', 'trash'])) {
142
            return false;
143
        }
144
        $schema = ApiClientProvider::getApiClient()->schema($type);
145
        if (empty($schema)) {
146
            return false;
147
        }
148
        // add special property `roles` to `users`
149
        if ($type === 'users') {
150
            $schema['properties']['roles'] = [
151
                'type' => 'array',
152
                'items' => [
153
                    'type' => 'string',
154
                    'enum' => $this->fetchRoles(),
155
                ],
156
                'uniqueItems' => true,
157
            ];
158
        }
159
        $categories = $this->fetchCategories($type);
160
        $objectTypeMeta = $this->fetchObjectTypeMeta($type);
161
162
        return $schema + $objectTypeMeta + array_filter(compact('categories'));
163
    }
164
165
    /**
166
     * Fetch `roles` names
167
     *
168
     * @return array
169
     */
170
    protected function fetchRoles(): array
171
    {
172
        $query = [
173
            'fields' => 'name',
174
            'page_size' => 100,
175
        ];
176
        $response = ApiClientProvider::getApiClient()->get('/roles', $query);
177
178
        return (array)Hash::extract((array)$response, 'data.{n}.attributes.name');
179
    }
180
181
    /**
182
     * Check if tags are in use
183
     *
184
     * @return bool
185
     */
186
    public function tagsInUse(): bool
187
    {
188
        $features = $this->objectTypesFeatures();
189
        $tagged = (array)Hash::get($features, 'tagged');
190
191
        return !empty($tagged);
192
    }
193
194
    /**
195
     * Fetch object type metadata
196
     *
197
     * @param string $type Object type.
198
     * @return array
199
     */
200
    protected function fetchObjectTypeMeta(string $type): array
201
    {
202
        $query = [
203
            'fields' => 'associations,relations',
204
        ];
205
        $response = ApiClientProvider::getApiClient()->get(
206
            sprintf('/model/object_types/%s', $type),
207
            $query,
208
        );
209
210
        return [
211
            'associations' => (array)Hash::get((array)$response, 'data.attributes.associations'),
212
            'relations' => array_flip((array)Hash::get((array)$response, 'data.meta.relations')),
213
        ];
214
    }
215
216
    /**
217
     * Fetch `categories`
218
     * This should be called only for types having `"Categories"` association
219
     *
220
     * @param string $type Object type name
221
     * @return array
222
     */
223
    protected function fetchCategories(string $type): array
224
    {
225
        $data = [];
226
        $url = sprintf('/model/categories?filter[type]=%s', $type);
227
        $pageCount = $page = 1;
228
        $pageSize = 100;
229
        $query = ['page_size' => $pageSize];
230
        try {
231
            while ($page <= $pageCount) {
232
                $response = (array)ApiClientProvider::getApiClient()->get($url, $query + compact('page'));
233
                $categories = (array)Hash::get($response, 'data');
234
                $data = array_merge($data, $categories);
235
                $pageCount = (int)Hash::get($response, 'meta.pagination.page_count');
236
                $page++;
237
            }
238
        } catch (BEditaClientException $ex) {
239
            // we ignore filter errors for now
240
            $data = [];
241
        }
242
243
        return array_map(
244
            function ($item) {
245
                return [
246
                    'id' => Hash::get((array)$item, 'id'),
247
                    'name' => Hash::get((array)$item, 'attributes.name'),
248
                    'label' => Hash::get((array)$item, 'attributes.label'),
249
                    'parent_id' => Hash::get((array)$item, 'attributes.parent_id'),
250
                    'enabled' => Hash::get((array)$item, 'attributes.enabled'),
251
                ];
252
            },
253
            $data,
254
        );
255
    }
256
257
    /**
258
     * Load internal schema properties from configuration.
259
     *
260
     * @param string $type Resource type name
261
     * @return array
262
     */
263
    protected function loadInternalSchema(string $type): array
264
    {
265
        Configure::load('schema_properties');
266
        $properties = (array)Configure::read(sprintf('SchemaProperties.%s', $type), []);
267
268
        return compact('properties');
269
    }
270
271
    /**
272
     * Read relations schema from API using internal cache.
273
     *
274
     * @return array Relations schema.
275
     */
276
    public function getRelationsSchema(): array
277
    {
278
        try {
279
            $schema = (array)Cache::remember(
280
                CacheTools::cacheKey('relations'),
281
                function () {
282
                    return $this->fetchRelationData();
283
                },
284
                self::CACHE_CONFIG,
285
            );
286
        } catch (BEditaClientException $e) {
287
            // The exception is being caught _outside_ of `Cache::remember()` to avoid caching the fallback.
288
            $this->log($e->getMessage(), LogLevel::ERROR);
289
            $this->Flash->error($e->getMessage(), ['params' => $e]);
290
            $schema = [];
291
        }
292
293
        return $schema;
294
    }
295
296
    /**
297
     * Fetch relations schema via API.
298
     *
299
     * @return array Relations schema.
300
     */
301
    protected function fetchRelationData(): array
302
    {
303
        $query = [
304
            'include' => 'left_object_types,right_object_types',
305
            'page_size' => 100,
306
        ];
307
        $response = ApiClientProvider::getApiClient()->get('/model/relations', $query);
308
309
        $relations = [];
310
        // retrieve relation right and left object types
311
        $typeNames = Hash::combine((array)$response, 'included.{n}.id', 'included.{n}.attributes.name');
312
        $descendants = (array)Hash::get($this->objectTypesFeatures(), 'descendants');
313
314
        foreach ($response['data'] as $res) {
315
            $left = (array)Hash::extract($res, 'relationships.left_object_types.data.{n}.id');
316
            $types = array_intersect_key($typeNames, array_flip($left));
317
            $left = $this->concreteTypes($types, $descendants);
318
319
            $right = (array)Hash::extract($res, 'relationships.right_object_types.data.{n}.id');
320
            $types = array_intersect_key($typeNames, array_flip($right));
321
            $right = $this->concreteTypes($types, $descendants);
322
323
            unset($res['relationships'], $res['links']);
324
            $relations[$res['attributes']['name']] = $res + compact('left', 'right');
325
            $relations[$res['attributes']['inverse_name']] = $res + [
326
                'left' => $right,
327
                'right' => $left,
328
            ];
329
        }
330
        Configure::load('relations');
331
        $schema = $relations + Configure::read('DefaultRelations');
332
        if (Configure::read('ChildrenParams')) {
333
            $schema['children']['attributes']['params'] = Configure::read('ChildrenParams');
334
        }
335
336
        return $schema;
337
    }
338
339
    /**
340
     * Retrieve concrete types from types list using `descendants` array
341
     *
342
     * @param array $types Object types
343
     * @param array $descendants Descendants array
344
     * @return array
345
     */
346
    protected function concreteTypes(array $types, array $descendants): array
347
    {
348
        $res = [];
349
        foreach ($types as $type) {
350
            if (!empty($descendants[$type])) {
351
                $res = array_merge($res, $descendants[$type]);
352
            } else {
353
                $res[] = $type;
354
            }
355
        }
356
        sort($res);
357
358
        return array_values(array_unique($res));
359
    }
360
361
    /**
362
     * Retrieve concrete type descendants of an object $type if any.
363
     *
364
     * @param string $type Object type name.
365
     * @return array
366
     */
367
    public function descendants(string $type): array
368
    {
369
        $features = $this->objectTypesFeatures();
370
371
        return (array)Hash::get($features, sprintf('descendants.%s', $type));
372
    }
373
374
    /**
375
     * Retrieve list of custom properties for a given object type.
376
     *
377
     * @param string $type Object type name.
378
     * @return array
379
     */
380
    public function customProps(string $type): array
381
    {
382
        return (array)Cache::remember(
383
            CacheTools::cacheKey(sprintf('custom_props_%s', $type)),
384
            function () use ($type) {
385
                return $this->fetchCustomProps($type);
386
            },
387
            self::CACHE_CONFIG,
388
        );
389
    }
390
391
    /**
392
     * Fetch custom properties for a given object type.
393
     *
394
     * @param string $type Object type name.
395
     * @return array
396
     */
397
    protected function fetchCustomProps(string $type): array
398
    {
399
        if ($this->getConfig('internalSchema')) {
400
            return []; // internal resources don't have custom properties
401
        }
402
        $customProperties = [];
403
        $pageCount = $page = 1;
404
        while ($page <= $pageCount) {
405
            $query = [
406
                'fields' => 'name',
407
                'filter' => ['object_type' => $type, 'type' => 'dynamic'],
408
                'page' => $page,
409
                'page_size' => 100,
410
            ];
411
            $response = ApiClientProvider::getApiClient()->get('/model/properties', $query);
412
            $customProperties = array_merge($customProperties, (array)Hash::extract($response, 'data.{n}.attributes.name'));
413
            $pageCount = (int)Hash::get($response, 'meta.pagination.page_count');
414
            $page++;
415
        }
416
        sort($customProperties);
417
418
        return $customProperties;
419
    }
420
421
    /**
422
     * Read object types features from API
423
     *
424
     * @return array
425
     */
426
    public function objectTypesFeatures(): array
427
    {
428
        try {
429
            $features = (array)Cache::remember(
430
                CacheTools::cacheKey('types_features'),
431
                function () {
432
                    return $this->fetchObjectTypesFeatures();
433
                },
434
                self::CACHE_CONFIG,
435
            );
436
        } catch (BEditaClientException $e) {
437
            $this->log($e->getMessage(), LogLevel::ERROR);
438
439
            return [];
440
        }
441
442
        return $features;
443
    }
444
445
    /**
446
     * Fetch object types information via API and manipulate response array.
447
     *
448
     * Resulting array will contain:
449
     *  * `descendants` - associative array having abstract types as keys
450
     *          and all concrete descendant types list as value
451
     *  * `uploadable` - list of concrete types having "Streams" associated,
452
     *          types that can be instantiated via file upload (like images, files)
453
     *  * `categorized` - list of concrete types having "Categories" associated
454
     *
455
     * @return array
456
     */
457
    protected function fetchObjectTypesFeatures(): array
458
    {
459
        $query = [
460
            'page_size' => 100,
461
            'fields' => 'name,is_abstract,associations,parent_name',
462
            'filter' => ['enabled' => true],
463
        ];
464
        $response = (array)ApiClientProvider::getApiClient()->get('/model/object_types', $query);
465
466
        $descendants = (array)Hash::extract($response, 'data.{n}.attributes.parent_name');
467
        $descendants = array_filter(array_unique($descendants));
468
        $types = Hash::combine($response, 'data.{n}.attributes.name', 'data.{n}.attributes');
469
        $descendants = array_fill_keys($descendants, []);
470
        $uploadable = $categorized = $tagged = [];
471
        foreach ($types as $name => $data) {
472
            $abstract = (bool)Hash::get($data, 'is_abstract');
473
            if ($abstract) {
474
                continue;
475
            }
476
            $parent = (string)Hash::get($data, 'parent_name');
477
            $this->setDescendant($name, $parent, $types, $descendants);
478
            if (!(bool)Hash::get($types, $name . '.is_abstract')) {
479
                $assoc = (array)Hash::get($types, $name . '.associations');
480
                if (in_array('Streams', $assoc)) {
481
                    $uploadable[] = $name;
482
                }
483
                if (in_array('Categories', $assoc)) {
484
                    $categorized[] = $name;
485
                }
486
                if (in_array('Tags', $assoc)) {
487
                    $tagged[] = $name;
488
                }
489
            }
490
        }
491
        sort($categorized);
492
        sort($tagged);
493
        sort($uploadable);
494
495
        return compact('descendants', 'uploadable', 'categorized', 'tagged');
496
    }
497
498
    /**
499
     * Set descendant in $descendants array
500
     *
501
     * @param string $name Object type name
502
     * @param string $parent Parent type name
503
     * @param array $types Types array
504
     * @param array $descendants Descendants array
505
     * @return void
506
     */
507
    protected function setDescendant(string $name, string $parent, array &$types, array &$descendants): void
508
    {
509
        $desc = (array)Hash::get($descendants, $parent);
510
        if (empty($parent) || in_array($name, $desc)) {
511
            return;
512
        }
513
        $descendants[$parent][] = $name;
514
        $superParent = (string)Hash::get($types, $parent . '.parent_name');
515
        $this->setDescendant($name, $superParent, $types, $descendants);
516
    }
517
518
    /**
519
     * Get abstract types
520
     *
521
     * @return array
522
     */
523
    public function abstractTypes(): array
524
    {
525
        $features = $this->objectTypesFeatures();
526
        $types = array_keys($features['descendants']);
527
        sort($types);
528
529
        return $types;
530
    }
531
532
    /**
533
     * Get all concrete types, i.e. all descendants of abstract types.
534
     *
535
     * @return array
536
     */
537
    public function allConcreteTypes(): array
538
    {
539
        $features = $this->objectTypesFeatures();
540
        $types = [];
541
        foreach ($features['descendants'] as $descendants) {
542
            if (!empty($descendants)) {
543
                $types = array_unique(array_merge($types, $descendants));
544
            }
545
        }
546
        sort($types);
547
548
        return $types;
549
    }
550
551
    /**
552
     * Clear schema cache
553
     *
554
     * @return void
555
     * @codeCoverageIgnore
556
     */
557
    public function clearCache(): void
558
    {
559
        Cache::clear(static::CACHE_CONFIG);
560
    }
561
}
562