Completed
Push — master ( b769ac...060a09 )
by Kevin
230:43 queued 167:38
created

Client::cache()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 8
Ratio 100 %

Importance

Changes 0
Metric Value
dl 8
loc 8
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 0
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
        if (! $this->isCommandValid($command)) {
181
            throw new RuntimeException(
182
                "Call to unsupported API command [{$command}], this call is not present in the API list."
183
            );
184
        }
185
186
        $requiredParameters = $this->getRequiredCommandParameters($command);
187
        $providedParameters = array_keys($options);
188
189
        $missing = array_diff($requiredParameters, $providedParameters);
190
191
        if (! empty($missing)) {
192
            $missing = implode(', ', $missing);
193
194
            throw new InvalidArgumentException(
195
                "Missing arguments [{$missing}] for command [{$command}]."
196
            );
197
        }
198
    }
199
200
    /**
201
     * Check if command is supported
202
     * @param  string $command
203
     * @return boolean
204
     */
205
    protected function isCommandValid(string $command)
206
    {
207
        return array_key_exists($command, $this->getApiList());
208
    }
209
210
    /**
211
     * Get required parameter names
212
     * @param  string $command
213
     * @return array
214
     */
215
    protected function getRequiredCommandParameters(string $command)
216
    {
217
        $commands = $this->getApiList();
218
        $parameters = $commands[$command]['params'];
219
220
        $required = array_filter($parameters, function ($rules) {
221
            return (bool) $rules['required'];
222
        });
223
224
        return array_keys($required);
225
    }
226
227
    /**
228
     * Returns command method based on the command.
229
     *
230
     * @param  string $command
231
     * @return string
232
     */
233
    public function getCommandMethod(string $command) : string
234
    {
235
        if (in_array($command, ['login', 'deployVirtualMachine'])) {
236
            return self::METHOD_POST;
237
        }
238
239
        return self::METHOD_GET;
240
    }
241
242
    /**
243
     * Builds the command URL's query string.
244
     *
245
     * @param  array $params
246
     * @return string
247
     */
248
    public function getCommandQuery(array $params) : string
249
    {
250
        return $this->signCommandParameters($params);
251
    }
252
253
    /**
254
     * Builds the authorization URL.
255
     *
256
     * @param  string $command
257
     * @param  array  $options
258
     * @return string
259
     */
260
    public function getCommandUrl(string $command, array $options = []) : string
261
    {
262
        $base   = $this->urlApi;
263
        $params = $this->getCommandParameters($command, $options);
264
        $query  = $this->getCommandQuery($params);
265
266
        return $this->appendQuery($base, $query);
267
    }
268
269
    /**
270
     * Returns command parameters based on provided options.
271
     *
272
     * @param  string $command
273
     * @param  array  $options
274
     * @return array
275
     */
276
    protected function getCommandParameters(string $command, array $options) : array
277
    {
278
        return array_merge($options, [
279
            'command'  => $command,
280
            'response' => 'json',
281
            'apikey'   => $this->apiKey,
282
        ]);
283
    }
284
285
    /**
286
     * Signs the command parameters.
287
     *
288
     * @param  array $params
289
     * @return string
290
     */
291
    protected function signCommandParameters(array $params = []) : string
292
    {
293
        if ($this->isSsoEnabled() && is_null($this->ssoKey)) {
294
            throw new InvalidArgumentException(
295
                'Required options not defined: ssoKey'
296
            );
297
        }
298
299
        ksort($params);
300
301
        $query = $this->buildQueryString($params);
302
303
        $key = $this->isSsoEnabled() ? $this->ssoKey : $this->secretKey;
304
        $signature = rawurlencode(base64_encode(hash_hmac(
305
            'SHA1',
306
            strtolower($query),
307
            $key,
308
            true
309
        )));
310
311
        // Reset SSO signing for the next request.
312
        $this->ssoEnabled = false;
313
314
        // To prevent the signature from being escaped we simply append
315
        // the signature to the previously build query.
316
        return $query.'&signature='.$signature;
317
    }
318
319
    /**
320
     * Get Cloudstack Client API list.
321
     *
322
     * Tries to load the API list from the cache directory when
323
     * the 'apiList' on the class is empty.
324
     *
325
     * @return array
326
     * @throws RuntimeException
327
     */
328
    public function getApiList() : array
329
    {
330
        if (is_null($this->apiList)) {
331
            if (! $this->cache()->has('api.list')) {
332
                throw new RuntimeException(
333
                    "Cloudstack Client API list not found. This file needs to be generated before using the client."
334
                );
335
            }
336
337
            $this->apiList = $this->cache()->get('api.list');
338
        }
339
340
        return $this->apiList;
341
    }
