Client   B
last analyzed

Complexity

Total Complexity 43

Size/Duplication

Total Lines 578
Duplicated Lines 0 %

Test Coverage

Coverage 90.43%

Importance

Changes 18
Bugs 1 Features 0
Metric Value
wmc 43
eloc 121
c 18
b 1
f 0
dl 0
loc 578
ccs 104
cts 115
cp 0.9043
rs 8.96

26 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 6 1
A getUrl() 0 3 1
A getIntegrator() 0 3 1
A setClientId() 0 5 1
A setHeaders() 0 5 1
A getPassword() 0 3 1
A buildOptions() 0 6 1
A processResponse() 0 23 3
A trimResourceAsNeeded() 0 3 1
A getClientId() 0 3 1
A setIntegrator() 0 5 1
A getVersion() 0 3 1
A emptyHeaders() 0 5 1
A setPassword() 0 5 1
A setVersion() 0 9 2
A __call() 0 17 4
A buildUri() 0 16 4
A addHeader() 0 7 2
A request() 0 25 4
A buildAuth() 0 7 2
A getHeaders() 0 23 2
A isLastPage() 0 3 1
A setPageSize() 0 5 2
A processError() 0 8 1
A isCollection() 0 8 1
A setUrl() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like Client often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Client, and based on these observations, apply Extract Interface, too.

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|null $version Version of the models to use with the API responses
147
     */
148 24
    public function __construct(Token $token, Guzzle $guzzle, ModelResolver $resolver, $version = null)
149
    {
150 24
        $this->token = $token;
151 24
        $this->guzzle = $guzzle;
152 24
        $this->resolver = $resolver;
153 24
        $this->setVersion($version ?? Arr::last($this->supported));
154 24
    }
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 12
    public function __call($verb, $args)
167
    {
168 12
        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 11
        if ($verb === 'getAll') {
174 1
            $this->page = 1;
175 1
            $verb = 'get';
176
        }
177
178 11
        if (!in_array($verb, $this->verbs)) {
179 1
            throw new InvalidArgumentException(sprintf("Unsupported verb [%s] was requested.", $verb));
180
        }
181
182 10
        return $this->request($verb, $this->trimResourceAsNeeded($args[0]), $args[1] ?? []);
183
    }
184
185
    /**
186
     * Adds key/value pair to the header to be sent
187
     *
188
     * @param array $header
189
     *
190
     * @return $this
191
     */
192 3
    public function addHeader(array $header)
193
    {
194 3
        foreach ($header as $key => $value) {
195 3
            $this->headers[$key] = $value;
196
        }
197
198 3
        return $this;
199
    }
200
201
    /**
202
     * Build authorization headers to send to CW API
203
     *
204
     * @return string
205
     */
206 15
    public function buildAuth()
207
    {
208 15
        if ($this->token->needsRefreshing()) {
209 1
            $this->token->refresh($this);
210
        }
211
212 15
        return 'Basic ' . base64_encode($this->token->getUsername() . ':' . $this->token->getPassword());
213
    }
214
215
    /**
216
     * Build the options to send to CW API
217
     *
218
     * We always need to login with Basic Auth, so add the "auth" option for Guzzle to use when logging in.
219
     * Additionally, pass any headers that have been set.
220
     *
221
     * @param array $options
222
     *
223
     * @return array
224
     */
225 11
    public function buildOptions(array $options = [])
226
    {
227 11
        return array_merge_recursive(
228
            $options,
229
            [
230 11
                'headers' => $this->getHeaders(),
231
            ]
232
        );
233
    }
234
235
    /**
236
     * Build the full path to the CW resource
237
     *
238
     * @param string $resource
239
     *
240
     * @return string
241
     * @throws MalformedRequest
242
     */
243 12
    public function buildUri($resource)
244
    {
245 12
        $uri = $this->getUrl($resource);
246
247
        // For getAll calls, make sure to add pageSize & page to request
248 12
        if ($this->page) {
249 1
            $uri .= (preg_match('/\\?/u', $uri) ? '&' : '?') . 'pageSize=' . $this->page_size . '&page=' . $this->page;
250
        }
251
252 12
        if (strlen($uri) > 2000) {
253 1
            throw new MalformedRequest(
254 1
                sprintf("The uri is too long. It is %s character(s) over the 2000 limit.", strlen($uri) - 2000)
255
            );
256
        }
257
258 12
        return $uri;
259
    }
260
261
    /**
262
     * Remove all set headers
263
     *
264
     * @return $this
265
     */
266 1
    public function emptyHeaders()
267
    {
268 1
        $this->setHeaders([]);
269
270 1
        return $this;
271
    }
272
273
    /**
274
     * Expose the client id
275
     *
276
     * @return string
277
     */
