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