Completed
Push — master ( cc6c53...b787be )
by Alberto
15s queued 12s
created

ApiProxyTrait::handleError()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 5
eloc 13
c 1
b 0
f 1
nc 6
nop 1
dl 0
loc 20
rs 9.5222
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
    protected $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
    protected $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()
82
            ->setClassName('Json')
83
            ->setOption('serialize', true);
84
    }
85
86
    /**
87
     * Set base URL used for mask links removing trailing slashes.
88
     *
89
     * @param string $path The path on which build base URL
90
     * @return void
91
     */
92
    protected function setBaseUrl($path): void
93
    {
94
        $requestPath = $this->request->getPath();
95
        $basePath = substr($requestPath, 0, strpos($requestPath, $path));
96
        $this->baseUrl = Router::url(rtrim($basePath, '/'), true);
97
    }
98
99
    /**
100
     * Proxy for GET requests to BEdita4 API
101
     *
102
     * @param string $path The path for API request
103
     * @return void
104
     */
105
    public function get($path = '')
106
    {
107
        $this->setBaseUrl($path);
108
        $this->apiRequest([
109
            'method' => 'get',
110
            'path' => $path,
111
            'query' => $this->request->getQueryParams(),
112
        ]);
113
    }
114
115
    /**
116
     * Routes a request to the API handling response and errors.
117
     *
118
     * `$options` are:
119
     * - method => the HTTP request method
120
     * - path => a string representing the complete endpoint path
121
     * - query => an array of query strings
122
     * - body => the body sent
123
     * - headers => an array of headers
124
     *
125
     * @param array $options The request options
126
     * @return void
127
     */
128
    protected function apiRequest(array $options): void
129
    {
130
        $options += [
131
            'method' => '',
132
            'path' => '',
133
            'query' => null,
134
            'body' => null,
135
            'headers' => null,
136
        ];
137
138
        try {
139
            switch (strtolower($options['method'])) {
140
                case 'get':
141
                    $response = $this->apiClient->get($options['path'], $options['query'], $options['headers']);
142
                    break;
143
                // case 'post':
144
                //     $response = $this->apiClient->post($options['path'], $options['body'], $options['headers']);
145
                //     break;
146
                // case 'patch':
147
                //     $response = $this->apiClient->patch($options['path'], $options['body'], $options['headers']);
148
                //     break;
149
                // case 'delete':
150
                //     $response = $this->apiClient->delete($options['path'], $options['body'], $options['headers']);
151
                //     break;
152
                default:
153
                    throw new MethodNotAllowedException();
154
            }
155
156
            if (empty($response) || !is_array($response)) {
157
                return;
158
            }
159
160
            $response = $this->maskResponseLinks($response);
161
            $this->set($response);
162
        } catch (\Throwable $e) {
163
            $this->handleError($e);
164
        }
165
    }
166
167
    /**
168
     * Handle error.
169
     * Set error var for view.
170
     *
171
     * @param \Throwable $error The error thrown.
172
     * @return void
173
     */
174
    protected function handleError(\Throwable $error): void
175
    {
176
        $status = $error->getCode();
177
        if ($status < 100 || $status > 599) {
178
            $status = 500;
179
        }
180
        $this->response = $this->response->withStatus($status);
181
        $errorData = [
182
            'status' => (string)$status,
183
            'title' => $error->getMessage(),
184
        ];
185
        $this->set('error', $errorData);
186
187
        if (!$error instanceof BEditaClientException) {
188
            return;
189
        }
190
191
        $errorAttributes = $error->getAttributes();
192
        if (!empty($errorAttributes)) {
193
            $this->set('error', $errorAttributes);
194
        }
195
    }
196
197
    /**
198
     * Mask links of response to not expose API URL.
199
     *
200
     * @param array $response The response from API
201
     * @return array
202
     */
203
    protected function maskResponseLinks(array $response): array
204
    {
205
        $response = $this->maskLinks($response, '$id');
206
        $response = $this->maskLinks($response, 'links');
207
        $response = $this->maskLinks($response, 'meta.schema');
208
209
        if (!empty($response['meta']['resources'])) {
210
            $response = $this->maskMultiLinks($response, 'meta.resources', 'href');
211
        }
212
213
        $data = (array)Hash::get($response, 'data');
214
        if (empty($data)) {
215
            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...
216
        }
217
218
        if (Hash::numeric(array_keys($data))) {
219
            foreach ($data as &$item) {
220
                $item = $this->maskLinks($item, 'links');
221
                $item = $this->maskMultiLinks($item);
222
            }
223
            $response['data'] = $data;
224
        } else {
225
            $response['data']['relationships'] = $this->maskMultiLinks($data);
226
        }
227
228
        return (array)$response;
229
    }
230
231
    /**
232
     * Mask links across multidimensional array.
233
     * By default search for `relationships` and mask their `links`.
234
     *
235
     * @param array $data The data with links to mask
236
     * @param string $path The path to search for
237
     * @param string $key The key on which are the links
238
     * @return array
239
     */
240
    protected function maskMultiLinks(array $data, string $path = 'relationships', string $key = 'links'): array
241
    {
242
        $relationships = Hash::get($data, $path, []);
243
        foreach ($relationships as &$rel) {
244
            $rel = $this->maskLinks($rel, $key);
245
        }
246
247
        return Hash::insert($data, $path, $relationships);
248
    }
249
250
    /**
251
     * Mask links found in `$path`
252
     *
253
     * @param array $data The data with links to mask
254
     * @param string $path The path to search for
255
     * @return array
256
     */
257
    protected function maskLinks(array $data, string $path): array
258
    {
259
        $links = Hash::get($data, $path, []);
260
        if (empty($links)) {
261
            return $data;
262
        }
263
264
        if (is_string($links)) {
265
            $links = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $links);
266
267
            return Hash::insert($data, $path, $links);
268
        }
269
270
        foreach ($links as &$link) {
271
            $link = str_replace($this->apiClient->getApiBaseUrl(), $this->baseUrl, $link);
272
        }
273
274
        return Hash::insert($data, $path, $links);
275
    }
276
}
277