278 13
    public function getClientId()
279
    {
280 13
        return $this->clientId;
281
    }
282
283
    /**
284
     * The headers to send
285
     *
286
     * When making an integrator call (expired token), then you only have to send the "x-cw-usertype" header.
287
     *
288
     * @return array
289
     */
290 13
    public function getHeaders()
291
    {
292
        $authorization_headers = [
293 13
            'clientId'      => $this->getClientId(),
294 13
            'Authorization' => $this->buildAuth(),
295
        ];
296
297 13
        if ($this->token->isForUser($this->getIntegrator())) {
298 1
            return array_merge(
299
                [
300 1
                    'x-cw-usertype' => 'integrator',
301
                ],
302
                $authorization_headers
303
            );
304
        }
305
306 12
        return array_merge(
307
            [
308 12
                'x-cw-usertype' => 'member',
309 12
                'Accept'        => 'application/vnd.connectwise.com+json; version=' . $this->getVersion(),
310
            ],
311
            $authorization_headers,
312 12
            $this->headers
313
        );
314
    }
315
316
    /**
317
     * Expose the integrator username
318
     *
319
     * @return string
320
     */
321 14
    public function getIntegrator()
322
    {
323 14
        return $this->integrator;
324
    }
325
326
    /**
327
     * Expose the integrator password
328
     *
329
     * @return string
330
     */
331 1
    public function getPassword()
332
    {
333 1
        return $this->password;
334
    }
335
336
    /**
337
     * Expose the url
338
     *
339
     * @param string|null $path
340
     *
341
     * @return string
342
     */
343 12
    public function getUrl($path = null)
344
    {
345 12
        return $this->url . '/v4_6_release/apis/3.0/' . ltrim($path, '/');
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $string of ltrim() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

345
        return $this->url . '/v4_6_release/apis/3.0/' . ltrim(/** @scrutinizer ignore-type */ $path, '/');
Loading history...
346
    }
347
348
    /**
349
     * Expose the version
350
     *
351
     * @return string
352
     */
353 13
    public function getVersion()
354
    {
355 13
        return $this->version;
356
    }
357
358
    /**
359
     * Check to see if the array is a collection
360
     *
361
     * @param array $array
362
     *
363
     * @return bool
364
     */
365 9
    protected function isCollection(array $array)
366
    {
367
        // Keys of the array
368 9
        $keys = array_keys($array);
369
370
        // If the array keys of the keys match the keys, then the array must
371
        // not be associative (e.g. the keys array looked like {0:0, 1:1...}).
372 9
        return array_keys($keys) === $keys;
373
    }
374
375
    /**
376
     * Check to see it there are more pages in a paginated call
377
     *
378
     * For paginated calls, ConnectWise returns a "Link" property in the header with a single string.
379
     *
380
     * There appears to be 3 potential responses...
381
     *  1) null -- The number of items in the collection is < the pageSize
382
     *  2) string ends with rel="last" -- More pages to go
383
     *  3) string ends with rel="first" -- On the last page
384
     *
385
     * Here are some examples...
386
     *  <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=2>; rel="next", \
387
     *      <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=3>; rel="last"
388
     *
389
     *  <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=3>; rel="next", \
390
     *      <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=1>; rel="first"
391
     *
392
     * @param ResponseInterface $response
393
     *
394
     * @return boolean
395
     */
396 1
    protected function isLastPage(ResponseInterface $response)
397
    {
398 1
        return !(bool)preg_match('/rel="last"$/u', $response->getHeader('Link')[0] ?? null);
0 ignored issues
show
Bug introduced by
It seems like $response->getHeader('Link')[0] ?? null can also be of type null; however, parameter $subject of preg_match() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

398
        return !(bool)preg_match('/rel="last"$/u', /** @scrutinizer ignore-type */ $response->getHeader('Link')[0] ?? null);
Loading history...
399
    }
400
401
    /**
402
     * Process the error received from ConnectWise
403
     *
404
     * @param RequestException $exception
405
     *
406
     * @throws RequestException
407
     */
408
    protected function processError(RequestException $exception)
409
    {
410
        // TODO: Figure out what to really do with an error...
411
        // Request is in here... Psr7\str($exception->getRequest()
412
        // If $exception->hasResponse(), then response is in here... Psr7\str($exception->getResponse()
413
414
        // NOTE: For now, just rethrow the original exception
415
        throw $exception;
416
    }
417
418
    /**
419
     * Parse the response for the given resource
420
     *
421
     * @param string $resource
422
     * @param ResponseInterface $response
423
     *
424
     * @return Collection|Model|ResponseInterface|array
425
     */
426 10
    protected function processResponse($resource, ResponseInterface $response)
