Passed
Pull Request — master (#1334)
by Dante
01:37
created

SchemaComponent::getSchema()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 32
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 17
c 0
b 0
f 0
nc 8
nop 2
dl 0
loc 32
rs 9.3888
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
        $schema = ApiClientProvider::getApiClient()->schema($type);
142
        if (empty($schema)) {
143
            return false;
144
        }
145
        // add special property `roles` to `users`
146
        if ($type === 'users') {
147
            $schema['properties']['roles'] = [
148
                'type' => 'array',
149
                'items' => [
150
                    'type' => 'string',
151
                    'enum' => $this->fetchRoles(),
152
                ],
153
                'uniqueItems' => true,
154
            ];
155
        }
156
        $categories = $this->fetchCategories($type);
157
        $objectTypeMeta = $this->fetchObjectTypeMeta($type);
158
159
        return $schema + $objectTypeMeta + array_filter(compact('categories'));
160
    }
161
162
    /**
163
     * Fetch `roles` names
164
     *
165
     * @return array
166
     */
167
    protected function fetchRoles(): array
168
    {
169
        $query = [
170
            'fields' => 'name',
171
            'page_size' => 100,
172
        ];
173
        $response = ApiClientProvider::getApiClient()->get('/roles', $query);
174
175
        return (array)Hash::extract((array)$response, 'data.{n}.attributes.name');
176
    }
177
178
    /**
179
     * Check if tags are in use
180
     *
181
     * @return bool
182
     */
183
    public function tagsInUse(): bool
184
    {
185
        $features = $this->objectTypesFeatures();
186
        $tagged = (array)Hash::get($features, 'tagged');
187
188
        return !empty($tagged);
189
    }
190
191
    /**
192
     * Fetch object type metadata
193
     *
194
     * @param string $type Object type.
195
     * @return array
196
     */
197
    protected function fetchObjectTypeMeta(string $type): array
198
    {
199
        $query = [
200
            'fields' => 'associations,relations',
201
        ];
202
        $response = ApiClientProvider::getApiClient()->get(
203
            sprintf('/model/object_types/%s', $type),
204
            $query,
205
        );
206
207
        return [
208
            'associations' => (array)Hash::get((array)$response, 'data.attributes.associations'),
209
            'relations' => array_flip((array)Hash::get((array)$response, 'data.meta.relations')),
210
        ];
211
    }
212
213
    /**
214
     * Fetch `categories`
215
     * This should be called only for types having `"Categories"` association
216
     *
217
     * @param string $type Object type name
218
     * @return array
219
     */
220
    protected function fetchCategories(string $type): array
221
    {
222
        $data = [];
223
        $url = sprintf('/model/categories?filter[type]=%s', $type);
224
        $pageCount = $page = 1;
225
        $pageSize = 100;
226
        $query = ['page_size' => $pageSize];
227
        try {
228
            while ($page <= $pageCount) {
229
                $response = (array)ApiClientProvider::getApiClient()->get($url, $query + compact('page'));
230
                $categories = (array)Hash::get($response, 'data');
231
                $data = array_merge($data, $categories);
232
                $pageCount = (int)Hash::get($response, 'meta.pagination.page_count');
233
                $page++;
234
            }
235
        } catch (BEditaClientException $ex) {
236
            // we ignore filter errors for now
237
            $data = [];
238
        }
239
240
        return array_map(
241
            function ($item) {
242
                return [
243
                    'id' => Hash::get((array)$item, 'id'),
244
                    'name' => Hash::get((array)$item, 'attributes.name'),
245
                    'label' => Hash::get((array)$item, 'attributes.label'),
246
                    'parent_id' => Hash::get((array)$item, 'attributes.parent_id'),
247
                    'enabled' => Hash::get((array)$item, 'attributes.enabled'),
248
                ];
249
            },
250
            $data,
251
        );
252
    }
253
254
    /**
255
     * Load internal schema properties from configuration.
256
     *
257
     * @param string $type Resource type name
258
     * @return array
259
     */
260
    protected function loadInternalSchema(string $type): array
261
    {
262
        Configure::load('schema_properties');
263
        $properties = (array)Configure::read(sprintf('SchemaProperties.%s', $type), []);
264
265
        return compact('properties');
266
    }
267
268
    /**
269
     * Read relations schema from API using internal cache.
270
     *
271
     * @return array Relations schema.
272
     */
273
    public function getRelationsSchema(): array
