Passed
Pull Request — master (#528)
by Stefano
03:37
created

SchemaComponent::fetchObjectTypesFeatures()   B

Complexity

Conditions 6
Paths 7

Size

Total Lines 35
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 6
eloc 25
nc 7
nop 0
dl 0
loc 35
rs 8.8977
c 2
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\CacheTrait;
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 \Cake\Controller\Component\FlashComponent $Flash
28
 */
29
class SchemaComponent extends Component
30
{
31
    use CacheTrait;
32
33
    /**
34
     * {@inheritDoc}
35
     */
36
    public $components = ['Flash'];
37
38
    /**
39
     * Cache config name for type schemas.
40
     *
41
     * @var string
42
     */
43
    const CACHE_CONFIG = '_schema_types_';
44
45
    /**
46
     * {@inheritDoc}
47
     */
48
    protected $_defaultConfig = [
49
        'type' => null, // resource or object type name
50
        'internalSchema' => false, // use internal schema
51
    ];
52
53
    /**
54
     * Read type JSON Schema from API using internal cache.
55
     *
56
     * @param string|null $type Type to get schema for. By default, configured type is used.
57
     * @param string|null $revision Schema revision.
58
     * @return array|bool JSON Schema.
59
     */
60
    public function getSchema(string $type = null, string $revision = null)
61
    {
62
        if ($type === null) {
63
            $type = $this->getConfig('type');
64
        }
65
66
        if ($this->getConfig('internalSchema')) {
67
            return $this->loadInternalSchema($type);
68
        }
69
70
        $schema = $this->loadWithRevision($type, $revision);
71
        if ($schema !== false) {
72
            return $schema;
73
        }
74
75
        try {
76
            $schema = Cache::remember(
77
                $this->cacheKey($type),
78
                function () use ($type) {
79
                    return $this->fetchSchema($type);
80
                },
81
                self::CACHE_CONFIG
82
            );
83
        } catch (BEditaClientException $e) {
84
            // Something bad happened. Booleans **ARE** valid JSON Schemas: returning `false` instead.
85
            // The exception is being caught _outside_ of `Cache::remember()` to avoid caching the fallback.
86
            $this->log($e, LogLevel::ERROR);
87
88
            return false;
89
        }
90
91
        return $schema;
92
    }
93
94
    /**
95
     * Load schema from cache with revision check.
96
     * If cached revision don't match cache is removed.
97
     *
98
     * @param string $type Type to get schema for. By default, configured type is used.
99
     * @param string $revision Schema revision.
100
     * @return array|bool Cached schema if revision match, otherwise false
101
     */
102
    protected function loadWithRevision(string $type, string $revision = null)
103
    {
104
        $key = $this->cacheKey($type);
105
        $schema = Cache::read($key, self::CACHE_CONFIG);
106
        if ($schema === false) {
107
            return false;
108
        }
109
        $cacheRevision = empty($schema['revision']) ? null : $schema['revision'];
110
        if ($revision === null || $cacheRevision === $revision) {
111
            return $schema;
112
        }
113
        // remove from cache if revision don't match
114
        Cache::delete($key, self::CACHE_CONFIG);
115
116
        return false;
117
    }
118
119
    /**
120
     * Fetch JSON Schema via API.
121
     *
122
     * @param string $type Type to get schema for.
123
     * @return array|bool JSON Schema.
124
     */
125
    protected function fetchSchema(string $type)
126
    {
127
        $schema = ApiClientProvider::getApiClient()->schema($type);
128
        if (empty($schema)) {
129
            return false;
130
        }
131
        // add special property `roles` to `users`
132
        if ($type === 'users') {
133
            $schema['properties']['roles'] = [
134
                'type' => 'string',
135
                'enum' => $this->fetchRoles(),
136
            ];
137
        }
138
        $categories = $this->fetchCategories($type);
139
        $objectTypeMeta = $this->fetchObjectTypeMeta($type);
140
141
        return $schema + $objectTypeMeta + array_filter(compact('categories'));
142
    }
143
144
    /**
145
     * Fetch `roles` names
146
     *
147
     * @return array
148
     */
149
    protected function fetchRoles(): array
150
    {
151
        $query = [
152
            'fields' => 'name',
153
            'page_size' => 100,
154
        ];
155
        $response = ApiClientProvider::getApiClient()->get('/roles', $query);
156
157
        return (array)Hash::extract((array)$response, 'data.{n}.attributes.name');
158
    }
159
160
    /**
161
     * Fetch object type metadata
162
     *
163
     * @param string $type Object type.
164
     * @return array
165
     */
166
    protected function fetchObjectTypeMeta(string $type): array
167
    {
168
        $query = [
169
            'fields' => 'associations,relations',
170
        ];
171
        $response = ApiClientProvider::getApiClient()->get(
172
            sprintf('/model/object_types/%s', $type),
173
            $query
174
        );
175
176
        return [
177
            'associations' => (array)Hash::get((array)$response, 'data.attributes.associations'),
178
            'relations' => array_flip((array)Hash::get((array)$response, 'data.meta.relations')),
179
        ];
180
    }
181
182
    /**
183
     * Fetch `categories`
184
     * This should be called only for types having `"Categories"` association
185
     *
186
     * @param string $type Object type name
187
     * @return array
188
     */
189
    protected function fetchCategories(string $type): array
190
    {
191
        $query = [
192
            'page_size' => 100,
193
        ];
194
        $url = sprintf('/model/categories?filter[type]=%s', $type);
195
        try {
196
            $response = ApiClientProvider::getApiClient()->get($url, $query);
197
        } catch (BEditaClientException $ex) {
198
            // we ignore filter errors for now
199
            $response = [];
200
        }
201
202
        return array_map(
203
            function ($item) {
204
                return [
205
                    'name' => Hash::get((array)$item, 'attributes.name'),
206
                    'label' => Hash::get((array)$item, 'attributes.label'),
207
                ];
208
            },
209
            (array)Hash::get((array)$response, 'data')
210
        );
211
    }
212
213
    /**
214
     * Load internal schema properties from configuration.
215
     *
216
     * @param string $type Resource type name
217
     * @return array
218
     */
219
    protected function loadInternalSchema(string $type): array
220
    {
221
        Configure::load('schema_properties');
222
        $properties = (array)Configure::read(sprintf('SchemaProperties.%s', $type), []);
223
224
        return compact('properties');
225
    }
226
227
    /**
228
     * Read relations schema from API using internal cache.
229
     *
230
     * @return array Relations schema.
231
     */
232
    public function getRelationsSchema()
233
    {
234
        try {
235
            $schema = (array)Cache::remember(
236
                $this->cacheKey('relations'),
237
                function () {
238
                    return $this->fetchRelationData();
239
                },
240
                self::CACHE_CONFIG
241
            );
242
        } catch (BEditaClientException $e) {
243
            // The exception is being caught _outside_ of `Cache::remember()` to avoid caching the fallback.
244
            $this->log($e, LogLevel::ERROR);
245
            $this->Flash->error($e->getMessage(), ['params' => $e]);
246
            $schema = [];
247
        }
248
249
        return $schema;
250
    }
251
252
    /**
253
     * Fetch relations schema via API.
254
     *
255
     * @return array Relations schema.
256
     */
257
    protected function fetchRelationData()
258
    {
259
        $query = [
260
            'include' => 'left_object_types,right_object_types',
261
            'page_size' => 100,
262
        ];
263
        $response = ApiClientProvider::getApiClient()->get('/model/relations', $query);
264
265
        $relations = [];
266
        // retrieve relation right and left object types
267
        $typeNames = Hash::combine((array)$response, 'included.{n}.id', 'included.{n}.attributes.name');
268
        $descendants = (array)Hash::get($this->objectTypesFeatures(), 'descendants');
269
270
        foreach ($response['data'] as $res) {
271
            $left = (array)Hash::extract($res, 'relationships.left_object_types.data.{n}.id');
272
            $types = array_intersect_key($typeNames, array_flip($left));
273
            $left = $this->concreteTypes($types, $descendants);
274
275
            $right = (array)Hash::extract($res, 'relationships.right_object_types.data.{n}.id');
276
            $types = array_intersect_key($typeNames, array_flip($right));
277
            $right = $this->concreteTypes($types, $descendants);
278
279
            unset($res['relationships'], $res['links']);
280
            $relations[$res['attributes']['name']] = $res + compact('left', 'right');
281
            $relations[$res['attributes']['inverse_name']] = $res + [
282
                'left' => $right,
283
                'right' => $left,
284
            ];
285
        }
286
        Configure::load('relations');
287
288
        return $relations + Configure::read('DefaultRelations');
289
    }
290
291
    /**
292
     * Retrieve concrete types from types list using `descendants` array
293
     *
294
     * @param array $types Object types
295
     * @param array $descendants Descendants array
296
     * @return array
297
     */
298
    protected function concreteTypes(array $types, array $descendants): array
299
    {
300
        $res = [];
301
        foreach ($types as $type) {
302
            if (!empty($descendants[$type])) {
303
                $res += $descendants[$type];
304
            } else {
305
                $res[] = $type;
306
            }
307
        }
308
        sort($res);
309
310
        return array_values(array_unique($res));
311
    }
312
313
    /**
314
     * Retrieve concrete type descendants of an object $type if any.
315
     *
316
     * @param string $type Object type name.
317
     * @return array
318
     */
319
    public function descendants(string $type): array
320
    {
321
        $features = $this->objectTypesFeatures();
322
323
        return (array)Hash::get($features, sprintf('descendants.%s', $type));
324
    }
325
326
    /**
327
     * Read object types features from API
328
     *
329
     * @return array
330
     */
331
    public function objectTypesFeatures(): array
332
    {
333
        try {
334
            $features = (array)Cache::remember(
335
                $this->cacheKey('types_features'),
336
                function () {
337
                    return $this->fetchObjectTypesFeatures();
338
                },
339
                self::CACHE_CONFIG
340
            );
341
        } catch (BEditaClientException $e) {
342
            $this->log($e, LogLevel::ERROR);
343
344
            return [];
345
        }
346
347
        return $features;
348
    }
349
350
    /**
351
     * Fetch object types information via API and manipulate response array.
352
     *
353
     * Resulting array will contain:
354
     *  * `descendants` - associative array having abstract types as keys
355
     *          and all concrete descendant types list as value
356
     *  * `uploadable` - list of concrete types having "Streams" associated,
357
     *          types that can be instantiated via file upload (like images, files)
358
     *  * `categorized` - list of concrete types having "Categories" associated
359
     *
360
     * @return array
361
     */
362
    protected function fetchObjectTypesFeatures(): array
363
    {
364
        $query = [
365
            'page_size' => 100,
366
            'fields' => 'name,is_abstract,associations,parent_name',
367
            'filter' => ['enabled' => true],
368
        ];
369
        $response = (array)ApiClientProvider::getApiClient()->get('/model/object_types', $query);
370
371
        $descendants = (array)Hash::extract($response, 'data.{n}.attributes.parent_name');
372
        $descendants = array_filter(array_unique($descendants));
373
        $types = Hash::combine($response, 'data.{n}.attributes.name', 'data.{n}.attributes');
374
        $descendants = array_fill_keys($descendants, []);
375
        $uploadable = $categorized = [];
376
        foreach ($types as $name => $data) {
377
            $abstract = (bool)Hash::get($data, 'is_abstract');
378
            if ($abstract) {
379
                continue;
380
            }
381
            $parent = (string)Hash::get($data, 'parent_name');
382
            $this->setDescendant($name, $parent, $types, $descendants);
383
            if (!(bool)Hash::get($types, $name . '.is_abstract')) {
384
                $assoc = (array)Hash::get($types, $name . '.associations');
385
                if (in_array('Streams', $assoc)) {
386
                    $uploadable[] = $name;
387
                }
388
                if (in_array('Categories', $assoc)) {
389
                    $categorized[] = $name;
390
                }
391
            }
392
        }
393
        sort($categorized);
394
        sort($uploadable);
395
396
        return compact('descendants', 'uploadable', 'categorized');
397
    }
398
399
    /**
400
     * Set descendant in $descendants array
401
     *
402
     * @param string $name Object type name
403
     * @param string $parent Parent type name
404
     * @param array $types Types array
405
     * @param array $descendants Descendants array
406
     * @return void
407
     */
408
    protected function setDescendant(string $name, string $parent, array &$types, array &$descendants): void
409
    {
410
        $desc = (array)Hash::get($descendants, $parent);
411
        if (empty($parent) || in_array($name, $desc)) {
412
            return;
413
        }
414
        $descendants[$parent][] = $name;
415
        $superParent = (string)Hash::get($types, $parent . '.parent_name');
416
        $this->setDescendant($name, $superParent, $types, $descendants);
417
    }
418
}
419