427
    {
428 10
        $response = (array)json_decode($response->getBody(), true);
429
430
        // Nothing to map the response to, so just return it as-is
431 10
        if (!$model = $this->resolver->find($resource, $this->getVersion())) {
432 1
            return $response;
433
        }
434
435
        // Not a collection of records, so cast to a model
436 9
        if (!$this->isCollection($response)) {
437 1
            return new $model($response, $this);
438
        }
439
440
        // Have a collection of records, so cast them all as a Collection
441 8
        return new Collection(
442 8
            array_map(
443
                function ($item) use ($model) {
444 1
                    $item = new $model($item, $this);
445
446 1
                    return $item;
447 8
                },
448
                $response
449
            )
450
        );
451
    }
452
453
    /**
454
     * Make call to the resource
455
     *
456
     * @param string $method
457
     * @param string $resource
458
     * @param array|null $options
459
     *
460
     * @return LaravelCollection|Model|Response
461
     * @throws GuzzleException
462
     * @throws MalformedRequest
463
     */
464 10
    protected function request($method, $resource, array $options = [])
465
    {
466
        try {
467 10
            $response = $this->guzzle->request($method, $this->buildUri($resource), $this->buildOptions($options));
468
469 10
            $processed = $this->processResponse($resource, $response);
470
471
            // If, not a "getAll" call, then return the response
472 10
            if (!$this->page) {
473 9
                return $processed;
474
            }
475
476
            // Get all of the other records
477 1
            while (!$this->isLastPage($response)) {
478
                $this->page = $this->page + 1;
479
480
                // Make next call
481
                $response = $this->guzzle->request($method, $this->buildUri($resource), $this->buildOptions($options));
482
483
                $processed = $processed->merge($this->processResponse($resource, $response));
484
            }
485
486 1
            return $processed;
487
        } catch (RequestException $e) {
488
            $this->processError($e);
489
        }
490
    }
491
492
    /**
493
     * Set the Client Id
494
     *
495
     * @param string $clientId
496
     *
497
     * @return $this
498
     */
499 24
    public function setClientId($clientId)
500
    {
501 24
        $this->clientId = $clientId;
502
503 24
        return $this;
504
    }
505
506
    /**
507
     * Set the headers
508
     *
509
     * There is an "addHeader" method to push a single header onto the stack. Otherwise, this replaces the headers.
510
     *
511
     * @param array $headers
512
     *
513
     * @return $this
514
     */
515 1
    public function setHeaders(array $headers)
516
    {
517 1
        $this->headers = $headers;
518
519 1
        return $this;
520
    }
521
522
    /**
523
     * Set the integrator username
524
     *
525
     * @param string $integrator
526
     *
527
     * @return $this
528
     */
529 5
    public function setIntegrator($integrator)
530
    {
531 5
        $this->integrator = $integrator;
532
533 5
        return $this;
534
    }
535
536
    /**
537
     * Set the integrator password
538
     *
539
     * @param string $password
540
     *
541
     * @return $this
542
     */
543 2
    public function setPassword($password)
544
    {
545 2
        $this->password = $password;
546
547 2
        return $this;
548
    }
549
550
    /**
551
     * Set the page size, not allowing < 0
552
     *
553
     * @param integer $page_size
554
     *
555
     * @return $this
556
     */
557
    public function setPageSize($page_size)
558
    {
559
        $this->page_size = $page_size >= 0 ? $page_size : 0;
560
561
        return $this;
562
    }
563
564
    /**
565
     * Set the URL to ConnectWise
566
     *
567
     * @param string $url
568
     *
569
     * @return $this
570
     */
571 3
    public function setUrl($url)
572
    {
573 3
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
574 1
            throw new InvalidArgumentException(sprintf("The URL provided[%s] is not a valid format.", $url));
575
        }
576
577 2
        $this->url = rtrim($url, '/');
578
579 2
        return $this;
580
    }
581
582
    /**
583
     * Set the version of the API response models
584
     *
585
     * @param string $version
586
     *
587
     * @return $this
588
     */
589 24
    public function setVersion($version)
590
    {
591 24
        if (!in_array($version, $this->supported)) {
592 1
            throw new InvalidArgumentException(sprintf("The Version provided[%s] is not supported.", $version));
593
        }
594
595 24
        $this->version = $version;
596
597 24
        return $this;
598
    }
599
600
    /**
601
     * Make the resource being requested be relative without the leading slash
602
     *
603
     * @param string $resource
604
     *
605
     * @return string
606
     */
607 10
    protected function trimResourceAsNeeded($resource)
608
    {
609 10
        return ltrim(Str::replaceFirst($this->getUrl(), '', $resource), '/');
610
    }
611
}
612