Passed
Pull Request — master (#908)
by Stefano
01:09
created

SchemaComponent   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 446
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 171
c 0
b 0
f 0
dl 0
loc 446
rs 8.96
wmc 43

18 Methods

Rating   Name   Duplication   Size   Complexity  
A fetchSchema() 0 17 3
A getSchemasByType() 0 8 2
A fetchRoles() 0 9 1
A getSchema() 0 32 5
A loadWithRevision() 0 15 5
A concreteTypes() 0 13 3
A loadInternalSchema() 0 6 1
A objectTypesFeatures() 0 17 2
A abstractTypes() 0 7 1
A getRelationsSchema() 0 18 2
A fetchRelationData() 0 32 2
A descendants() 0 5 1
A fetchCategories() 0 23 2
A setDescendant() 0 9 3
A tagsInUse() 0 6 1
B fetchObjectTypesFeatures() 0 39 7
A fetchObjectTypeMeta() 0 13 1
A clearCache() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like SchemaComponent often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SchemaComponent, and based on these observations, apply Extract Interface, too.

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