Passed
Pull Request — master (#54)
by Paolo
02:49
created

ApiProxyTrait::handleError()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 21
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 14
c 1
b 0
f 0
nc 6
nop 1
dl 0
loc 21
rs 9.4888
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
use Cake\View\ViewVarsTrait;
23
24
/**
25
 * Use this Trait in a controller to directly proxy requests to BE4 API.
26
 * The response will be the same of the API itself with links masked.
27
 *
28
 * You need also to define routing rules configured as (for ApiController)
29
 *
30
 * ```
31
 * $builder->scope('/api', ['_namePrefix' => 'api:'], function (RouteBuilder $builder) {
32
 *     $builder->get('/**', ['controller' => 'Api', 'action' => 'get'], 'get');
33
 *     $builder->post('/**', ['controller' => 'Api', 'action' => 'post'], 'post');
34
 *     // and so on for patch, delete if you want to use it
35
 * });
36
 * ```
37
 */
38
trait ApiProxyTrait
39
{
40
    use ViewVarsTrait;
41
42
    /**
43
     * An instance of a \Cake\Http\ServerRequest object that contains information about the current request.
44
     *
45
     * @var \Cake\Http\ServerRequest
46
     */
47
    public $request;
48
49
    /**
50
     * An instance of a Response object that contains information about the impending response.
51
     *
52
     * @var \Cake\Http\Response
53
     */
54
    public $response;
55
56
    /**
57
     * BEdita4 API client
58
     *
59
     * @var \BEdita\SDK\BEditaClient
60
     */
61
    protected $apiClient = null;
62
63
    /**
64
     * Base URL used for mask links.
65
     *
66
     * @var string
67
     */
68
    protected $baseUrl = '';
69
70
    /**
71
     * {@inheritDoc}
72
     */
73
    public function initialize(): void
74
    {
75
        parent::initialize();
76
77
        if ($this->apiClient === null) {
78
            $this->apiClient = ApiClientProvider::getApiClient();
79
        }
80
81
        $this->viewBuilder()->setClassName('Json');
82
    }
83
84
    /**
85
     * Set base URL used for mask links removing trailing slashes.
86
     *
87
     * @param string $path The path on which build base URL
88
     * @return void
89
     */
90
    protected function setBaseUrl($path): void
91
    {
92
        $requestPath = $this->request->getPath();
93
        $basePath = substr($requestPath, 0, strpos($requestPath, $path));
94
        $this->baseUrl = Router::url(rtrim($basePath, '/'), true);
95
    }
96
97
    /**
98
     * Proxy for GET requests to BEdita4 API
99
     *
100
     * @param string $path The path for API request
101
     * @return void
102
     */
103
    public function get($path = ''): void
104
    {
105
        $this->apiRequest([
106
            'method' => 'get',
107
            'path' => $path,
108
            'query' => $this->request->getQueryParams(),
109
        ]);
110
    }
111
112
    /**
113
     * Proxy for POST requests to BEdita4 API
114
     *
115
     * @param string $path The path for API request
116
     * @return void
117
     */
118
    public function post($path = ''): void
119
    {
120
        $this->apiRequest([
121
            'method' => 'post',
122
            'path' => $path,
123
            'body' => $this->request->getData(),
124
        ]);
125
    }
126
127
    /**
128
     * Proxy for PATCH requests to BEdita4 API
129
     *
130
     * @param string $path The path for API request
131
     * @return void
132
     */
133
    public function patch($path = ''): void
134
    {
135
        $this->apiRequest([
136
            'method' => 'patch',
137
            'path' => $path,
138
            'body' => $this->request->getData(),
139
        ]);
140
    }
141
142
    /**
143
     * Proxy for DELETE requests to BEdita4 API
144
     *
145
     * @param string $path The path for API request
146
     * @return void
147
     */
148
    public function delete($path = ''): void
149
    {
150
        $this->apiRequest([
151
            'method' => 'delete',
152
            'path' => $path,
153
            'body' => $this->request->getData(),
154
        ]);
155
    }
156
157
    /**
158
     * Routes a request to the API handling response and errors.
159
     *
160
     * `$options` are:
161
     * - method => the HTTP request method
162
     * - path => a string representing the complete endpoint path
163
     * - query => an array of query strings
164
     * - body => the body sent
165
     * - headers => an array of headers
166
     *
167
     * @param array $options The request options
168
     * @return void
169
     */
170
    protected function apiRequest(array $options): void
171
    {
172
        $options += [
173
            'method' => '',
174
            'path' => '',
175
            'query' => null,
176
            'body' => null,
177
            'headers' => null,
178
        ];
179
180
        if (empty($options['body'])) {
181
            $options['body'] = null;
182
        }
183
        if (is_array($options['body'])) {
184
            $options['body'] = json_encode($options['body']);
185
        }
186
187
        try {
188
            $this->setBaseUrl($options['path']);
189
            $method = strtolower($options['method']);
190
            if (!in_array($method, ['get', 'post', 'patch', 'delete'])) {
191
                throw new MethodNotAllowedException();
192
            }
193
194
            if ($method === 'get') {
195
                $response = $this->apiClient->get($options['path'], $options['query'], $options['headers']);
196
            } else {
197
                $response = call_user_func_array(
198
                    [$this->apiClient, $method], // call 'post', 'patch' or 'delete'
199
                    [$options['path'], $options['body'], $options['headers']]
200
                );
201
            }
202
203
            if ($response === null) {
204
                $this->autoRender = false;
0 ignored issues
show
Bug Best Practice introduced by
The property autoRender does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
205
                $this->response = $this->response->withStringBody(null);
206
207
                return;
208
            }
209
210
            $response = $this->maskResponseLinks($response);
211
            $this->set($response);
212
            $this->set('_serialize', array_keys($response));
213
        } catch (\Throwable $e) {
214
            $this->handleError($e);
215
        }
216
    }
217
218
    /**
219
     * Handle error.
220
     * Set error var for view.
221
     *
222
     * @param \Throwable $error The error thrown.
223
     * @return void
224
     */
225
    protected function handleError(\Throwable $error): void
226
    {
227
        $status = $error->getCode();
228
        if ($status < 100 || $status > 599) {
229
            $status = 500;
230
        }
231
        $this->response = $this->response->withStatus($status);
232
        $errorData = [
233
            'status' => (string)$status,
234
            'title' => $error->getMessage(),
235
        ];
236
        $this->set('error', $errorData);
237
        $this->set('_serialize', ['error']);
238
239
        if (!$error instanceof BEditaClientException) {
240
            return;
241
        }
242
243
        $errorAttributes = $error->getAttributes();
244
        if (!empty($errorAttributes)) {
245
            $this->set('error', $errorAttributes);
246
        }
247
    }
248
249
    /**
250
     * Mask links of response to not expose API URL.
251
     *
252
     * @param array $response The response from API
253
     * @return array
254
     */
255
    protected function maskResponseLinks(array $response): array
256
    {
257
        $response = $this->maskLinks($response, '$id');
258
        $response = $this->maskLinks($response, 'links');
259
        $response = $this->maskLinks($response, 'meta.schema');
260
261
        if (!empty($response['meta']['resources'])) {
262
            $response = $this->maskMultiLinks($response, 'meta.resources', 'href');
263
        }
264
265
        $data = (array)Hash::get($response, 'data');
266
        if (empty($data)) {
267
            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...
268
        }
269
270
        if (Hash::numeric(array_keys($data))) {
271
            foreach ($data as &$item) {
272
                $item = $this->maskLinks($item, 'links');
273
                $item = $this->maskMultiLinks($item);
274
            }
275
            $response['data'] = $data;
276
        } else {
277
            $response['data'] = $this->maskMultiLinks($data);
278
        }
279
280
        return (array)$response;
281
    }
282
283
    /**
284
     * Mask links across multidimensional array.
285
     * By default search for `relationships` and mask their `links`.
286
     *
287
     * @param array $data The data with links to mask
288
     * @param string $path The path to search for
289
     * @param string $key The key on which are the links
290
     * @return array
291
     */
292
    protected function maskMultiLinks(array $data, string $path = 'relationships', string $key = 'links'): array
293
    {
294
        $relationships = Hash::get($data, $path, []);
295
        foreach ($relationships as &$rel) {
296
            $rel = $this->maskLinks($rel, $key);
297
        }
298
299
        return Hash::insert($data, $path, $relationships);
300
    }
301
302
    /**
303
     * Mask links found in `$path`
304
     *
305
     * @param array $data The data with links to mask
306
     * @param string $path The path to search for
307
     * @return array
308
     */
309
    protected function maskLinks(array $data, string $path): array
310
    {
311
        $links = Hash::get($data, $path, []);
312
        if (empty($links)) {
313
            return $data;
314
        }
315
316
        if (is_string($links)) {
317
            $links = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $links);
318
319
            return Hash::insert($data, $path, $links);
320
        }
321
322
        foreach ($links as &$link) {
323
            $link = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $link);
324
        }
325
326
        return Hash::insert($data, $path, $links);
327
    }
328
}
329