Completed
Push — master ( 6007cb...b4b93c )
by Kevin
64:59 queued 36:15
created

Client::isCommandValid()   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 InvalidArgumentException;
8
use PCextreme\Cloudstack\Exception\ClientException;
9
use PCextreme\Cloudstack\Util\UrlHelpersTrait;
10
use Psr\Http\Message\ResponseInterface;
11
use RuntimeException;
12
13
class Client extends AbstractClient
14
{
15
    use UrlHelpersTrait;
16
17
    /**
18
     * @var array
19
     */
20
    protected $apiList;
21
22
    /**
23
     * @var string
24
     */
25
    private $urlApi;
26
27
    /**
28
     * @var string
29
     */
30
    private $urlClient;
31
32
    /**
33
     * @var string
34
     */
35
    private $urlConsole;
36
37
    /**
38
     * @var string
39
     */
40
    protected $apiKey;
41
42
    /**
43
     * @var string
44
     */
45
    protected $secretKey;
46
47
    /**
48
     * @var string
49
     */
50
    protected $ssoKey;
51
52
    /**
53
     * @var string
54
     */
55
    private $responseError = 'errortext';
56
57
    /**
58
     * @var string
59
     */
60
    private $responseCode = 'errorcode';
61
62
    /**
63
     * @var boolean
64
     */
65
    private $ssoEnabled = false;
66
67
    /**
68
     * Constructs a new Cloudstack client instance.
69
     *
70
     * @param  array $options
71
     *     An array of options to set on this client. Options include
72
     *     'apiList', 'urlApi', 'urlClient', 'urlConsole', 'apiKey',
73
     *     'secretKey', 'responseError' and 'responseCode'.
74
     * @param  array $collaborators
75
     *     An array of collaborators that may be used to override
76
     *     this provider's default behavior. Collaborators include
77
     *     `requestFactory` and `httpClient`.
78
     */
79
    public function __construct(array $options = [], array $collaborators = [])
80
    {
81
        $this->assertRequiredOptions($options);
82
83
        $possible   = $this->getConfigurableOptions();
84
        $configured = array_intersect_key($options, array_flip($possible));
85
86
        foreach ($configured as $key => $value) {
87
            $this->$key = $value;
88
        }
89
90
        // Remove all options that are only used locally
91
        $options = array_diff_key($options, $configured);
92
93
        parent::__construct($options, $collaborators);
94
    }
95
96
    /**
97
     * Returns all options that can be configured.
98
     *
99
     * @return array
100
     */
101
    protected function getConfigurableOptions() : array
102
    {
103
        return array_merge($this->getRequiredOptions(), [
104
            'apiList',
105
            'urlClient',
106
            'urlConsole',
107
            'ssoKey',
108
            'responseError',
109
            'responseCode',
110
        ]);
111
    }
112
113
    /**
114
     * Returns all options that are required.
115
     *
116
     * @return array
117
     */
118
    protected function getRequiredOptions() : array
119
    {
120
        return [
121
            'urlApi',
122
            'apiKey',
123
            'secretKey',
124
        ];
125
    }
126
127
    /**
128
     * Verifies that all required options have been passed.
129
     *
130
     * @param  array $options
131
     * @return void
132
     * @throws InvalidArgumentException
133
     */
134
    private function assertRequiredOptions(array $options) : void
135
    {
136
        $missing = array_diff_key(array_flip($this->getRequiredOptions()), $options);
137
138
        if (!empty($missing)) {
139
            throw new InvalidArgumentException(
140
                'Required options not defined: '.implode(', ', array_keys($missing))
141
            );
142
        }
143
    }
144
145
    /**
146
     * Execute command.
147
     *
148
     * @param  string $command
149
     * @param  array  $options
150
     * @return mixed
151
     */
152
    public function command(string $command, array $options = [])
153
    {
154
        $this->assertRequiredCommandOptions($command, $options);
155
156
        $method  = $this->getCommandMethod($command);
157
        $url     = $this->getCommandUrl($command, $options);
158
        $request = $this->getRequest($method, $url, $options);
159
160
        return $this->getResponse($request);
161
    }
162
163
    /**
164
     * Verifies that all required options have been passed.
165
     *
166
     * @param string $command
167
     * @param  array  $options
168
     * @return void
169
     * @throws RuntimeException
170
     * @throws InvalidArgumentException
171
     */
172
    private function assertRequiredCommandOptions(string $command, array $options = []) : void
173
    {
174
        if (! $this->isCommandValid($command)) {
175
            throw new RuntimeException(
176
                "Call to unsupported API command [{$command}], this call is not present in the API list."
177
            );
178
        }
179
180
        $requiredParameters = $this->getRequiredCommandParameters($command);
181
        $providedParameters = array_keys($options);
182
183
        $missing = array_diff($requiredParameters, $providedParameters);
184
185
        if (! empty($missing)) {
186
            $missing = implode(', ', $missing);
187
188
            throw new InvalidArgumentException(
189
                "Missing arguments [{$missing}] for command [{$command}]."
190
            );
191
        }
192
    }
193
194
    /**
195
     * Check if command is supported
196
     * @param  string $command
197
     * @return boolean
198
     */
199
    protected function isCommandValid(string $command)
200
    {
201
        return array_key_exists($command, $this->getApiList());
202
    }
203
204
    /**
205
     * Get required parameter names
206
     * @param  string $command
207
     * @return array
208
     */
209
    protected function getRequiredCommandParameters(string $command)
210
    {
211
        $commands = $this->getApiList();
212
        $parameters = $commands[$command]['params'];
213
214
        $required = array_filter($parameters, function ($rules) {
215
            return (bool) $rules['required'];
216
        });
217
218
        return array_keys($required);
219
    }
220
221
    /**
222
     * Returns command method based on the command.
223
     *
224
     * @param  string $command
225
     * @return string
226
     */
227
    public function getCommandMethod(string $command) : string
228
    {
229
        if (in_array($command, ['login', 'deployVirtualMachine'])) {
230
            return self::METHOD_POST;
231
        }
232
233
        return self::METHOD_GET;
234
    }
235
236
    /**
237
     * Builds the command URL's query string.
238
     *
239
     * @param  array $params
240
     * @return string
241
     */
242
    public function getCommandQuery(array $params) : string
243
    {
244
        return $this->signCommandParameters($params);
245
    }
246
247
    /**
248
     * Builds the authorization URL.
249
     *
250
     * @param  string $command
251
     * @param  array  $options
252
     * @return string
253
     */
254
    public function getCommandUrl(string $command, array $options = []) : string
255
    {
256
        $base   = $this->urlApi;
257
        $params = $this->getCommandParameters($command, $options);
258
        $query  = $this->getCommandQuery($params);
259
260
        return $this->appendQuery($base, $query);
261
    }
262
263
    /**
264
     * Returns command parameters based on provided options.
265
     *
266
     * @param  string $command
267
     * @param  array  $options
268
     * @return array
269
     */
270
    protected function getCommandParameters(string $command, array $options) : array
271
    {
272
        return array_merge($options, [
273
            'command'  => $command,
274
            'response' => 'json',
275
            'apikey'   => $this->apiKey,
276
        ]);
277
    }
278
279
    /**
280
     * Signs the command parameters.
281
     *
282
     * @param  array $params
283
     * @return string
284
     */
285
    protected function signCommandParameters(array $params = []) : string
286
    {
287
        if ($this->isSsoEnabled() && is_null($this->ssoKey)) {
288
            throw new InvalidArgumentException(
289
                'Required options not defined: ssoKey'
290
            );
291
        }
292
293
        ksort($params);
294
295
        $query = $this->buildQueryString($params);
296
297
        $key = $this->isSsoEnabled() ? $this->ssoKey : $this->secretKey;
298
        $signature = rawurlencode(base64_encode(hash_hmac(
299
            'SHA1',
300
            strtolower($query),
301
            $key,
302
            true
303
        )));
304
305
        // Reset SSO signing for the next request.
306
        $this->ssoEnabled = false;
307
308
        // To prevent the signature from being escaped we simply append
309
        // the signature to the previously build query.
310
        return $query.'&signature='.$signature;
311
    }
312
313
    /**
314
     * Get Cloudstack Client API list.
315
     *
316
     * Tries to load the API list from the cache directory when
317
     * the 'apiList' on the class is empty.
318
     *
319
     * @return array
320
     * @throws RuntimeException
321
     */
322
    public function getApiList() : array
323
    {
324
        if (is_null($this->apiList)) {
325
            $path = __DIR__.'/../cache/api_list.php';
326
327
            if (!file_exists($path)) {
328
                throw new RuntimeException(
329
                    "Cloudstack Client API list not found. This file needs to be generated before using the client."
330
                );
331
            }
332
333
            $this->apiList = require $path;
334
        }
335
336
        return $this->apiList;
337
    }
338
339
    /**
340
     * Set Cloudstack Client API list.
341
     *
342
     * @param  array $apiList
343
     * @return void
344
     */
345
    public function setApiList(array $apiList) : void
346
    {
347
        $this->apiList = $apiList;
348
    }
349
350
    /**
351
     * Appends a query string to a URL.
352
     *
353
     * @param  string $url
354
     * @param  string $query
355
     * @return string
356
     */
357
    protected function appendQuery(string $url, string $query) : string
358
    {
359
        $query = trim($query, '?&');
360
361
        if ($query) {
362
            return $url.'?'.$query;
363
        }
364
365
        return $url;
366
    }
367
368
    /**
369
     * Build a query string from an array.
370
     *
371
     * @param  array $params
372
     * @return string
373
     */
374
    protected function buildQueryString(array $params) : string
375
    {
376
        // We need to modify the nested array keys to get them accepted by Cloudstack.
377
        // For example 'details[0][key]' should resolve to 'details[0].key'.
378
        array_walk($params, function (&$value, $key) {
379
            if (is_array($value)) {
380
                $parsedParams = [];
381
382
                foreach ($value as $index => $entry) {
383
                    $parsedParams[] = [
384
                        $key.'['.$index.']'.'.key' => $entry['key'],
385
                        $key.'['.$index.']'.'.value' => $entry['value'],
386
                    ];
387
                }
388
389
                $value = $parsedParams;
390
            }
391
        });
392
393
        // Next we flatten the params array and prepare the query params. We need
394
        // to encode the values, but we can't encode the keys. This would otherwise
395
        // compromise the signature. Therefore we can't use http_build_query().
396
        $queryParams = $this->flattenParams($params);
397
        array_walk($queryParams, function (&$value, $key) {
398
            $value = $key.'='.rawurlencode($value);
399
        });
400
401
        return implode('&', $queryParams);
402
    }
403
404
    /**
405
     * Flatten query params.
406
     *
407
     * @param  array $params
408
     * @return array
409
     */
410
    protected static function flattenParams(array $params) : array
411
    {
412
        $result = [];
413
414
        foreach ($params as $key => $value) {
415
            if (!is_array($value)) {
416
                $result[$key] = $value;
417
            } else {
418
                $result = array_merge($result, static::flattenParams($value));
419
            }
420
        }
421
422
        return $result;
423
    }
424
425
    /**
426
     * Checks a provider response for errors.
427
     *
428
     * @param  ResponseInterface $response
429
     * @param  array|string      $data
430
     * @return void
431
     * @throws ClientException
432
     */
433
    protected function checkResponse(ResponseInterface $response, $data) : void
434
    {
435
        // Cloudstack returns multidimensional responses, keyed with the
436
        // command name. To handle errors in a generic way we need to 'reset'
437
        // the data array. To prevent strings from breaking this we ensure we
438
        // have an array to begin with.
439
        $data = is_array($data) ? $data : [$data];
440
441
        if (isset(reset($data)[$this->responseError])) {
442
            $error = reset($data)[$this->responseError];
443
            $code  = $this->responseCode ? reset($data)[$this->responseCode] : 0;
444
445
            throw new ClientException($error, $code, $data);
446
        }
447
    }
448
449
    /**
450
     * Enable SSO key signing for the next request.
451
     *
452
     * @param  boolean $enable
453
     * @return self
454
     */
455
    public function enableSso(bool $enable = true) : self
456
    {
457
        $this->ssoEnabled = $enable;
458
459
        return $this;
460
    }
461
    /**
462
     * Determine if SSO signing is enabled.
463
     *
464
     * @return boolean
465
     */
466
    public function isSsoEnabled() : bool
467
    {
468
        return $this->ssoEnabled;
469
    }
470
471
    /**
472
     * Handle dynamic method calls into the method.
473
     *
474
     * @param  mixed $method
475
     * @param  array $parameters
476
     * @return mixed
477
     */
478
    public function __call($method, array $parameters)
479
    {
480
        array_unshift($parameters, $method);
481
482
        return call_user_func_array(array($this, 'command'), $parameters);
483
    }
484
}
485