Completed
Pull Request — master (#28)
by Jesse
126:41
created

Client::getCommandQuery()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace PCextreme\Cloudstack;
6
7
use PCextreme\Cloudstack\Exception\ClientException;
8
use InvalidArgumentException;
9
use PCextreme\Cloudstack\Util\UrlHelpersTrait;
10
use Psr\Http\Message\ResponseInterface;
11
use RuntimeException;
12
use Symfony\Component\Cache\Simple\FilesystemCache;
13
14
class Client extends AbstractClient
15
{
16
    use UrlHelpersTrait;
17
18
    /**
19
     * @var array
20
     */
21
    protected $apiList;
22
23
    /**
24
     * @var string
25
     */
26
    private $urlApi;
27
28
    /**
29
     * @var string
30
     */
31
    private $urlClient;
32
33
    /**
34
     * @var string
35
     */
36
    private $urlConsole;
37
38
    /**
39
     * @var string
40
     */
41
    protected $apiKey;
42
43
    /**
44
     * @var string
45
     */
46
    protected $secretKey;
47
48
    /**
49
     * @var string
50
     */
51
    protected $ssoKey;
52
53
    /**
54
     * @var string
55
     */
56
    private $responseError = 'errortext';
57
58
    /**
59
     * @var string
60
     */
61
    private $responseCode = 'errorcode';
62
63
    /**
64
     * @var boolean
65
     */
66
    private $ssoEnabled = false;
67
68
    /**
69
     * @var FilesystemCache
70
     */
71
    private $cache;
72
73
    /**
74
     * Constructs a new Cloudstack client instance.
75
     *
76
     * @param  array $options
77
     *     An array of options to set on this client. Options include
78
     *     'apiList', 'urlApi', 'urlClient', 'urlConsole', 'apiKey',
79
     *     'secretKey', 'responseError' and 'responseCode'.
80
     * @param  array $collaborators
81
     *     An array of collaborators that may be used to override
82
     *     this provider's default behavior. Collaborators include
83
     *     `requestFactory` and `httpClient`.
84
     */
85
    public function __construct(array $options = [], array $collaborators = [])
86
    {
87
        $this->assertRequiredOptions($options);
88
89
        $possible   = $this->getConfigurableOptions();
90
        $configured = array_intersect_key($options, array_flip($possible));
91
92
        foreach ($configured as $key => $value) {
93
            $this->$key = $value;
94
        }
95
96
        // Remove all options that are only used locally
97
        $options = array_diff_key($options, $configured);
98
99
        parent::__construct($options, $collaborators);
100
    }
101
102
    /**
103
     * Returns all options that can be configured.
104
     *
105
     * @return array
106
     */
107
    protected function getConfigurableOptions() : array
108
    {
109
        return array_merge($this->getRequiredOptions(), [
110
            'apiList',
111
            'urlClient',
112
            'urlConsole',
113
            'ssoKey',
114
            'responseError',
115
            'responseCode',
116
        ]);
117
    }
118
119
    /**
120
     * Returns all options that are required.
121
     *
122
     * @return array
123
     */
124
    protected function getRequiredOptions() : array
125
    {
126
        return [
127
            'urlApi',
128
            'apiKey',
129
            'secretKey',
130
        ];
131
    }
132
133
    /**
134
     * Verifies that all required options have been passed.
135
     *
136
     * @param  array $options
137
     * @return void
138
     * @throws InvalidArgumentException
139
     */
140
    private function assertRequiredOptions(array $options) : void
141
    {
142
        $missing = array_diff_key(array_flip($this->getRequiredOptions()), $options);
143
144
        if (!empty($missing)) {
145
            throw new InvalidArgumentException(
146
                'Required options not defined: '.implode(', ', array_keys($missing))
147
            );
148
        }
149
    }
150
151
    /**
152
     * Execute command.
153
     *
154
     * @param  string $command
155
     * @param  array  $options
156
     * @return mixed
157
     */
158
    public function command(string $command, array $options = [])
159
    {
160
        $this->assertRequiredCommandOptions($command, $options);
161
162
        $method  = $this->getCommandMethod($command);
163
        $url     = $this->getCommandUrl($command, $options);
164
        $request = $this->getRequest($method, $url, $options);
165
166
        return $this->getResponse($request);
167
    }
168
169
    /**
170
     * Verifies that all required options have been passed.
171
     *
172
     * @param string $command
173
     * @param  array  $options
174
     * @return void
175
     * @throws RuntimeException
176
     * @throws InvalidArgumentException
177
     */
178
    private function assertRequiredCommandOptions(string $command, array $options = []) : void
179
    {
180
        $apiList = $this->getApiList();
181
182
        if (!array_key_exists($command, $apiList)) {
183
            throw new RuntimeException(
184
                "Call to unsupported API command [{$command}], this call is not present in the API list."
185
            );
186
        }
187
188
        foreach ($apiList[$command]['params'] as $key => $value) {
189
            if (!array_key_exists($key, $options) && (bool) $value['required']) {
190
                throw new InvalidArgumentException(
191
                    "Missing argument [{$key}] for command [{$command}] must be of type [{$value['type']}]."
192
                );
193
            }
194
        }
195
    }
196
197
    /**
198
     * Returns command method based on the command.
199
     *
200
     * @param  string $command
201
     * @return string
202
     */
203
    public function getCommandMethod(string $command) : string
204
    {
205
        if (in_array($command, ['login', 'deployVirtualMachine'])) {
206
            return self::METHOD_POST;
207
        }
208
209
        return self::METHOD_GET;
210
    }
211
212
    /**
213
     * Builds the command URL's query string.
214
     *
215
     * @param  array $params
216
     * @return string
217
     */
218
    public function getCommandQuery(array $params) : string
219
    {
220
        return $this->signCommandParameters($params);
221
    }
222
223
    /**
224
     * Builds the authorization URL.
225
     *
226
     * @param  string $command
227
     * @param  array  $options
228
     * @return string
229
     */
230
    public function getCommandUrl(string $command, array $options = []) : string
231
    {
232
        $base   = $this->urlApi;
233
        $params = $this->getCommandParameters($command, $options);
234
        $query  = $this->getCommandQuery($params);
235
236
        return $this->appendQuery($base, $query);
237
    }
238
239
    /**
240
     * Returns command parameters based on provided options.
241
     *
242
     * @param  string $command
243
     * @param  array  $options
244
     * @return array
245
     */
246
    protected function getCommandParameters(string $command, array $options) : array
247
    {
248
        return array_merge($options, [
249
            'command'  => $command,
250
            'response' => 'json',
251
            'apikey'   => $this->apiKey,
252
        ]);
253
    }
254
255
    /**
256
     * Signs the command parameters.
257
     *
258
     * @param  array $params
259
     * @return string
260
     */
261
    protected function signCommandParameters(array $params = []) : string
262
    {
263
        if ($this->isSsoEnabled() && is_null($this->ssoKey)) {
264
            throw new InvalidArgumentException(
265
                'Required options not defined: ssoKey'
266
            );
267
        }
268
269
        ksort($params);
270
271
        $query = $this->buildQueryString($params);
272
273
        $key = $this->isSsoEnabled() ? $this->ssoKey : $this->secretKey;
274
        $signature = rawurlencode(base64_encode(hash_hmac(
275
            'SHA1',
276
            strtolower($query),
277
            $key,
278
            true
279
        )));
280
281
        // Reset SSO signing for the next request.
282
        $this->ssoEnabled = false;
283
284
        // To prevent the signature from being escaped we simply append
285
        // the signature to the previously build query.
286
        return $query.'&signature='.$signature;
287
    }
288
289
    /**
290
     * Get Cloudstack Client API list.
291
     *
292
     * Tries to load the API list from the cache directory when
293
     * the 'apiList' on the class is empty.
294
     *
295
     * @return array
296
     * @throws RuntimeException
297
     */
298
    public function getApiList() : array
299
    {
300
        if (is_null($this->apiList)) {
301
            if (! $this->cache()->has('api.list')) {
302
                throw new RuntimeException(
303
                    "Cloudstack Client API list not found. This file needs to be generated before using the client."
304
                );
305
            }
306
307
            $this->apiList = $this->cache()->get('api.list');
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->cache()->get('api.list') of type * is incompatible with the declared type array of property $apiList.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
308
        }
309
310
        return $this->apiList;
311
    }
312
313
    /**
314
     * Set Cloudstack Client API list.
315
     *
316
     * @param  array $apiList
317
     * @return void
318
     */
319
    public function setApiList(array $apiList) : void
320
    {
321
        $this->apiList = $apiList;
322
    }
323
324
    /**
325
     * Appends a query string to a URL.
326
     *
327
     * @param  string $url
328
     * @param  string $query
329
     * @return string
330
     */
331
    protected function appendQuery(string $url, string $query) : string
332
    {
333
        $query = trim($query, '?&');
334
335
        if ($query) {
336
            return $url.'?'.$query;
337
        }
338
339
        return $url;
340
    }
341
342
    /**
343
     * Build a query string from an array.
344
     *
345
     * @param  array $params
346
     * @return string
347
     */
348
    protected function buildQueryString(array $params) : string
349
    {
350
        // We need to modify the nested array keys to get them accepted by Cloudstack.
351
        // For example 'details[0][key]' should resolve to 'details[0].key'.
352
        array_walk($params, function (&$value, $key) {
353
            if (is_array($value)) {
354
                $parsedParams = [];
355
356
                foreach ($value as $index => $entry) {
357
                    $parsedParams[] = [
358
                        $key.'['.$index.']'.'.key' => $entry['key'],
359
                        $key.'['.$index.']'.'.value' => $entry['value'],
360
                    ];
361
                }
362
363
                $value = $parsedParams;
364
            }
365
        });
366
367
        // Next we flatten the params array and prepare the query params. We need
368
        // to encode the values, but we can't encode the keys. This would otherwise
369
        // compromise the signature. Therefore we can't use http_build_query().
370
        $queryParams = $this->flattenParams($params);
371
        array_walk($queryParams, function (&$value, $key) {
372
            $value = $key.'='.rawurlencode($value);
373
        });
374
375
        return implode('&', $queryParams);
376
    }
377
378
    /**
379
     * Flatten query params.
380
     *
381
     * @param  array $params
382
     * @return array
383
     */
384
    protected static function flattenParams(array $params) : array
385
    {
386
        $result = [];
387
388
        foreach ($params as $key => $value) {
389
            if (!is_array($value)) {
390
                $result[$key] = $value;
391
            } else {
392
                $result = array_merge($result, static::flattenParams($value));
393
            }
394
        }
395
396
        return $result;
397
    }
398
399
    /**
400
     * Checks a provider response for errors.
401
     *
402
     * @param  ResponseInterface $response
403
     * @param  array|string      $data
404
     * @return void
405
     * @throws ClientException
406
     */
407
    protected function checkResponse(ResponseInterface $response, $data) : void
408
    {
409
        // Cloudstack returns multidimensional responses, keyed with the
410
        // command name. To handle errors in a generic way we need to 'reset'
411
        // the data array. To prevent strings from breaking this we ensure we
412
        // have an array to begin with.
413
        $data = is_array($data) ? $data : [$data];
414
415
        if (isset(reset($data)[$this->responseError])) {
416
            $error = reset($data)[$this->responseError];
417
            $code  = $this->responseCode ? reset($data)[$this->responseCode] : 0;
418
419
            throw new ClientException($error, $code, $data);
420
        }
421
    }
422
423
    /**
424
     * Enable SSO key signing for the next request.
425
     *
426
     * @param  boolean $enable
427
     * @return self
428
     */
429
    public function enableSso(bool $enable = true) : self
430
    {
431
        $this->ssoEnabled = $enable;
432
433
        return $this;
434
    }
435
    /**
436
     * Determine if SSO signing is enabled.
437
     *
438
     * @return boolean
439
     */
440
    public function isSsoEnabled() : bool
441
    {
442
        return $this->ssoEnabled;
443
    }
444
445
    /**
446
     * Get cache driver instance
447
     * @return FilesystemCache
448
     */
449 View Code Duplication
    private function cache() : FilesystemCache
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
450
    {
451
        if (! isset($this->cache)) {
452
            $this->cache = new FilesystemCache('', 0, __DIR__.'/../../cache');
453
        }
454
455
        return $this->cache;
456
    }
457
458
    /**
459
     * Handle dynamic method calls into the method.
460
     *
461
     * @param  string $method
462
     * @param  array  $parameters
463
     * @return mixed
464
     */
465
    public function __call(string $method, array $parameters)
466
    {
467
        array_unshift($parameters, $method);
468
469
        return call_user_func_array(array($this, 'command'), $parameters);
470
    }
471
}
472