Passed
Pull Request — master (#815)
by Stefano
02:40
created

SchemaComponent::abstractTypes()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
c 0
b 0
f 0
dl 0
loc 7
rs 10
cc 1
nc 1
nop 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 $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
     * Fetch object type metadata
176
     *
177
     * @param string $type Object type.
178
     * @return array
179
     */
180
    protected function fetchObjectTypeMeta(string $type): array
181
    {
182
        $query = [
183
            'fields' => 'associations,relations',
184
        ];
185
        $response = ApiClientProvider::getApiClient()->get(
186
            sprintf('/model/object_types/%s', $type),
187
            $query
188
        );
189
190
        return [
191
            'associations' => (array)Hash::get((array)$response, 'data.attributes.associations'),
192
            'relations' => array_flip((array)Hash::get((array)$response, 'data.meta.relations')),
193
        ];
194
    }
195
196
    /**
197
     * Fetch `categories`
198
     * This should be called only for types having `"Categories"` association
199
     *
200
     * @param string $type Object type name
201
     * @return array
202
     */
203
    protected function fetchCategories(string $type): array
204
    {
205
        $query = [
206
            'page_size' => 100,
207
        ];
208
        $url = sprintf('/model/categories?filter[type]=%s', $type);
209
        try {
210
            $response = ApiClientProvider::getApiClient()->get($url, $query);
211
        } catch (BEditaClientException $ex) {
212
            // we ignore filter errors for now
213
            $response = [];
214
        }
215
216
        return array_map(
217
            function ($item) {
218
                return [
219
                    'id' => Hash::get((array)$item, 'id'),
220
                    'name' => Hash::get((array)$item, 'attributes.name'),
221
                    'label' => Hash::get((array)$item, 'attributes.label'),
222
                    'parent_id' => Hash::get((array)$item, 'attributes.parent_id'),
223
                ];
224
            },
225
            (array)Hash::get((array)$response, 'data')
226
        );
227
    }
228
229
    /**
230
     * Load internal schema properties from configuration.
231
     *
232
     * @param string $type Resource type name
233
     * @return array
234
     */
235
    protected function loadInternalSchema(string $type): array
236
    {
237
        Configure::load('schema_properties');
238
        $properties = (array)Configure::read(sprintf('SchemaProperties.%s', $type), []);
239
240
        return compact('properties');
241
    }
242
243
    /**
244
     * Read relations schema from API using internal cache.
245
     *
246
     * @return array Relations schema.
247
     */
248
    public function getRelationsSchema(): array
249
    {
250
        try {
251
            $schema = (array)Cache::remember(
252
                CacheTools::cacheKey('relations'),
253
                function () {
254
                    return $this->fetchRelationData();
255
                },
256
                self::CACHE_CONFIG
257
            );
258
        } catch (BEditaClientException $e) {
259
            // The exception is being caught _outside_ of `Cache::remember()` to avoid caching the fallback.
260
            $this->log($e->getMessage(), LogLevel::ERROR);
261
            $this->Flash->error($e->getMessage(), ['params' => $e]);
262
            $schema = [];
263
        }
264
265
        return $schema;
266
    }
267
268
    /**
269
     * Fetch relations schema via API.
270
     *
271
     * @return array Relations schema.
272
     */
273
    protected function fetchRelationData(): array
274
    {
275
        $query = [
276
            'include' => 'left_object_types,right_object_types',
277
            'page_size' => 100,
278
        ];
279
        $response = ApiClientProvider::getApiClient()->get('/model/relations', $query);
280
281
        $relations = [];
282
        // retrieve relation right and left object types
283
        $typeNames = Hash::combine((array)$response, 'included.{n}.id', 'included.{n}.attributes.name');
284
        $descendants = (array)Hash::get($this->objectTypesFeatures(), 'descendants');
285
286
        foreach ($response['data'] as $res) {
287
            $left = (array)Hash::extract($res, 'relationships.left_object_types.data.{n}.id');
288
            $types = array_intersect_key($typeNames, array_flip($left));
289
            $left = $this->concreteTypes($types, $descendants);
290
291
            $right = (array)Hash::extract($res, 'relationships.right_object_types.data.{n}.id');
292
            $types = array_intersect_key($typeNames, array_flip($right));
293
            $right = $this->concreteTypes($types, $descendants);
294
295
            unset($res['relationships'], $res['links']);
296
            $relations[$res['attributes']['name']] = $res + compact('left', 'right');
297
            $relations[$res['attributes']['inverse_name']] = $res + [
298
                'left' => $right,
299
                'right' => $left,
300
            ];
301
        }
302
        Configure::load('relations');
303
304
        return $relations + Configure::read('DefaultRelations');
305
    }
306
307
    /**
308
     * Retrieve concrete types from types list using `descendants` array
309
     *
310
     * @param array $types Object types
311
     * @param array $descendants Descendants array
312
     * @return array
313
     */
314
    protected function concreteTypes(array $types, array $descendants): array
315
    {
316
        $res = [];
317
        foreach ($types as $type) {
318
            if (!empty($descendants[$type])) {
319
                $res = array_merge($res, $descendants[$type]);
320
            } else {
321
                $res[] = $type;
322
            }
323
        }
324
        sort($res);
325
326
        return array_values(array_unique($res));
327
    }
328
329
    /**
330
     * Retrieve concrete type descendants of an object $type if any.
331
     *
332
     * @param string $type Object type name.
333
     * @return array
334
     */
335
    public function descendants(string $type): array
336
    {
337
        $features = $this->objectTypesFeatures();
338
339
        return (array)Hash::get($features, sprintf('descendants.%s', $type));
340
    }
341
342
    /**
343
     * Read object types features from API
344
     *
345
     * @return array
346
     */
347
    public function objectTypesFeatures(): array
348
    {
349
        try {
350
            $features = (array)Cache::remember(
351
                CacheTools::cacheKey('types_features'),
352
                function () {
353
                    return $this->fetchObjectTypesFeatures();
354
                },
355
                self::CACHE_CONFIG
356
            );
357
        } catch (BEditaClientException $e) {
358
            $this->log($e->getMessage(), LogLevel::ERROR);
359
360
            return [];
361
        }
362
363
        return $features;
364
    }
365
366
    /**
367
     * Fetch object types information via API and manipulate response array.
368
     *
369
     * Resulting array will contain:
370
     *  * `descendants` - associative array having abstract types as keys
371
     *          and all concrete descendant types list as value
372
     *  * `uploadable` - list of concrete types having "Streams" associated,
373
     *          types that can be instantiated via file upload (like images, files)
374
     *  * `categorized` - list of concrete types having "Categories" associated
375
     *
376
     * @return array
377
     */
378
    protected function fetchObjectTypesFeatures(): array
379
    {
380
        $query = [
381
            'page_size' => 100,
382
            'fields' => 'name,is_abstract,associations,parent_name',
383
            'filter' => ['enabled' => true],
384
        ];
385
        $response = (array)ApiClientProvider::getApiClient()->get('/model/object_types', $query);
386
387
        $descendants = (array)Hash::extract($response, 'data.{n}.attributes.parent_name');
388
        $descendants = array_filter(array_unique($descendants));
389
        $types = Hash::combine($response, 'data.{n}.attributes.name', 'data.{n}.attributes');
390
        $descendants = array_fill_keys($descendants, []);
391
        $uploadable = $categorized = [];
392
        foreach ($types as $name => $data) {
393
            $abstract = (bool)Hash::get($data, 'is_abstract');
394
            if ($abstract) {
395
                continue;
396
            }
397
            $parent = (string)Hash::get($data, 'parent_name');
398
            $this->setDescendant($name, $parent, $types, $descendants);
399
            if (!(bool)Hash::get($types, $name . '.is_abstract')) {
400
                $assoc = (array)Hash::get($types, $name . '.associations');
401
                if (in_array('Streams', $assoc)) {
402
                    $uploadable[] = $name;
403
                }
404
                if (in_array('Categories', $assoc)) {
405
                    $categorized[] = $name;
406
                }
407
            }
408
        }
409
        sort($categorized);
410
        sort($uploadable);
411
412
        return compact('descendants', 'uploadable', 'categorized');
413
    }
414
415
    /**
416
     * Set descendant in $descendants array
417
     *
418
     * @param string $name Object type name
419
     * @param string $parent Parent type name
420
     * @param array $types Types array
421
     * @param array $descendants Descendants array
422
     * @return void
423
     */
424
    protected function setDescendant(string $name, string $parent, array &$types, array &$descendants): void
425
    {
426
        $desc = (array)Hash::get($descendants, $parent);
427
        if (empty($parent) || in_array($name, $desc)) {
428
            return;
429
        }
430
        $descendants[$parent][] = $name;
431
        $superParent = (string)Hash::get($types, $parent . '.parent_name');
432
        $this->setDescendant($name, $superParent, $types, $descendants);
433
    }
434
435
    /**
436
     * Get abstract types
437
     *
438
     * @return array
439
     */
440
    public function abstractTypes(): array
441
    {
442
        $features = $this->objectTypesFeatures();
443
        $types = array_keys($features['descendants']);
444
        sort($types);
445
446
        return $types;
447
    }
448
}
449