Passed
Pull Request — master (#32)
by Alberto
01:58
created

ApiProxyTrait::post()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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