Passed
Pull Request — master (#32)
by Stefano
02:15 queued 12s
created

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