Passed
Pull Request — master (#32)
by Alberto
02:00
created

ApiProxyTrait::apiRequest()   B

Complexity

Conditions 6
Paths 36

Size

Total Lines 42
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

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