Passed
Push — develop ( 018825...40fb98 )
by Stephen
15:55 queued 11:08
created

Client::buildOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 3
c 3
b 0
f 0
dl 0
loc 6
ccs 3
cts 3
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
1
<?php
2
3
namespace Spinen\ConnectWise\Api;
4
5
use GuzzleHttp\Client as Guzzle;
6
use GuzzleHttp\Exception\GuzzleException;
7
use GuzzleHttp\Exception\RequestException;
8
use GuzzleHttp\Psr7\Response;
9
use Illuminate\Support\Arr;
10
use Illuminate\Support\Collection as LaravelCollection;
11
use Illuminate\Support\Str;
12
use InvalidArgumentException;
13
use Psr\Http\Message\ResponseInterface;
14
use Spinen\ConnectWise\Exceptions\MalformedRequest;
15
use Spinen\ConnectWise\Support\Collection;
16
use Spinen\ConnectWise\Support\Model;
17
use Spinen\ConnectWise\Support\ModelResolver;
18
19
/**
20
 * Class Client
21
 *
22
 * @package Spinen\ConnectWise\Api
23
 *
24
 * @method LaravelCollection|Model delete(string $resource, array $options = [])
25
 * @method LaravelCollection|Model get(string $resource, array $options = [])
26
 * @method LaravelCollection|Model getAll(string $resource, array $options = [])
27
 * @method LaravelCollection|Model head(string $resource, array $options = [])
28
 * @method LaravelCollection|Model patch(string $resource, array $options = [])
29
 * @method LaravelCollection|Model post(string $resource, array $options = [])
30
 * @method LaravelCollection|Model put(string $resource, array $options = [])
31
 */
