SchemaComponent::getSchema()   A
last analyzed

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