Issues (5)

src/Controller/ApiProxyTrait.php (2 issues)

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\BEditaClient;
18
use BEdita\SDK\BEditaClientException;
19
use BEdita\WebTools\ApiClientProvider;
20
use Cake\Http\Exception\BadRequestException;
21
use Cake\Http\Exception\MethodNotAllowedException;
22
use Cake\Http\Response;
23
use Cake\Http\ServerRequest;
24
use Cake\Routing\Router;
25
use Cake\Utility\Hash;
26
use Cake\View\ViewVarsTrait;
27
use Throwable;
28
29
/**
30
 * Use this Trait in a controller to directly proxy requests to BE4 API.
31
 * The response will be the same of the API itself with links masked.
32
 *
33
 * You need also to define routing rules configured as (for ApiController)
34
 *
35
 * ```
36
 * $builder->scope('/api', ['_namePrefix' => 'api:'], function (RouteBuilder $builder) {
37
 *     $builder->get('/**', ['controller' => 'Api', 'action' => 'get'], 'get');
38
 *     $builder->post('/**', ['controller' => 'Api', 'action' => 'post'], 'post');
39
 *     // and so on for patch, delete if you want to use it
40
 * });
41
 * ```
42
 */
43
trait ApiProxyTrait
44
{
45
    use ViewVarsTrait;
46
47
    /**
48
     * An instance of a \Cake\Http\ServerRequest object that contains information about the current request.
49
     *
50
     * @var \Cake\Http\ServerRequest
51
     */
52
    protected ServerRequest $request;
53
54
    /**
55
     * An instance of a Response object that contains information about the impending response.
56
     *
57
     * @var \Cake\Http\Response
58
     */
59
    protected Response $response;
60
61
    /**
62
     * BEdita API client
63
     *
64
     * @var \BEdita\SDK\BEditaClient|null
65
     */
66
    protected ?BEditaClient $apiClient = null;
67
68
    /**
69
     * Base URL used for mask links.
70
     *
71
     * @var string
72
     */
73
    protected string $baseUrl = '';
74
75
    /**
76
     * @inheritDoc
77
     */
78
    public function initialize(): void
79
    {
80
        parent::initialize();
81
82
        if ($this->apiClient === null) {
83
            $this->apiClient = ApiClientProvider::getApiClient();
84
        }
85
86
        $this->viewBuilder()->setClassName('Json');
87
    }
88
89
    /**
90
     * Set base URL used for mask links removing trailing slashes.
91
     *
92
     * @param string $path The path on which build base URL
93
     * @return void
94
     */
95
    protected function setBaseUrl(string $path): void
96
    {
97
        $requestPath = $this->request->getPath();
98
        $pos = strpos(rawurldecode($requestPath), $path);
99
        if ($pos === false) {
100
            throw new BadRequestException('Path not found in request');
101
        }
102
103
        $basePath = substr($requestPath, 0, $pos);
104
        $this->baseUrl = Router::url(rtrim($basePath, '/'), true);
105
    }
106
107
    /**
108
     * Proxy for GET requests to BEdita API
109
     *
110
     * @param string $path The path for API request
111
     * @return void
112
     */
113
    public function get(string $path = ''): void
114
    {
115
        $this->apiRequest([
116
            'method' => 'get',
117
            'path' => $path,
118
            'query' => $this->request->getQueryParams(),
119
        ]);
120
    }
121
122
    /**
123
     * Proxy for POST requests to BEdita API
124
     *
125
     * @param string $path The path for API request
126
     * @return void
127
     */
128
    public function post(string $path = ''): void
129
    {
130
        $this->apiRequest([
131
            'method' => 'post',
132
            'path' => $path,
133
            'body' => $this->request->getData(),
134
        ]);
135
    }
136
137
    /**
138
     * Proxy for PATCH requests to BEdita API
139
     *
140
     * @param string $path The path for API request
141
     * @return void
142
     */
143
    public function patch(string $path = ''): void
144
    {
145
        $this->apiRequest([
146
            'method' => 'patch',
147
            'path' => $path,
148
            'body' => $this->request->getData(),
149
        ]);
150
    }
151
152
    /**
153
     * Proxy for DELETE requests to BEdita API
154
     *
155
     * @param string $path The path for API request
156
     * @return void
157
     */
158
    public function delete(string $path = ''): void
159
    {
160
        $this->apiRequest([
161
            'method' => 'delete',
162
            'path' => $path,
163
            'body' => $this->request->getData(),
164
        ]);
165
    }
166
167
    /**
168
     * Routes a request to the API handling response and errors.
169
     *
170
     * `$options` are:
171
     * - method => the HTTP request method
172
     * - path => a string representing the complete endpoint path
173
     * - query => an array of query strings
174
     * - body => the body sent
175
     * - headers => an array of headers
176
     *
177
     * @param array $options The request options
178
     * @return void
179
     */
180
    protected function apiRequest(array $options): void
181
    {
182
        $options += [
183
            'method' => '',
184
            'path' => '',
185
            'query' => null,
186
            'body' => null,
187
            'headers' => null,
188
        ];
189
190
        if (empty($options['body'])) {
191
            $options['body'] = null;
192
        }
193
        if (is_array($options['body'])) {
194
            $options['body'] = json_encode($options['body']);
195
        }
196
197
        try {
198
            $this->setBaseUrl($options['path']);
199
            $method = strtolower($options['method']);
200
            if (!in_array($method, ['get', 'post', 'patch', 'delete'])) {
201
                throw new MethodNotAllowedException();
202
            }
203
204
            if ($method === 'get') {
205
                $response = $this->apiClient->get($options['path'], $options['query'], $options['headers']);
0 ignored issues
show
The method get() does not exist on null. ( Ignorable by Annotation )

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

205
                /** @scrutinizer ignore-call */ 
206
                $response = $this->apiClient->get($options['path'], $options['query'], $options['headers']);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
206
            } else {
207
                $response = call_user_func_array(
208
                    [$this->apiClient, $method], // call 'post', 'patch' or 'delete'
209
                    [$options['path'], $options['body'], $options['headers']]
210
                );
211
            }
212
213
            if ($response === null) {
214
                $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...
215
                $this->response = $this->response->withStringBody(null);
216
217
                return;
218
            }
219
220
            $response = $this->maskResponseLinks($response);
221
            $this->set($response);
222
            $this->viewBuilder()->setOption('serialize', array_keys($response));
223
        } catch (Throwable $e) {
224
            $this->handleError($e);
225
        }
226
    }
227
228
    /**
229
     * Handle error.
230
     * Set error var for view.
231
     *
232
     * @param \Throwable $error The error thrown.
233
     * @return void
234
     */
235
    protected function handleError(Throwable $error): void
236
    {
237
        $status = $error->getCode();
238
        if ($status < 100 || $status > 599) {
239
            $status = 500;
240
        }
241
        $this->response = $this->response->withStatus($status);
242
        $errorData = [
243
            'status' => (string)$status,
244
            'title' => $error->getMessage(),
245
        ];
246
        $this->set('error', $errorData);
247
        $this->viewBuilder()->setOption('serialize', ['error']);
248
249
        if (!$error instanceof BEditaClientException) {
250
            return;
251
        }
252
253
        $errorAttributes = $error->getAttributes();
254
        if (!empty($errorAttributes)) {
255
            $this->set('error', $errorAttributes);
256
        }
257
    }
258
259
    /**
260
     * Mask links of response to not expose API URL.
261
     *
262
     * @param array $response The response from API
263
     * @return array
264
     */
265
    protected function maskResponseLinks(array $response): array
266
    {
267
        $response = $this->maskLinks($response, '$id');
268
        $response = $this->maskLinks($response, 'links');
269
        $response = $this->maskLinks($response, 'meta.schema');
270
271
        if (!empty($response['meta']['resources'])) {
272
            $response = $this->maskMultiLinks($response, 'meta.resources', 'href');
273
        }
274
275
        $data = (array)Hash::get($response, 'data');
276
        if (empty($data)) {
277
            return $response;
278
        }
279
280
        if (Hash::numeric(array_keys($data))) {
281
            foreach ($data as &$item) {
282
                $item = $this->maskLinks($item, 'links');
283
                $item = $this->maskMultiLinks($item);
284
            }
285
            $response['data'] = $data;
286
        } else {
287
            $response['data'] = $this->maskMultiLinks($data);
288
        }
289
290
        return (array)$response;
291
    }
292
293
    /**
294
     * Mask links across multidimensional array.
295
     * By default search for `relationships` and mask their `links`.
296
     *
297
     * @param array $data The data with links to mask
298
     * @param string $path The path to search for
299
     * @param string $key The key on which are the links
300
     * @return array
301
     */
302
    protected function maskMultiLinks(array $data, string $path = 'relationships', string $key = 'links'): array
303
    {
304
        $relationships = Hash::get($data, $path, []);
305
        foreach ($relationships as &$rel) {
306
            $rel = $this->maskLinks($rel, $key);
307
        }
308
309
        return Hash::insert($data, $path, $relationships);
310
    }
311
312
    /**
313
     * Mask links found in `$path`
314
     *
315
     * @param array $data The data with links to mask
316
     * @param string $path The path to search for
317
     * @return array
318
     */
319
    protected function maskLinks(array $data, string $path): array
320
    {
321
        $links = Hash::get($data, $path, []);
322
        if (empty($links)) {
323
            return $data;
324
        }
325
326
        if (is_string($links)) {
327
            $links = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $links);
328
329
            return Hash::insert($data, $path, $links);
330
        }
331
332
        foreach ($links as &$link) {
333
            if (is_string($link)) {
334
                $link = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $link);
335
            }
336
        }
337
338
        return Hash::insert($data, $path, $links);
339
    }
340
}
341