32
class Client
33
{
34
    /**
35
     * The Client Id
36
     *
37
     * @var string
38
     * @see https://developer.connectwise.com/ClientID
39
     */
40
    protected $clientId;
41
42
    /**
43
     * @var Guzzle
44
     */
45
    protected $guzzle;
46
47
    /**
48
     * Headers that need to be used with token
49
     *
50
     * @var array
51
     */
52
    protected $headers = [];
53
54
    /**
55
     * Integrator username to make global calls
56
     *
57
     * @var string
58
     */
59
    protected $integrator;
60
61
    /**
62
     * Current page
63
     *
64
     * @var int
65
     */
66
    protected $page;
67
68
    /**
69
     * Number of records to retrieve
70
     *
71
     * @var int
72
     */
73
    protected $page_size = 100;
74
75
    /**
76
     * Integration password for global calls
77
     *
78
     * @var string
79
     */
80
    protected $password;
81
82
    /**
83
     * Resolves a model for the uri
84
     *
85
     * @var ModelResolver
86
     */
87
    protected $resolver;
88
89
    /**
90
     * The supported versions
91
     *
92
     * @var array
93
     */
94
    public $supported = [
95
        '2018.4',
96
        '2018.5',
97
        '2018.6',
98
        '2019.1',
99
        '2019.2',
100
        '2019.3',
101
        '2019.4',
102
        '2019.5',
103
    ];
104
105
    /**
106
     * Public & private keys to log into CW
107
     *
108
     * @var Token
109
     */
110
    protected $token;
111
112
    /**
113
     * URL to the ConnectWise installation
114
     *
115
     * @var string
116
     */
117
    protected $url;
118
119
    /**
120
     * Supported verbs
121
     *
122
     * @var array
123
     */
124
    protected $verbs = [
125
        'delete',
126
        'get',
127
        'head',
128
        'put',
129
        'post',
130
        'patch',
131
    ];
132
133
    /**
134
     * Version of the API being requested
135
     *
136
     * @var string
137
     */
138
    protected $version;
139
140
    /**
141
     * Client constructor.
142
     *
143
     * @param Token $token
144
     * @param Guzzle $guzzle
145
     * @param ModelResolver $resolver
146
     * @param string $version Version of the models to use with the API responses
147
     */
148 23
    public function __construct(Token $token, Guzzle $guzzle, ModelResolver $resolver, $version = null)
149
    {
150 23
        $this->token = $token;
151 23
        $this->guzzle = $guzzle;
152 23
        $this->resolver = $resolver;
153 23
        $this->setVersion($version ?? Arr::last($this->supported));
154 23
    }
155
156
    /**
157
     * Magic method to allow shortcuts to the request types
158
     *
159
     * @param string $verb
160
     * @param array $args
161
     *
162
     * @return Collection|Model|Response
163
     * @throws GuzzleException
164
     * @throws MalformedRequest
165
     */
166 11
    public function __call($verb, $args)
167
    {
168 11
        if (count($args) < 1) {
169 1
            throw new InvalidArgumentException('Magic request methods require a resource and optional options array');
170
        }
171
172
        // For "getAll", set page to 1 & change verb to "get", otherwise, no page
173 10
        ($verb === 'getAll') ? $this->page = 1 && $verb = 'get' : $this->page = 0;
0 ignored issues
show
Documentation Bug introduced by
The property $page was declared of type integer, but 1 && $verb = 'get' is of type boolean. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
174
175 10
        if (!in_array($verb, $this->verbs)) {
176 1
            throw new InvalidArgumentException(sprintf("Unsupported verb [%s] was requested.", $verb));
177
        }
178
179 9
        return $this->request($verb, $this->trimResourceAsNeeded($args[0]), $args[1] ?? []);
180
    }
181
182
    /**
183
     * Adds key/value pair to the header to be sent
184
     *
185
     * @param array $header
186
     *
187
     * @return $this
188
     */
189 3
    public function addHeader(array $header)
190
    {
191 3
        foreach ($header as $key => $value) {
192 3
            $this->headers[$key] = $value;
193
        }
194
195 3
        return $this;
196
    }
197
198
    /**
199
     * Build authorization headers to send to CW API
200
     *
201
     * @return string
202
     */
203 14
    public function buildAuth()
204
    {
205 14
        if ($this->token->needsRefreshing()) {
206 1
            $this->token->refresh($this);
207
        }
208
209 14
        return 'Basic ' . base64_encode($this->token->getUsername() . ':' . $this->token->getPassword());
210
    }
211
212
    /**
213
     * Build the options to send to CW API
214
     *
215
     * We always need to login with Basic Auth, so add the "auth" option for Guzzle to use when logging in.
216
     * Additionally, pass any headers that have been set.
217
     *
218
     * @param array $options
219
     *
220
     * @return array
221
     */
222 10
    public function buildOptions(array $options = [])
223
    {
224 10
        return array_merge_recursive(
225
            $options,
226
            [
227 10
                'headers' => $this->getHeaders(),
228
            ]
229
        );
230
    }
231
232
    /**
233
     * Build the full path to the CW resource
234
     *
235
     * @param string $resource
236
     *
237
     * @return string
238
     * @throws MalformedRequest
239
     */
240 11
    public function buildUri($resource)
241
    {
242 11
        $uri = $this->getUrl($resource);
243
244
        // For getAll calls, make sure to add pageSize & page to request
245 11
        if ($this->page) {
246
            $uri .= (preg_match('/\\?/u', $uri) ? '&' : '?') . 'pageSize=' . $this->page_size . '&page=' . $this->page;
247
        }
248
249 11
        if (strlen($uri) > 2000) {
250 1
            throw new MalformedRequest(
251 1
                sprintf("The uri is too long. It is %s character(s) over the 2000 limit.", strlen($uri) - 2000)
252
            );
253
        }
254
255 11
        return $uri;
256
    }
257
258
    /**
259
     * Remove all set headers
260
     *
261
     * @return $this
262
     */
263 1
    public function emptyHeaders()
264
    {
265 1
        $this->setHeaders([]);
266
267 1
        return $this;
268
    }
269
270
    /**
271
     * Expose the client id
272
     *
273
     * @return string
274
     */
275 12
    public function getClientId()
276
    {
277 12
        return $this->clientId;
278
    }
279
280
    /**
281
     * The headers to send
282
     *
283
     * When making an integrator call (expired token), then you only have to send the "x-cw-usertype" header.
284
     *
285
     * @return array
286
     */
287 12
    public function getHeaders()
288
    {
289
        $authorization_headers = [
290 12
            'clientId'      => $this->getClientId(),
291 12
            'Authorization' => $this->buildAuth(),
292
        ];
293
294 12
        if ($this->token->isForUser($this->getIntegrator())) {
295 1
            return array_merge(
296
                [
297 1
                    'x-cw-usertype' => 'integrator',
298
                ],
299
                $authorization_headers
300
            );
301
        }
302
303 11
        return array_merge(
304
            [
305 11
                'x-cw-usertype' => 'member',
306 11
                'Accept'        => 'application/vnd.connectwise.com+json; version=' . $this->getVersion(),
307
            ],
308
            $authorization_headers,
309 11
            $this->headers
310
        );
311
    }
312
313
    /**
314
     * Expose the integrator username
315
     *
316
     * @return string
317
     */
318 13
    public function getIntegrator()
319
    {
320 13
        return $this->integrator;
321
    }
322
323
    /**
324
     * Expose the integrator password
325
     *
326
     * @return string
327
     */
328 1
    public function getPassword()
329
    {
330 1
        return $this->password;
331
    }
332
333
    /**
334
     * Expose the url
335
     *
336
     * @param string|null $path
337
     *
338
     * @return string
339
     */
340 11
    public function getUrl($path = null)
341
    {
342 11
        return $this->url . '/v4_6_release/apis/3.0/' . ltrim($path, '/');
343
    }
344
345
    /**
346
     * Expose the version
347
     *
348
     * @return string
349
     */
350 12
    public function getVersion()
351
    {
352 12
        return $this->version;
353
    }
354
355
    /**
356
     * Check to see if the array is a collection
357
     *
358
     * @param array $array
359
     *
360
     * @return bool
361
     */
362 8
    protected function isCollection(array $array)
363
    {
364
        // Keys of the array
365 8
        $keys = array_keys($array);
366
367
        // If the array keys of the keys match the keys, then the array must
368
        // not be associative (e.g. the keys array looked like {0:0, 1:1...}).
369 8
        return array_keys($keys) === $keys;
370
    }
371
372
    /**
373
     * Check to see it there are more pages in a paginated call
374
     *
375
     * For paginated calls, ConnectWise returns a "Link" property in the header with a single string.
376
     *
377
     * There appears to be 3 potential responses...
378
     *  1) null -- The number of items in the collection is < the pageSize
379
     *  2) string ends with rel="last" -- More pages to go
380
     *  3) string ends with rel="first" -- On the last page
381
     *
382
     * Here are some examples...
383
     *  <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=2>; rel="next", \
384
     *      <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=3>; rel="last”
385
     *
386
     *  <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=3>; rel="next", \
387
     *      <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=1>; rel="first”
388
     *
389
     * @param ResponseInterface $response
390
     *
391
     * @return boolean
392
     */
393
    protected function isLastPage(ResponseInterface $response)
394
    {
395
        return !(bool)preg_match('/rel="last"$/u', $response->getHeader('Link')[0] ?? null);
396
    }
397
398
    /**
399
     * Process the error received from ConnectWise
400
     *
401
     * @param RequestException $exception
402
     *
403
     * @throws RequestException
404
     */
405
    protected function processError(RequestException $exception)
406
    {
407
        // TODO: Figure out what to really do with an error...
408
        // Request is in here... Psr7\str($exception->getRequest()
409
        // If $exception->hasResponse(), then response is in here... Psr7\str($exception->getResponse()
410
411
        // NOTE: For now, just rethrow the original exception
412
        throw $exception;
413
    }
414
415
    /**
416
     * Parse the response for the given resource
417
     *
418
     * @param string $resource
419
     * @param ResponseInterface $response
420
     *
421
     * @return Collection|Model|ResponseInterface
422
     */
423 9
    protected function processResponse($resource, ResponseInterface $response)
424
    {
425 9
        $response = (array)json_decode($response->getBody(), true);
426
427
        // Nothing to map the response to, so just return it as-is
428 9
        if (!$model = $this->resolver->find($resource, $this->getVersion())) {
429 1
            return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response returns the type array which is incompatible with the documented return type Psr\Http\Message\Respons...nnectWise\Support\Model.
Loading history...
430
        }
431
432
        // Not a collection of records, so cast to a model
433 8
        if (!$this->isCollection($response)) {
434 1
            return new $model($response, $this);
435
        }
436
437
        // Have a collection of records, so cast them all as a Collection
438 7
        return new Collection(
439 7
            array_map(
440
                function ($item) use ($model) {
441 1
                    $item = new $model($item, $this);
442
443 1
                    return $item;
444 7
                },
445
                $response
446
            )
447
        );
448
    }
449
450
    /**
451
     * Make call to the resource
452
     *
453
     * @param string $method
454
     * @param string $resource
455
     * @param array|null $options
456
     *
457
     * @return LaravelCollection|Model|Response
458
     * @throws GuzzleException
459
     * @throws MalformedRequest
460
     */
461 9
    protected function request($method, $resource, array $options = [])
462
    {
463
        try {
464 9
            $response = $this->guzzle->request($method, $this->buildUri($resource), $this->buildOptions($options));
465
466 9
            $processed = $this->processResponse($resource, $response);
467
468
            // If, not a "gatAll" call, then return the response
469 9
            if (!$this->page) {
470 9
                return $processed;
471
            }
472
473
            // Get all of the other records
474
            while (!$this->isLastPage($response)) {
475
                $this->page = $this->page + 1;
476
477
                // Make next call
478
                $response = $this->guzzle->request($method, $this->buildUri($resource), $this->buildOptions($options));
479
480
                $processed = $processed->merge($this->processResponse($resource, $response));
481
            }
482
483
            return $processed;
484
        } catch (RequestException $e) {
485
            $this->processError($e);
486
        }
487
    }
488
489
    /**
490
     * Set the Client Id
491
     *
492
     * @param string $clientId
493
     *
494
     * @return $this
495
     */
496 23
    public function setClientId($clientId)
497
    {
498 23
        $this->clientId = $clientId;
499
500 23
        return $this;
501
    }
502
503
    /**
504
     * Set the headers
505
     *
506
     * There is an "addHeader" method to push a single header onto the stack. Otherwise, this replaces the headers.
507
     *
508
     * @param array $headers
509
     *
510
     * @return $this
511
     */
512 1
    public function setHeaders(array $headers)
513
    {
514 1
        $this->headers = $headers;
515
516 1
        return $this;
517
    }
518
519
    /**
520
     * Set the integrator username
521
     *
522
     * @param string $integrator
523
     *
524
     * @return $this
525
     */
526 5
    public function setIntegrator($integrator)
527
    {
528 5
        $this->integrator = $integrator;
529
530 5
        return $this;
531
    }
532
533
    /**
534
     * Set the integrator password
535
     *
536
     * @param string $password
537
     *
538
     * @return $this
539
     */
540 2
    public function setPassword($password)
541
    {
542 2
        $this->password = $password;
543
544 2
        return $this;
545
    }
546
547
    /**
548
     * Set the page size
549
     *
550
     * @param integer $page_size
551
     *
552
     * @return $this
553
     */
554
    public function setPageSize($page_size)
555
    {
556
        $this->page_size = $page_size;
557
558
        return $this;
559
    }
560
561
    /**
562
     * Set the URL to ConnectWise
563
     *
564
     * @param string $url
565
     *
566
     * @return $this
567
     */
568 3
    public function setUrl($url)
569
    {
570 3
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
571 1
            throw new InvalidArgumentException(sprintf("The URL provided[%s] is not a valid format.", $url));
572
        }
573
574 2
        $this->url = rtrim($url, '/');
575
576 2
        return $this;
577
    }
578
579
    /**
580
     * Set the version of the API response models
581
     *
582
     * @param string $version
583
     *
584
     * @return $this
585
     */
586 23
    public function setVersion($version)
587
    {
588 23
        if (!in_array($version, $this->supported)) {
589 1
            throw new InvalidArgumentException(sprintf("The Version provided[%s] is not supported.", $version));
590
        }
591
592 23
        $this->version = $version;
593
594 23
        return $this;
595
    }
596
597
    /**
598
     * Make the resource being requested be relative without the leading slash
599
     *
600
     * @param string $resource
601
     *
602
     * @return string
603
     */
604 9
    protected function trimResourceAsNeeded($resource)
605
    {
606 9
        return ltrim(Str::replaceFirst($this->getUrl(), '', $resource), '/');
607
    }
608
}
609