274
    {
275
        try {
276
            $schema = (array)Cache::remember(
277
                CacheTools::cacheKey('relations'),
278
                function () {
279
                    return $this->fetchRelationData();
280
                },
281
                self::CACHE_CONFIG,
282
            );
283
        } catch (BEditaClientException $e) {
284
            // The exception is being caught _outside_ of `Cache::remember()` to avoid caching the fallback.
285
            $this->log($e->getMessage(), LogLevel::ERROR);
286
            $this->Flash->error($e->getMessage(), ['params' => $e]);
287
            $schema = [];
288
        }
289
290
        return $schema;
291
    }
292
293
    /**
294
     * Fetch relations schema via API.
295
     *
296
     * @return array Relations schema.
297
     */
298
    protected function fetchRelationData(): array
299
    {
300
        $query = [
301
            'include' => 'left_object_types,right_object_types',
302
            'page_size' => 100,
303
        ];
304
        $response = ApiClientProvider::getApiClient()->get('/model/relations', $query);
305
306
        $relations = [];
307
        // retrieve relation right and left object types
308
        $typeNames = Hash::combine((array)$response, 'included.{n}.id', 'included.{n}.attributes.name');
309
        $descendants = (array)Hash::get($this->objectTypesFeatures(), 'descendants');
310
311
        foreach ($response['data'] as $res) {
312
            $left = (array)Hash::extract($res, 'relationships.left_object_types.data.{n}.id');
313
            $types = array_intersect_key($typeNames, array_flip($left));
314
            $left = $this->concreteTypes($types, $descendants);
315
316
            $right = (array)Hash::extract($res, 'relationships.right_object_types.data.{n}.id');
317
            $types = array_intersect_key($typeNames, array_flip($right));
318
            $right = $this->concreteTypes($types, $descendants);
319
320
            unset($res['relationships'], $res['links']);
321
            $relations[$res['attributes']['name']] = $res + compact('left', 'right');
322
            $relations[$res['attributes']['inverse_name']] = $res + [
323
                'left' => $right,
324
                'right' => $left,
325
            ];
326
        }
327
        Configure::load('relations');
328
        $schema = $relations + Configure::read('DefaultRelations');
329
        if (Configure::read('ChildrenParams')) {
330
            $schema['children']['attributes']['params'] = Configure::read('ChildrenParams');
331
        }
332
333
        return $schema;
334
    }
335
336
    /**
337
     * Retrieve concrete types from types list using `descendants` array
338
     *
339
     * @param array $types Object types
340
     * @param array $descendants Descendants array
341
     * @return array
342
     */
343
    protected function concreteTypes(array $types, array $descendants): array
344
    {
345
        $res = [];
346
        foreach ($types as $type) {
347
            if (!empty($descendants[$type])) {
348
                $res = array_merge($res, $descendants[$type]);
349
            } else {
350
                $res[] = $type;
351
            }
352
        }
353
        sort($res);
354
355
        return array_values(array_unique($res));
356
    }
357
358
    /**
359
     * Retrieve concrete type descendants of an object $type if any.
360
     *
361
     * @param string $type Object type name.
362
     * @return array
363
     */
364
    public function descendants(string $type): array
365
    {
366
        $features = $this->objectTypesFeatures();
367
368
        return (array)Hash::get($features, sprintf('descendants.%s', $type));
369
    }
370
371
    /**
372
     * Retrieve list of custom properties for a given object type.
373
     *
374
     * @param string $type Object type name.
375
     * @return array
376
     */
377
    public function customProps(string $type): array
378
    {
379
        return (array)Cache::remember(
380
            CacheTools::cacheKey(sprintf('custom_props_%s', $type)),
381
            function () use ($type) {
382
                return $this->fetchCustomProps($type);
383
            },
384
            self::CACHE_CONFIG,
385
        );
386
    }
387
388
    /**
389
     * Fetch custom properties for a given object type.
390
     *
391
     * @param string $type Object type name.
392
     * @return array
393
     */
394
    protected function fetchCustomProps(string $type): array
395
    {
396
        if ($this->getConfig('internalSchema')) {
397
            return []; // internal resources don't have custom properties
398
        }
399
        $customProperties = [];
400
        $pageCount = $page = 1;
401
        while ($page <= $pageCount) {
402
            $query = [
403
                'fields' => 'name',
404
                'filter' => ['object_type' => $type, 'type' => 'dynamic'],
405
                'page' => $page,
406
                'page_size' => 100,
407
            ];
408
            $response = ApiClientProvider::getApiClient()->get('/model/properties', $query);
409
            $customProperties = array_merge($customProperties, (array)Hash::extract($response, 'data.{n}.attributes.name'));
410
            $pageCount = (int)Hash::get($response, 'meta.pagination.page_count');
411
            $page++;
412
        }
413
        sort($customProperties);
414
415
        return $customProperties;
416
    }
