Passed
Pull Request — master (#1332)
by Dante
01:45
created

ModulesComponent::skipSaveObject()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 24
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 17
nc 4
nop 2
dl 0
loc 24
rs 9.7
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2022 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\Core\Exception\UploadException;
16
use App\Utility\DateRangesTools;
17
use App\Utility\OEmbed;
18
use App\Utility\RelationsTools;
19
use App\Utility\SchemaTrait;
20
use BEdita\WebTools\ApiClientProvider;
21
use Cake\Cache\Cache;
22
use Cake\Controller\Component;
23
use Cake\Core\Configure;
24
use Cake\Event\Event;
25
use Cake\Event\EventInterface;
26
use Cake\Http\Client\Response;
27
use Cake\Http\Exception\BadRequestException;
28
use Cake\Http\Exception\InternalErrorException;
29
use Cake\I18n\I18n;
30
use Cake\Utility\Hash;
31
32
/**
33
 * Component to load available modules.
34
 *
35
 * @property \Authentication\Controller\Component\AuthenticationComponent $Authentication
36
 * @property \App\Controller\Component\ChildrenComponent $Children
37
 * @property \App\Controller\Component\ConfigComponent $Config
38
 * @property \App\Controller\Component\ParentsComponent $Parents
39
 * @property \App\Controller\Component\SchemaComponent $Schema
40
 */
41
class ModulesComponent extends Component
42
{
43
    use SchemaTrait;
44
45
    /**
46
     * Fixed relationships to be loaded for each object
47
     *
48
     * @var array
49
     */
50
    public const FIXED_RELATIONSHIPS = [
51
        'parent',
52
        'children',
53
        'parents',
54
        'translations',
55
        'streams',
56
        'roles',
57
    ];
58
59
    /**
60
     * @inheritDoc
61
     */
62
    public array $components = ['Authentication', 'Children', 'Config', 'Parents', 'Schema'];
63
64
    /**
65
     * @inheritDoc
66
     */
67
    protected array $_defaultConfig = [
68
        'currentModuleName' => null,
69
        'clearHomeCache' => false,
70
    ];
71
72
    /**
73
     * Project modules for a user from `/home` endpoint
74
     *
75
     * @var array
76
     */
77
    protected array $modules = [];
78
79
    /**
80
     * Other "logic" modules, non objects
81
     *
82
     * @var array
83
     */
84
    protected array $otherModules = [
85
        'tags' => [
86
            'name' => 'tags',
87
            'hints' => ['allow' => ['GET', 'POST', 'PATCH', 'DELETE']],
88
        ],
89
    ];
90
91
    /**
92
     * @inheritDoc
93
     */
94
    public function beforeFilter(EventInterface $event): ?Response
95
    {
96
        /** @var \Authentication\Identity|null $user */
97
        $user = $this->Authentication->getIdentity();
98
        if (!empty($user)) {
99
            $this->getController()->set('modules', $this->getModules());
100
        }
101
102
        return null;
103
    }
104
105
    /**
106
     * Read modules and project info from `/home' endpoint.
107
     *
108
     * @return void
109
     */
110
    public function startup(): void
111
    {
112
        /** @var \Authentication\Identity|null $user */
113
        $user = $this->Authentication->getIdentity();
114
        if (empty($user) || !$user->get('id')) {
115
            $this->getController()->set(['modules' => [], 'project' => []]);
116
117
            return;
118
        }
119
120
        if ($this->getConfig('clearHomeCache')) {
121
            Cache::delete(sprintf('home_%d', $user->get('id')));
122
        }
123
124
        $project = $this->getProject();
125
        $uploadable = (array)Hash::get($this->Schema->objectTypesFeatures(), 'uploadable');
126
        $this->getController()->set(compact('project', 'uploadable'));
127
128
        $currentModuleName = $this->getConfig('currentModuleName');
129
        $modules = (array)$this->getController()->viewBuilder()->getVar('modules');
130
        if (!empty($currentModuleName)) {
131
            $currentModule = Hash::get($modules, $currentModuleName);
132
        }
133
134
        if (!empty($currentModule)) {
135
            $this->getController()->set(compact('currentModule'));
136
        }
137
    }
138
139
    /**
140
     * Create internal list of available modules in `$this->modules` as an array with `name` as key
141
     * and return it.
142
     * Modules are created from configuration and merged with information read from `/home` endpoint
143
     *
144
     * @return array
145
     */
146
    public function getModules(): array
147
    {
148
        $modules = (array)Configure::read('Modules');
149
        $pluginModules = array_filter($modules, function ($item) {
150
            return !empty($item['route']);
151
        });
152
        $metaModules = $this->modulesFromMeta() + $this->otherModules;
153
        $modules = array_intersect_key($modules, $metaModules);
154
        array_walk(
155
            $modules,
156
            function (&$data, $key) use ($metaModules): void {
157
                $data = array_merge((array)Hash::get($metaModules, $key), $data);
158
            },
159
        );
160
        $this->modules = array_merge(
161
            $modules,
162
            array_diff_key($metaModules, $modules),
163
            $pluginModules,
164
        );
165
        $this->modulesByAccessControl();
166
        if (!$this->Schema->tagsInUse()) {
167
            unset($this->modules['tags']);
168
        }
169
        $types = array_keys($this->modules);
170
        if (isset($this->modules['translations']) && !$this->translationsEnabled($types)) {
171
            unset($this->modules['translations']);
172
        }
173
174
        return $this->modules;
175
    }
176
177
    /**
178
     * Check if translations are enabled for at least one of the given object types.
179
     *
180
     * @param array $types Object types to check.
181
     * @return bool
182
     */
183
    public function translationsEnabled(array $types): bool
184
    {
185
        foreach ($types as $objectType) {
186
            $schema = (array)$this->Schema->getSchema($objectType);
187
            $translatable = (array)Hash::get($schema, 'translatable');
188
            if (count($translatable) > 0) {
189
                return true;
190
            }
191
        }
192
193
        return false;
194
    }
195
196
    /**
197
     * This filters modules and apply 'AccessControl' config by user role, if any.
198
     * Module can be "hidden": remove from modules.
199
     * Module can be "readonly": adjust "hints.allow" for module.
200
     *
201
     * @return void
202
     */
203
    protected function modulesByAccessControl(): void
204
    {
205
        $accessControl = (array)Configure::read('AccessControl');
206
        if (empty($accessControl)) {
207
            return;
208
        }
209
        /** @var \Authentication\Identity|null $user */
210
        $user = $this->Authentication->getIdentity();
211
        $userRoles = (array)$user->get('roles');
212
        if (empty($user) || empty($user->getOriginalData()) || in_array('admin', $userRoles)) {
213
            return;
214
        }
215
        $roles = array_intersect(array_keys($accessControl), $userRoles);
216
        $modules = (array)array_keys($this->modules);
217
        $hidden = [];
218
        $readonly = [];
219
        $write = [];
220
        foreach ($roles as $role) {
221
            $h = (array)Hash::get($accessControl, sprintf('%s.hidden', $role));
222
            $hidden = empty($hidden) ? $h : array_intersect($hidden, $h);
223
            $r = (array)Hash::get($accessControl, sprintf('%s.readonly', $role));
224
            $readonly = empty($readonly) ? $r : array_intersect($readonly, $r);
225
            $write = array_unique(array_merge($write, array_diff($modules, $hidden, $readonly)));
226
        }
227
        // Note: https://github.com/bedita/manager/issues/969 Accesses priority is "write" > "read" > "hidden"
228
        $readonly = array_diff($readonly, $write);
229
        $hidden = array_diff($hidden, $readonly, $write);
230
        if (empty($hidden) && empty($readonly)) {
231
            return;
232
        }
233
        // remove "hidden"
234
        $this->modules = array_diff_key($this->modules, array_flip($hidden));
235
        // make sure $readonly contains valid module names
236
        $readonly = array_intersect($readonly, array_keys($this->modules));
237
        foreach ($readonly as $key) {
238
            $path = sprintf('%s.hints.allow', $key);
239
            $allow = (array)Hash::get($this->modules, $path);
240
            $this->modules[$key]['hints']['allow'] = array_diff($allow, ['POST', 'PATCH', 'DELETE']);
241
        }
242
    }
243
244
    /**
245
     * Modules data from `/home` endpoint 'meta' response.
246
     * Modules are object endpoints from BE4 API
247
     *
248
     * @return array
249
     */
250
    protected function modulesFromMeta(): array
251
    {
252
        /** @var \Authentication\Identity $user */
253
        $user = $this->Authentication->getIdentity();
254
        $meta = $this->getMeta($user);
255
        $modules = collection(Hash::get($meta, 'resources', []))
256
            ->map(function (array $data, $endpoint) {
257
                $name = substr($endpoint, 1);
258
259
                return $data + compact('name');
260
            })
261
            ->reject(function (array $data) {
262
                return Hash::get($data, 'hints.object_type') !== true && !in_array(Hash::get($data, 'name'), ['trash', 'translations']);
263
            })
264
            ->toList();
265
266
        return Hash::combine($modules, '{n}.name', '{n}');
267
    }
268
269
    /**
270
     * Get information about current project.
271
     *
272
     * @return array
273
     */
274
    public function getProject(): array
275
    {
276
        /** @var \Authentication\Identity $user */
277
        $user = $this->Authentication->getIdentity();
278
        $api = $this->getMeta($user);
279
        $apiName = (string)Hash::get($api, 'project.name');
280
        $apiName = str_replace('API', '', $apiName);
281
        $api['project']['name'] = $apiName;
282
283
        return [
284
            'api' => (array)Hash::get($api, 'project'),
285
            'beditaApi' => [
286
                'name' => (string)Hash::get(
287
                    (array)Configure::read('Project'),
288
                    'name',
289
                    (string)Hash::get($api, 'project.name'),
290
                ),
291
                'version' => (string)Hash::get($api, 'version'),
292
            ],
293
        ];
294
    }
295
296
    /**
297
     * Check if an object type is abstract or concrete.
298
     * This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized.
299
     *
300
     * @param string $name Name of object type.
301
     * @return bool True if abstract, false if concrete
302
     */
303
    public function isAbstract(string $name): bool
304
    {
305
        return (bool)Hash::get($this->modules, sprintf('%s.hints.multiple_types', $name), false);
306
    }
307
308
    /**
309
     * Get list of object types
310
     * This method MUST NOT be called from `beforeRender` since `$this->modules` array is still not initialized.
311
     *
312
     * @param bool|null $abstract Only abstract or concrete types.
313
     * @return array Type names list
314
     */
315
    public function objectTypes(?bool $abstract = null): array
316
    {
317
        $types = [];
318
        foreach ($this->modules as $name => $data) {
319
            if (empty($data['hints']['object_type'])) {
320
                continue;
321
            }
322
            if ($abstract === null || $data['hints']['multiple_types'] === $abstract) {
323
                $types[] = $name;
324
            }
325
        }
326
327
        return $types;
328
    }
329
330
    /**
331
     * Read oEmbed metadata
332
     *
333
     * @param string $url Remote URL
334
     * @return array|null
335
     * @codeCoverageIgnore
336
     */
337
    protected function oEmbedMeta(string $url): ?array
338
    {
339
        return (new OEmbed())->readMetadata($url);
340
    }
341
342
    /**
343
     * Upload a file and store it in a media stream
344
     * Or create a remote media trying to get some metadata via oEmbed
345
     *
346
     * @param array $requestData The request data from form
347
     * @return void
348
     */
349
    public function upload(array &$requestData): void
350
    {
351
        $uploadBehavior = Hash::get($requestData, 'upload_behavior', 'file');
352
353
        if ($uploadBehavior === 'embed' && !empty($requestData['remote_url'])) {
354
            $data = $this->oEmbedMeta($requestData['remote_url']);
355
            $requestData = array_filter($requestData) + $data;
356
357
            return;
358
        }
359
        if (empty($requestData['file'])) {
360
            return;
361
        }
362
363
        // verify upload form data
364
        if ($this->checkRequestForUpload($requestData)) {
365
            /** @var \Laminas\Diactoros\UploadedFile $file */
366
            $file = $requestData['file'];
367
            $filepath = $file->getStream()->getMetadata('uri');
368
            $content = file_get_contents($filepath);
369
370
            // upload file
371
            $filename = basename($file->getClientFileName());
372
            $headers = ['Content-Type' => $file->getClientMediaType()];
373
            $apiClient = ApiClientProvider::getApiClient();
374
            $type = $this->getController()->getRequest()->getParam('object_type');
375
376
            if (empty($requestData['id'])) {
377
                $response = $apiClient->post(
378
                    sprintf('/%s/upload/%s', $type, $filename),
379
                    $content,
380
                    $headers,
381
                );
382
                $requestData['id'] = Hash::get($response, 'data.id');
383
                unset($requestData['file'], $requestData['remote_url']);
384
385
                return;
386
            }
387
388
            // remove stream
389
            $this->removeStream($requestData);
390
391
            // create stream
392
            $response = $apiClient->upload($filename, $filepath, $headers);
393
394
            // link stream to media
395
            $streamUuid = Hash::get($response, 'data.id');
396
            $this->assocStreamToMedia($streamUuid, $requestData, $filename);
397
        }
398
        unset($requestData['file'], $requestData['remote_url']);
399
    }
400
401
    /**
402
     * Remove a stream from a media, if any
403
     *
404
     * @param array $requestData The request data from form
405
     * @return bool
406
     */
407
    public function removeStream(array $requestData): bool
408
    {
409
        if (empty($requestData['id'])) {
410
            return false;
411
        }
412
413
        $apiClient = ApiClientProvider::getApiClient();
414
        $response = $apiClient->get(sprintf('/%s/%s/streams', $requestData['model-type'], $requestData['id']));
415
        if (empty($response['data'])) { // no streams for media
416
            return false;
417
        }
418
        $streamId = Hash::get($response, 'data.0.id');
419
        $apiClient->deleteObject($streamId, 'streams');
420
421
        return true;
422
    }
423
424
    /**
425
     * Associate a stream to a media using API
426
     * If $requestData['id'] is null, create media from stream.
427
     * If $requestData['id'] is not null, replace properly related stream.
428
     *
429
     * @param string $streamId The stream ID
430
     * @param array $requestData The request data
431
     * @param string $defaultTitle The default title for media
432
     * @return string The media ID
433
     */
434
    public function assocStreamToMedia(string $streamId, array &$requestData, string $defaultTitle): string
435
    {
436
        $apiClient = ApiClientProvider::getApiClient();
437
        $type = $requestData['model-type'];
438
        if (empty($requestData['id'])) {
439
            // create media from stream
440
            // save only `title` (filename if not set) and `status` in new media object
441
            $attributes = array_filter([
442
                'title' => !empty($requestData['title']) ? $requestData['title'] : $defaultTitle,
443
                'status' => Hash::get($requestData, 'status'),
444
            ]);
445
            $data = compact('type', 'attributes');
446
            $body = compact('data');
447
            $response = $apiClient->createMediaFromStream($streamId, $type, $body);
448
            // `title` and `status` saved here, remove from next save
449
            unset($requestData['title'], $requestData['status']);
450
451
            return (string)Hash::get($response, 'data.id');
452
        }
453
454
        // assoc existing media to stream
455
        $id = (string)Hash::get($requestData, 'id');
456
        $data = compact('id', 'type');
457
        $apiClient->replaceRelated($streamId, 'streams', 'object', $data);
458
459
        return $id;
460
    }
461
462
    /**
463
     * Check request data for upload and return true if upload is boht possible and needed
464
     *
465
     * @param array $requestData The request data
466
     * @return bool true if upload is possible and needed
467
     */
468
    public function checkRequestForUpload(array $requestData): bool
469
    {
470
        /** @var \Laminas\Diactoros\UploadedFile $file */
471
        $file = $requestData['file'];
472
        $error = $file->getError();
473
        // check if change file is empty
474
        if ($error === UPLOAD_ERR_NO_FILE) {
475
            return false;
476
        }
477
478
        // if upload error, throw exception
479
        if ($error !== UPLOAD_ERR_OK) {
480
            throw new UploadException(null, $error);
481
        }
482
483
        // verify presence and value of 'name', 'tmp_name', 'type'
484
        $name = $file->getClientFileName();
485
        if (empty($name)) {
486
            throw new InternalErrorException('Invalid form data: file.name');
487
        }
488
        $uri = $file->getStream()->getMetadata('uri');
489
        if (empty($uri)) {
490
            throw new InternalErrorException('Invalid form data: file.tmp_name');
491
        }
492
493
        // verify 'model-type'
494
        if (empty($requestData['model-type']) || !is_string($requestData['model-type'])) {
495
            throw new InternalErrorException('Invalid form data: model-type');
496
        }
497
498
        return true;
499
    }
500
501
    /**
502
     * Check if save can be skipped.
503
     * This is used to avoid saving object with no changes.
504
     *
505
     * @param string $id The object ID
506
     * @param array $requestData The request data
507
     * @return bool True if save can be skipped, false otherwise
508
     */
509
    public function skipSaveObject(string $id, array &$requestData): bool
510
    {
511
        if (empty($id)) {
512
            return false;
513
        }
514
        if (isset($requestData['date_ranges'])) {
515
            // check if date_ranges has changed
516
            $type = $this->getController()->getRequest()->getParam('object_type');
517
            $response = ApiClientProvider::getApiClient()->getObject($id, $type, ['fields' => 'date_ranges']);
518
            $actualDateRanges = (array)Hash::get($response, 'data.attributes.date_ranges');
519
            $dr1 = DateRangesTools::toString($actualDateRanges);
520
            $requestDateRanges = (array)Hash::get($requestData, 'date_ranges');
521
            $dr2 = DateRangesTools::toString($requestDateRanges);
522
            if ($dr1 === $dr2) {
523
                unset($requestData['date_ranges']);
524
            } else {
525
                return false;
526
            }
527
        }
528
        $data = array_filter($requestData, function ($key) {
529
            return !in_array($key, ['id', 'date_ranges', 'permissions']);
530
        }, ARRAY_FILTER_USE_KEY);
531
532
        return empty($data);
533
    }
534
535
    /**
536
     * Check if save related can be skipped.
537
     * This is used to avoid saving object relations with no changes.
538
     *
539
     * @param string $id The object ID
540
     * @param array $relatedData The related data
541
     * @return bool True if save related can be skipped, false otherwise
542
     */
543
    public function skipSaveRelated(string $id, array &$relatedData): bool
544
    {
545
        if (empty($relatedData)) {
546
            return true;
547
        }
548
        $methods = (array)Hash::extract($relatedData, '{n}.method');
549
        if (in_array('addRelated', $methods) || in_array('removeRelated', $methods) || empty($id)) {
550
            return false;
551
        }
552
        // check replaceRelated
553
        $type = $this->getController()->getRequest()->getParam('object_type');
554
        $rr = $relatedData;
555
        foreach ($rr as $method => $data) {
556
            $actualRelated = (array)ApiClientProvider::getApiClient()->getRelated($id, $type, $data['relation']);
557
            $actualRelated = (array)Hash::get($actualRelated, 'data');
558
            $actualRelated = RelationsTools::toString($actualRelated);
559
            $requestRelated = (array)Hash::get($data, 'relatedIds', []);
560
            $requestRelated = RelationsTools::toString($requestRelated);
561
            if ($actualRelated === $requestRelated) {
562
                unset($relatedData[$method]);
563
                continue;
564
            }
565
566
            return false;
567
        }
568
569
        return empty($relatedData);
570
    }
571
572
    /**
573
     * Check if save permissions can be skipped.
574
     * This is used to avoid saving object permissions with no changes.
575
     *
576
     * @param string $id The object ID
577
     * @param array $requestPermissions The request permissions
578
     * @param array $schema The object type schema
579
     * @return bool True if save permissions can be skipped, false otherwise
580
     */
581
    public function skipSavePermissions(string $id, array $requestPermissions, array $schema): bool
582
    {
583
        if (!in_array('Permissions', (array)Hash::get($schema, 'associations'))) {
584
            return true;
585
        }
586
        $requestPermissions = array_map(
587
            function ($role) {
588
                return (int)$role;
589
            },
590
            $requestPermissions,
591
        );
592
        sort($requestPermissions);
593
        $query = ['filter' => ['object_id' => $id], 'page_size' => 100];
594
        $objectPermissions = (array)ApiClientProvider::getApiClient()->getObjects('object_permissions', $query);
595
        $actualPermissions = (array)Hash::extract($objectPermissions, 'data.{n}.attributes.role_id');
596
        sort($actualPermissions);
597
598
        return $actualPermissions === $requestPermissions;
599
    }
600
601
    /**
602
     * Set current attributes from loaded $object data in `currentAttributes`.
603
     *
604
     * @param array $object The object.
605
     * @return void
606
     */
607
    public function setupAttributes(array &$object): void
608
    {
609
        $currentAttributes = json_encode((array)Hash::get($object, 'attributes'));
610
        $this->getController()->set(compact('currentAttributes'));
611
    }
612
613
    /**
614
     * Setup relations information metadata.
615
     *
616
     * @param array $schema Relations schema.
617
     * @param array $relationships Object relationships.
618
     * @param array $order Ordered names inside 'main' and 'aside' keys.
619
     * @param array $hidden List of hidden relations.
620
     * @param array $readonly List of readonly relations.
621
     * @return void
622
     */
623
    public function setupRelationsMeta(array $schema, array $relationships, array $order = [], array $hidden = [], array $readonly = []): void
624
    {
625
        // relations between objects
626
        $relationsSchema = $this->relationsSchema($schema, $relationships, $hidden, $readonly);
627
        // relations between objects and resources
628
        $resourceRelations = array_diff(array_keys($relationships), array_keys($relationsSchema), $hidden, self::FIXED_RELATIONSHIPS);
629
        // set objectRelations array with name as key and label as value
630
        $relationNames = array_keys($relationsSchema);
631
632
        // define 'main' and 'aside' relation groups
633
        $aside = array_intersect((array)Hash::get($order, 'aside'), $relationNames);
634
        $relationNames = array_diff($relationNames, $aside);
635
        $main = array_intersect((array)Hash::get($order, 'main'), $relationNames);
636
        $main = array_unique(array_merge($main, $relationNames));
637
638
        $objectRelations = [
639
            'main' => $this->relationLabels($relationsSchema, $main),
640
            'aside' => $this->relationLabels($relationsSchema, $aside),
641
        ];
642
643
        $this->getController()->set(compact('relationsSchema', 'resourceRelations', 'objectRelations'));
644
    }
645
646
    /**
647
     * Relations schema by schema and relationships.
648
     *
649
     * @param array $schema The schema
650
     * @param array $relationships The relationships
651
     * @param array $hidden Hidden relationships
652
     * @param array $readonly Readonly relationships
653
     * @return array
654
     */
655
    protected function relationsSchema(array $schema, array $relationships, array $hidden = [], array $readonly = []): array
656
    {
657
        $types = $this->objectTypes(false);
658
        sort($types);
659
        $relationsSchema = array_diff_key(array_intersect_key($schema, $relationships), array_flip($hidden));
660
661
        foreach ($relationsSchema as $relName => &$relSchema) {
662
            if (in_array('objects', (array)Hash::get($relSchema, 'right'))) {
663
                $relSchema['right'] = $types;
664
            }
665
            if (!empty($relationships[$relName]['readonly']) || in_array($relName, $readonly)) {
666
                $relSchema['readonly'] = true;
667
            }
668
        }
669
670
        return $relationsSchema;
671
    }
672
673
    /**
674
     * Retrieve associative array with names as keys and labels as values.
675
     *
676
     * @param array $relationsSchema Relations schema.
677
     * @param array $names Relation names.
678
     * @return array
679
     */
680
    protected function relationLabels(array &$relationsSchema, array $names): array
681
    {
682
        return (array)array_combine(
683
            $names,
684
            array_map(
685
                function ($r) use ($relationsSchema) {
686
                    // return 'label' or 'inverse_label' looking at 'name'
687
                    $attributes = $relationsSchema[$r]['attributes'];
688
                    if ($r === $attributes['name']) {
689
                        return $attributes['label'];
690
                    }
691
692
                    return $attributes['inverse_label'];
693
                },
694
                $names,
695
            ),
696
        );
697
    }
698
699
    /**
700
     * Get related types from relation name.
701
     *
702
     * @param array $schema Relations schema.
703
     * @param string $relation Relation name.
704
     * @return array
705
     */
706
    public function relatedTypes(array $schema, string $relation): array
707
    {
708
        $relationsSchema = (array)Hash::get($schema, $relation);
709
710
        return (array)Hash::get($relationsSchema, 'right');
711
    }
712
713
    /**
714
     * Save related objects.
715
     *
716
     * @param string $id Object ID
717
     * @param string $type Object type
718
     * @param array $relatedData Related objects data
719
     * @return void
720
     */
721
    public function saveRelated(string $id, string $type, array $relatedData): void
722
    {
723
        foreach ($relatedData as $data) {
724
            $this->saveRelatedObjects($id, $type, $data);
725
            $event = new Event('Controller.afterSaveRelated', $this, compact('id', 'type', 'data'));
726
            $this->getController()->getEventManager()->dispatch($event);
727
        }
728
    }
729
730
    /**
731
     * Save related objects per object by ID.
732
     *
733
     * @param string $id Object ID
734
     * @param string $type Object type
735
     * @param array $data Related object data
736
     * @return array|null
737
     * @throws \Cake\Http\Exception\BadRequestException
738
     */
739
    public function saveRelatedObjects(string $id, string $type, array $data): ?array
740
    {
741
        $method = (string)Hash::get($data, 'method');
742
        if (!in_array($method, ['addRelated', 'removeRelated', 'replaceRelated'])) {
743
            throw new BadRequestException(__('Bad related data method'));
744
        }
745
        $relation = (string)Hash::get($data, 'relation');
746
        $related = $this->getRelated($data);
747
        if ($relation === 'parent' && $type === 'folders') {
748
            return $this->Parents->{$method}($id, $related);
749
        }
750
        if ($relation === 'children' && $type === 'folders') {
751
            return $this->Children->{$method}($id, $related);
752
        }
753
        $lang = I18n::getLocale();
754
        $headers = ['Accept-Language' => $lang];
755
756
        return ApiClientProvider::getApiClient()->{$method}($id, $type, $relation, $related, $headers);
757
    }
758
759
    /**
760
     * Get related objects.
761
     * If related object has no ID, it will be created.
762
     *
763
     * @param array $data Related object data
764
     * @return array
765
     */
766
    public function getRelated(array $data): array
767
    {
768
        $related = (array)Hash::get($data, 'relatedIds');
769
        if (empty($related)) {
770
            return [];
771
        }
772
        $relatedObjects = [];
773
        foreach ($related as $obj) {
774
            if (!empty($obj['id'])) {
775
                $relatedObjects[] = [
776
                    'id' => $obj['id'],
777
                    'type' => $obj['type'],
778
                    'meta' => (array)Hash::get($obj, 'meta'),
779
                ];
780
                continue;
781
            }
782
            $response = ApiClientProvider::getApiClient()->save(
783
                (string)Hash::get($obj, 'type'),
784
                (array)Hash::get($obj, 'attributes'),
785
            );
786
            $relatedObjects[] = [
787
                'id' => Hash::get($response, 'data.id'),
788
                'type' => Hash::get($response, 'data.type'),
789
                'meta' => (array)Hash::get($response, 'data.meta'),
790
            ];
791
        }
792
793
        return $relatedObjects;
794
    }
795
}
796