Passed
Pull Request — master (#22)
by Alberto
01:54
created

ApiProxyTrait::get()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 5
c 1
b 0
f 1
nc 1
nop 1
dl 0
loc 7
rs 10
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2020 ChannelWeb Srl, Chialab Srl
7
 *
8
 * This file is part of BEdita: you can redistribute it and/or modify
9
 * it under the terms of the GNU Lesser General Public License as published
10
 * by the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
14
 */
15
namespace BEdita\WebTools\Controller;
16
17
use BEdita\SDK\BEditaClientException;
18
use BEdita\WebTools\ApiClientProvider;
19
use Cake\Http\Exception\MethodNotAllowedException;
20
use Cake\Routing\Router;
21
use Cake\Utility\Hash;
22
23
/**
24
 * Use this Trait in a controller to directly proxy requests to BE4 API.
25
 * The response will be the same of the API itself with links masked.
26
 *
27
 * You need also to define routing rules configured as (for ApiController)
28
 *
29
 * ```
30
 * $builder->scope('/api', ['_namePrefix' => 'api:'], function (RouteBuilder $builder) {
31
 *     $builder->get('/**', ['controller' => 'Api', 'action' => 'get'], 'get');
32
 *     $builder->post('/**', ['controller' => 'Api', 'action' => 'post'], 'post');
33
 *     // and so on for patch, delete if you want to use it
34
 * });
35
 * ```
36
 */
37
trait ApiProxyTrait
38
{
39
    /**
40
     * BEdita4 API client
41
     *
42
     * @var \BEdita\SDK\BEditaClient
43
     */
44
    protected $apiClient = null;
45
46
    /**
47
     * Base URL used for mask links.
48
     *
49
     * @var string
50
     */
51
    protected $baseUrl = '';
52
53
    /**
54
     * {@inheritDoc}
55
     */
56
    public function initialize(): void
57
    {
58
        parent::initialize();
59
60
        if ($this->apiClient === null) {
61
            $this->apiClient = ApiClientProvider::getApiClient();
62
        }
63
64
        $this->viewBuilder()
0 ignored issues
show
Bug introduced by
It seems like viewBuilder() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

64
        $this->/** @scrutinizer ignore-call */ 
65
               viewBuilder()
Loading history...
65
            ->setClassName('Json')
66
            ->setOption('serialize', true);
67
    }
68
69
    /**
70
     * Set base URL used for mask links removing trailing slashes.
71
     *
72
     * @param string $path The path on which build base URL
73
     * @return void
74
     */
75
    protected function setBaseUrl($path): void
76
    {
77
        $requestPath = $this->request->getPath();
78
        $basePath = substr($requestPath, 0, strpos($requestPath, $path));
79
        $this->baseUrl = Router::url(rtrim($basePath, '/'), true);
80
    }
81
82
    /**
83
     * Proxy for GET requests to BEdita4 API
84
     *
85
     * @param string $path The path for API request
86
     * @return void
87
     */
88
    public function get($path = '')
89
    {
90
        $this->setBaseUrl($path);
91
        $this->request([
92
            'method' => 'get',
93
            'path' => $path,
94
            'query' => $this->request->getQueryParams(),
95
        ]);
96
    }
97
98
    /**
99
     * Routes a request to the API handling response and errors.
100
     *
101
     * `$options` are:
102
     * - method => the HTTP request method
103
     * - path => a string representing the complete endpoint path
104
     * - query => an array of query strings
105
     * - body => the body sent
106
     * - headers => an array of headers
107
     *
108
     * @param array $options The request options
109
     * @return void
110
     */
111
    protected function request(array $options): void
112
    {
113
        $options += [
114
            'method' => '',
115
            'path' => '',
116
            'query' => null,
117
            'body' => null,
118
            'headers' => null,
119
        ];
120
121
        try {
122
            switch (strtolower($options['method'])) {
123
                case 'get':
124
                    $response = $this->apiClient->get($options['path'], $options['query'], $options['headers']);
125
                    break;
126
                case 'post':
127
                    $response = $this->apiClient->post($options['path'], $options['body'], $options['headers']);
128
                    break;
129
                case 'patch':
130
                    $response = $this->apiClient->patch($options['path'], $options['body'], $options['headers']);
131
                    break;
132
                case 'delete':
133
                    $response = $this->apiClient->delete($options['path'], $options['body'], $options['headers']);
134
                    break;
135
                default:
136
                    throw new MethodNotAllowedException();
137
            }
138
139
            if (empty($response) || !is_array($response)) {
140
                return;
141
            }
142
143
            $response = $this->maskResponseLinks($response);
144
            $this->set($response);
0 ignored issues
show
Bug introduced by
It seems like set() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

144
            $this->/** @scrutinizer ignore-call */ 
145
                   set($response);
Loading history...
145
        } catch (\Throwable $e) {
146
            $this->handleError($e);
147
        }
148
    }
149
150
    /**
151
     * Handle error.
152
     * Set error var for view.
153
     *
154
     * @param \Throwable $error The error thrown.
155
     * @return void
156
     */
157
    protected function handleError(\Throwable $error): void
158
    {
159
        $status = $error->getCode();
160
        if ($status < 100 || $status > 599) {
161
            $status = 500;
162
        }
163
        $this->response = $this->response->withStatus($status);
0 ignored issues
show
Bug Best Practice introduced by
The property response does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
164
        $errorData = [
165
            'status' => $status,
166
            'title' => $error->getMessage(),
167
        ];
168
        $this->set('error', $errorData);
169
170
        if (!$error instanceof BEditaClientException) {
171
            return;
172
        }
173
174
        $errorAttributes = $error->getAttributes();
175
        if (!empty($errorAttributes)) {
176
            $this->set('error', $errorAttributes);
177
        }
178
    }
179
180
    /**
181
     * Mask links of response to not expose API URL.
182
     *
183
     * @param array $response The response from API
184
     * @return array
185
     */
186
    protected function maskResponseLinks(array $response): array
187
    {
188
        $response = $this->maskLinks($response, '$id');
189
        $response = $this->maskLinks($response, 'links');
190
        $response = $this->maskLinks($response, 'meta.schema');
191
192
        if (!empty($response['meta']['resources'])) {
193
            $response = $this->maskMultiLinks($response, 'meta.resources', 'href');
194
        }
195
196
        $data = Hash::get($response, 'data');
197
        if (empty($data)) {
198
            return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response could return the type ArrayAccess which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
199
        }
200
201
        if (Hash::numeric(array_keys($data))) {
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type ArrayAccess; however, parameter $input of array_keys() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

201
        if (Hash::numeric(array_keys(/** @scrutinizer ignore-type */ $data))) {
Loading history...
202
            foreach ($data as $key => &$item) {
203
                $item = $this->maskLinks($item, 'links');
204
                $item = $this->maskMultiLinks($item);
205
            }
206
            $response['data'] = $data;
207
        } else {
208
            $response['data']['relationships'] = $this->maskMultiLinks($data);
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type ArrayAccess; however, parameter $data of BEdita\WebTools\Controll...Trait::maskMultiLinks() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

208
            $response['data']['relationships'] = $this->maskMultiLinks(/** @scrutinizer ignore-type */ $data);
Loading history...
209
        }
210
211
        return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response could return the type ArrayAccess which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
212
    }
213
214
    /**
215
     * Mask links across multidimensional array.
216
     * By default search for `relationships` and mask their `links`.
217
     *
218
     * @param array $data The data with links to mask
219
     * @param string $path The path to search for
220
     * @param string $key The key on which are the links
221
     * @return array
222
     */
223
    protected function maskMultiLinks(array $data, $path = 'relationships', $key = 'links'): array
224
    {
225
        $relationships = Hash::get($data, $path, []);
226
        foreach ($relationships as &$rel) {
227
            $rel = $this->maskLinks($rel, $key);
228
        }
229
230
        return Hash::insert($data, $path, $relationships);
231
    }
232
233
    /**
234
     * Mask links found in `$path`
235
     *
236
     * @param array|string $data The data with links to mask
237
     * @param string $path The path to search for
238
     * @return array
239
     */
240
    protected function maskLinks($data, $path): array
241
    {
242
        $links = Hash::get($data, $path, []);
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type string; however, parameter $data of Cake\Utility\Hash::get() does only seem to accept ArrayAccess|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

242
        $links = Hash::get(/** @scrutinizer ignore-type */ $data, $path, []);
Loading history...
243
        if (empty($links)) {
244
            return $data;
245
        }
246
247
        if (is_string($links)) {
248
            $links = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $links);
249
250
            return Hash::insert($data, $path, $links);
251
        }
252
253
        foreach ($links as &$link) {
254
            $link = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $link);
255
        }
256
257
        return Hash::insert($data, $path, $links);
258
    }
259
}
260