417
418
    /**
419
     * Read object types features from API
420
     *
421
     * @return array
422
     */
423
    public function objectTypesFeatures(): array
424
    {
425
        try {
426
            $features = (array)Cache::remember(
427
                CacheTools::cacheKey('types_features'),
428
                function () {
429
                    return $this->fetchObjectTypesFeatures();
430
                },
431
                self::CACHE_CONFIG,
432
            );
433
        } catch (BEditaClientException $e) {
434
            $this->log($e->getMessage(), LogLevel::ERROR);
435
436
            return [];
437
        }
438
439
        return $features;
440
    }
441
442
    /**
443
     * Fetch object types information via API and manipulate response array.
444
     *
445
     * Resulting array will contain:
446
     *  * `descendants` - associative array having abstract types as keys
447
     *          and all concrete descendant types list as value
448
     *  * `uploadable` - list of concrete types having "Streams" associated,
449
     *          types that can be instantiated via file upload (like images, files)
450
     *  * `categorized` - list of concrete types having "Categories" associated
451
     *
452
     * @return array
453
     */
454
    protected function fetchObjectTypesFeatures(): array
455
    {
456
        $query = [
457
            'page_size' => 100,
458
            'fields' => 'name,is_abstract,associations,parent_name',
459
            'filter' => ['enabled' => true],
460
        ];
461
        $response = (array)ApiClientProvider::getApiClient()->get('/model/object_types', $query);
462
463
        $descendants = (array)Hash::extract($response, 'data.{n}.attributes.parent_name');
464
        $descendants = array_filter(array_unique($descendants));
465
        $types = Hash::combine($response, 'data.{n}.attributes.name', 'data.{n}.attributes');
466
        $descendants = array_fill_keys($descendants, []);
467
        $uploadable = $categorized = $tagged = [];
468
        foreach ($types as $name => $data) {
469
            $abstract = (bool)Hash::get($data, 'is_abstract');
470
            if ($abstract) {
471
                continue;
472
            }
473
            $parent = (string)Hash::get($data, 'parent_name');
474
            $this->setDescendant($name, $parent, $types, $descendants);
475
            if (!(bool)Hash::get($types, $name . '.is_abstract')) {
476
                $assoc = (array)Hash::get($types, $name . '.associations');
477
                if (in_array('Streams', $assoc)) {
478
                    $uploadable[] = $name;
479
                }
480
                if (in_array('Categories', $assoc)) {
481
                    $categorized[] = $name;
482
                }
483
                if (in_array('Tags', $assoc)) {
484
                    $tagged[] = $name;
485
                }
486
            }
487
        }
488
        sort($categorized);
489
        sort($tagged);
490
        sort($uploadable);
491
492
        return compact('descendants', 'uploadable', 'categorized', 'tagged');
493
    }
494
495
    /**
496
     * Set descendant in $descendants array
497
     *
498
     * @param string $name Object type name
499
     * @param string $parent Parent type name
500
     * @param array $types Types array
501
     * @param array $descendants Descendants array
502
     * @return void
503
     */
504
    protected function setDescendant(string $name, string $parent, array &$types, array &$descendants): void
505
    {
506
        $desc = (array)Hash::get($descendants, $parent);
507
        if (empty($parent) || in_array($name, $desc)) {
508
            return;
509
        }
510
        $descendants[$parent][] = $name;
511
        $superParent = (string)Hash::get($types, $parent . '.parent_name');
512
        $this->setDescendant($name, $superParent, $types, $descendants);
513
    }
514
515
    /**
516
     * Get abstract types
517
     *
518
     * @return array
519
     */
520
    public function abstractTypes(): array
521
    {
522
        $features = $this->objectTypesFeatures();
523
        $types = array_keys($features['descendants']);
524
        sort($types);
525
526
        return $types;
527
    }
528
529
    /**
530
     * Get all concrete types, i.e. all descendants of abstract types.
531
     *
532
     * @return array
533
     */
534
    public function allConcreteTypes(): array
535
    {
536
        $features = $this->objectTypesFeatures();
537
        $types = [];
538
        foreach ($features['descendants'] as $descendants) {
539
            if (!empty($descendants)) {
540
                $types = array_unique(array_merge($types, $descendants));
541
            }
542
        }
543
        sort($types);
544
545
        return $types;
546
    }
547
548
    /**
549
     * Clear schema cache
550
     *
551
     * @return void
552
     * @codeCoverageIgnore
553
     */
554
    public function clearCache(): void
555
    {
556
        Cache::clear(static::CACHE_CONFIG);
557
    }
558
}
559