342
343
    /**
344
     * Set Cloudstack Client API list.
345
     *
346
     * @param  array $apiList
347
     * @return void
348
     */
349
    public function setApiList(array $apiList) : void
350
    {
351
        $this->apiList = $apiList;
352
    }
353
354
    /**
355
     * Appends a query string to a URL.
356
     *
357
     * @param  string $url
358
     * @param  string $query
359
     * @return string
360
     */
361
    protected function appendQuery(string $url, string $query) : string
362
    {
363
        $query = trim($query, '?&');
364
365
        if ($query) {
366
            return $url.'?'.$query;
367
        }
368
369
        return $url;
370
    }
371
372
    /**
373
     * Build a query string from an array.
374
     *
375
     * @param  array $params
376
     * @return string
377
     */
378
    protected function buildQueryString(array $params) : string
379
    {
380
        // We need to modify the nested array keys to get them accepted by Cloudstack.
381
        // For example 'details[0][key]' should resolve to 'details[0].key'.
382
        array_walk($params, function (&$value, $key) {
383
            if (is_array($value)) {
384
                $parsedParams = [];
385
386
                foreach ($value as $index => $entry) {
387
                    $parsedParams[] = [
388
                        $key.'['.$index.']'.'.key' => $entry['key'],
389
                        $key.'['.$index.']'.'.value' => $entry['value'],
390
                    ];
391
                }
392
393
                $value = $parsedParams;
394
            }
395
        });
396
397
        // Next we flatten the params array and prepare the query params. We need
398
        // to encode the values, but we can't encode the keys. This would otherwise
399
        // compromise the signature. Therefore we can't use http_build_query().
400
        $queryParams = $this->flattenParams($params);
401
        array_walk($queryParams, function (&$value, $key) {
402
            $value = $key.'='.rawurlencode($value);
403
        });
404
405
        return implode('&', $queryParams);
406
    }
407
408
    /**
409
     * Flatten query params.
410
     *
411
     * @param  array $params
412
     * @return array
413
     */
414
    protected static function flattenParams(array $params) : array
415
    {
416
        $result = [];
417
418
        foreach ($params as $key => $value) {
419
            if (!is_array($value)) {
420
                $result[$key] = $value;
421
            } else {
422
                $result = array_merge($result, static::flattenParams($value));
423
            }
424
        }
425
426
        return $result;
427
    }
428
429
    /**
430
     * Checks a provider response for errors.
431
     *
432
     * @param  ResponseInterface $response
433
     * @param  array|string      $data
434
     * @return void
435
     * @throws ClientException
436
     */
437
    protected function checkResponse(ResponseInterface $response, $data) : void
438
    {
439
        // Cloudstack returns multidimensional responses, keyed with the
440
        // command name. To handle errors in a generic way we need to 'reset'
441
        // the data array. To prevent strings from breaking this we ensure we
442
        // have an array to begin with.
443
        $data = is_array($data) ? $data : [$data];
444
445
        if (isset(reset($data)[$this->responseError])) {
446
            $error = reset($data)[$this->responseError];
447
            $code  = $this->responseCode ? reset($data)[$this->responseCode] : 0;
448
449
            throw new ClientException($error, $code, $data);
450
        }
451
    }
452
453
    /**
454
     * Enable SSO key signing for the next request.
455
     *
456
     * @param  boolean $enable
457
     * @return self
458
     */
459
    public function enableSso(bool $enable = true) : self
460
    {
461
        $this->ssoEnabled = $enable;
462
463
        return $this;
464
    }
465
    /**
466
     * Determine if SSO signing is enabled.
467
     *
468
     * @return boolean
469
     */
470
    public function isSsoEnabled() : bool
471
    {
472
        return $this->ssoEnabled;
473
    }
474
475
    /**
476
     * Get cache driver instance
477
     * @return FilesystemCache
478
     */
479 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...
480
    {
481
        if (! isset($this->cache)) {
482
            $this->cache = new FilesystemCache('', 0, __DIR__.'/../../cache');
483
        }
484
485
        return $this->cache;
486
    }
487
488
    /**
489
     * Handle dynamic method calls into the method.
490
     *
491
     * @param  mixed $method
492
     * @param  array $parameters
493
     * @return mixed
494
     */
495
    public function __call($method, array $parameters)
496
    {
497
        array_unshift($parameters, $method);
498
499
        return call_user_func_array(array($this, 'command'), $parameters);
500
    }
501
}
502