Completed
Push — develop ( 37738a...f7969e )
by Stephen
15s queued 13s
created

Client   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 575
Duplicated Lines 0 %

Test Coverage

Coverage 85.84%

Importance

Changes 15
Bugs 0 Features 0
Metric Value
wmc 43
eloc 119
c 15
b 0
f 0
dl 0
loc 575
ccs 97
cts 113
cp 0.8584
rs 8.96

26 Methods

Rating   Name   Duplication   Size   Complexity  
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 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 __construct() 0 6 1
A setVersion() 0 9 2
A __call() 0 14 5
A buildUri() 0 16 4
A addHeader() 0 7 2
A request() 0 25 4
A buildAuth() 0 7 2
A isLastPage() 0 3 1
A setPageSize() 0 5 1
A processError() 0 8 1
A isCollection() 0 8 1
A setUrl() 0 9 2
A processResponse() 0 23 3
A getHeaders() 0 23 2
A buildOptions() 0 6 1

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;
9
use GuzzleHttp\Psr7\Response;
10
use Illuminate\Support\Arr;
11
use Illuminate\Support\Collection as LaravelCollection;
12
use Illuminate\Support\Str;
13
use InvalidArgumentException;
14
use Psr\Http\Message\ResponseInterface;
15
use Spinen\ConnectWise\Exceptions\MalformedRequest;
16
use Spinen\ConnectWise\Support\Collection;
17
use Spinen\ConnectWise\Support\Model;
18
use Spinen\ConnectWise\Support\ModelResolver;
19
20
/**
21
 * Class Client
22
 *
23
 * @package Spinen\ConnectWise\Api
24
 *
25
 * @method LaravelCollection|Model delete(string $resource, array $options = [])
26
 * @method LaravelCollection|Model get(string $resource, array $options = [])
27
 * @method LaravelCollection|Model getAll(string $resource, array $options = [])
28
 * @method LaravelCollection|Model head(string $resource, array $options = [])
29
 * @method LaravelCollection|Model patch(string $resource, array $options = [])
30
 * @method LaravelCollection|Model post(string $resource, array $options = [])
31
 * @method LaravelCollection|Model put(string $resource, array $options = [])
32
 */
33
class Client
34
{
35
    /**
36
     * The Client Id
37
     *
38
     * @var string
39
     * @see https://developer.connectwise.com/ClientID
40
     */
41
    protected $clientId;
42
43
    /**
44
     * @var Guzzle
45
     */
46
    protected $guzzle;
47
48
    /**
49
     * Headers that needs to be used with token
50
     *
51
     * @var array
52
     */
53
    protected $headers = [];
54
55
    /**
56
     * Integrator username to make global calls
57
     *
58
     * @var string
59
     */
60
    protected $integrator;
61
62
    /**
63
     * Current page
64
     *
65
     * @var int
66
     */
67
    protected $page;
68
69
    /**
70
     * Number or records to retrieve
71
     *
72
     * @var int
73
     */
74
    protected $page_size = 100;
75
76
    /**
77
     * Integration password for global calls
78
     *
79
     * @var string
80
     */
81
    protected $password;
82
83
    /**
84
     * Resolves a model for the uri
85
     *
86
     * @var ModelResolver
87
     */
88
    protected $resolver;
89
90
    /**
91
     * The supported versions
92
     *
93
     * @var array
94
     */
95
    public $supported = [
96
        '2018.4',
97
        '2018.5',
98
        '2018.6',
99
        '2019.1',
100
        '2019.2',
101
        '2019.3',
102
        '2019.4',
103
        '2019.5',
104
    ];
105
106
    /**
107
     * Public & private keys to log into CW
108
     *
109
     * @var Token
110
     */
111
    protected $token;
112
113
    /**
114
     * URL to the ConnectWise installation
115
     *
116
     * @var string
117
     */
118
    protected $url;
119
120
    /**
121
     * Supported verbs
122
     *
123
     * @var array
124
     */
125
    protected $verbs = [
126
        'delete',
127
        'get',
128
        'head',
129
        'put',
130
        'post',
131
        'patch',
132
    ];
133
134
    /**
135
     * Version of the API being requested
136
     *
137
     * @var string
138
     */
139
    protected $version;
140
141
    /**
142
     * Client constructor.
143
     *
144
     * @param Token $token
145
     * @param Guzzle $guzzle
146
     * @param ModelResolver $resolver
147
     * @param string $version Version of the models to use with the API responses
148
     */
149 23
    public function __construct(Token $token, Guzzle $guzzle, ModelResolver $resolver, $version = null)
150
    {
151 23
        $this->token = $token;
152 23
        $this->guzzle = $guzzle;
153 23
        $this->resolver = $resolver;
154 23
        $this->setVersion($version ?? Arr::last($this->supported));
155 23
    }
156
157
    /**
158
     * Magic method to allow short cut to the request types
159
     *
160
     * @param string $verb
161
     * @param array $args
162
     *
163
     * @return Collection|Model|Response
164
     * @throws GuzzleException
165
     * @throws MalformedRequest
166
     */
167 11
    public function __call($verb, $args)
168
    {
169 11
        if (count($args) < 1) {
170 1
            throw new InvalidArgumentException('Magic request methods require a resource and optional options array');
171
        }
172
173
        // For "getAll", set page to 1 & change verb to "get", otherwise, no page
174 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...
175
176 10
        if (!in_array($verb, $this->verbs)) {
177 1
            throw new InvalidArgumentException(sprintf("Unsupported verb [%s] was requested.", $verb));
178
        }
179
180 9
        return $this->request($verb, $this->trimResourceAsNeeded($args[0]), $args[1] ?? []);
181
    }
182
183
    /**
184
     * Adds key/value pair to the header to be sent
185
     *
186
     * @param array $header
187
     *
188
     * @return $this
189
     */
190 3
    public function addHeader(array $header)
191
    {
192 3
        foreach ($header as $key => $value) {
193 3
            $this->headers[$key] = $value;
194
        }
195
196 3
        return $this;
197
    }
198
199
    /**
200
     * Build authorization headers to send CW API
201
     *
202
     * @return string
203
     */
204 14
    public function buildAuth()
205
    {
206 14
        if ($this->token->needsRefreshing()) {
207 1
            $this->token->refresh($this);
208
        }
209
210 14
        return 'Basic ' . base64_encode($this->token->getUsername() . ':' . $this->token->getPassword());
211
    }
212
213
    /**
214
     * Build the options to send to API
215
     *
216
     * We allays need to login with Basic Auth, so add the "auth" option for Guzzle to use when logging in.
217
     * Additionally, pass any headers that have been set.
218
     *
219
     * @param array $options
220
     *
221
     * @return array
222
     */
223 10
    public function buildOptions(array $options = [])
224
    {
225 10
        return array_merge_recursive(
226
            $options,
227
            [
228 10
                'headers' => $this->getHeaders(),
229
            ]
230
        );
231
    }
232
233
    /**
234
     * Build the full path to the CW resource
235
     *
236
     * @param string $resource
237
     *
238
     * @return string
239
     * @throws MalformedRequest
240
     */
241 11
    public function buildUri($resource)
242
    {
243 11
        $uri = $this->getUrl($resource);
244
245
        // For getAll calls, make sure to add pageSize & page to request
246 11
        if ($this->page) {
247
            $uri .= (preg_match('/\\?/u', $uri) ? '&' : '?') . 'pageSize=' . $this->page_size . '&page=' . $this->page;
248
        }
249
250 11
        if (strlen($uri) > 2000) {
251 1
            throw new MalformedRequest(
252 1
                sprintf("The uri is too long. It is %s character(s) over the 2000 limit.", strlen($uri) - 2000)
253
            );
254
        }
255
256 11
        return $uri;
257
    }
258
259
    /**
260
     * Remove all set headers
261
     *
262
     * @return $this
263
     */
264 1
    public function emptyHeaders()
265
    {
266 1
        $this->setHeaders([]);
267
268 1
        return $this;
269
    }
270
271
    /**
272
     * Expose the client id
273
     *
274
     * @return string
275
     */
276 12
    public function getClientId()
277
    {
278 12
        return $this->clientId;
279
    }
280
281
    /**
282
     * The headers to send
283
     *
284
     * When making an integrator call (expired token), then you have to only send the "x-cw-usertype" header.
285
     *
286
     * @return array
287
     */
288 12
    public function getHeaders()
289
    {
290
        $authorization_headers = [
291 12
            'clientId'      => $this->getClientId(),
292 12
            'Authorization' => $this->buildAuth(),
293
        ];
294
295 12
        if ($this->token->isForUser($this->getIntegrator())) {
296 1
            return array_merge(
297
                [
298 1
                    'x-cw-usertype' => 'integrator',
299
                ],
300
                $authorization_headers
301
            );
302
        }
303
304 11
        return array_merge(
305
            [
306 11
                'x-cw-usertype' => 'member',
307 11
                'Accept'        => 'application/vnd.connectwise.com+json; version=' . $this->getVersion(),
308
            ],
309
            $authorization_headers,
310 11
            $this->headers
311
        );
312
    }
313
314
    /**
315
     * Expose the integrator username
316
     *
317
     * @return string
318
     */
319 13
    public function getIntegrator()
320
    {
321 13
        return $this->integrator;
322
    }
323
324
    /**
325
     * Expose the integrator password
326
     *
327
     * @return string
328
     */
329 1
    public function getPassword()
330
    {
331 1
        return $this->password;
332
    }
333
334
    /**
335
     * Expose the url
336
     *
337
     * @param string|null $path
338
     *
339
     * @return string
340
     */
341 11
    public function getUrl($path = null)
342
    {
343 11
        return $this->url . '/v4_6_release/apis/3.0/' . ltrim($path, '/');
344
    }
345
346
    /**
347
     * Expose the version
348
     *
349
     * @return string
350
     */
351 12
    public function getVersion()
352
    {
353 12
        return $this->version;
354
    }
355
356
    /**
357
     * Check to see if the array is a collection
358
     *
359
     * @param array $array
360
     *
361
     * @return bool
362
     */
363 8
    protected function isCollection(array $array)
364
    {
365
        // Keys of the array
366 8
        $keys = array_keys($array);
367
368
        // If the array keys of the keys match the keys, then the array must
369
        // not be associative (e.g. the keys array looked like {0:0, 1:1...}).
370 8
        return array_keys($keys) === $keys;
371
    }
372
373
    /**
374
     * Check to see it there are more pages in a paginated call
375
     *
376
     * For paginated calls, ConnectWise returns a "Link" property in teh header with a single string.
377
     *
378
     * There appears to be 3 potential responses...
379
     *  1) null -- The number of items in the collection is < the pageSize
380
     *  2) string ends with rel="last" -- More pages to go
381
     *  3) string ends with rel="first" -- On the last page
382
     *
383
     * Here are some examples...
384
     *  <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=2>; rel="next", \
385
     *      <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=3>; rel="last”
386
     *
387
     *  <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=3>; rel="next", \
388
     *      <https://some.host/v4_6_release/apis/3.0/finance/agreements&pageSize=10&page=1>; rel="first”
389
     *
390
     * @param ResponseInterface $response
391
     *
392
     * @return boolean
393
     */
394
    protected function isLastPage(ResponseInterface $response)
395
    {
396
        return !(bool)preg_match('/rel="last"$/u', $response->getHeader('Link')[0] ?? null);
397
    }
398
399
    /**
400
     * Process the error received from ConnectWise
401
     *
402
     * @param RequestException $exception
403
     *
404
     * @throws RequestException
405
     */
406
    protected function processError(RequestException $exception)
407
    {
408
        // TODO: Figure out what to really do with an error...
409
        // Request is in here... Psr7\str($exception->getRequest()
410
        // If $exception->hasResponse(), then response is in here... Psr7\str($exception->getResponse()
411
412
        // NOTE: For now, just rethrow the original exception
413
        throw $exception;
414
    }
415
416
    /**
417
     * Parse the response for the given resource
418
     *
419
     * @param string $resource
420
     * @param ResponseInterface $response
421
     *
422
     * @return Collection|Model|ResponseInterface
423
     */
424 9
    protected function processResponse($resource, ResponseInterface $response)
425
    {
426 9
        $response = (array)json_decode($response->getBody(), true);
427
428
        // Nothing to map response, so just return it as is
429 9
        if (!$model = $this->resolver->find($resource, $this->getVersion())) {
430 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...
431
        }
432
433
        // Not a collection of records, so cast to model
434 8
        if (!$this->isCollection($response)) {
435 1
            return new $model($response, $this);
436
        }
437
438
        // Have a collection of records, so cast them all as a collection
439 7
        return new Collection(
440 7
            array_map(
441
                function ($item) use ($model) {
442 1
                    $item = new $model($item, $this);
443
444 1
                    return $item;
445 7
                },
446
                $response
447
            )
448
        );
449
    }
450
451
    /**
452
     * Make call to the resource
453
     *
454
     * @param string $method
455
     * @param string $resource
456
     * @param array|null $options
457
     *
458
     * @return LaravelCollection|Model|Response
459
     * @throws GuzzleException
460
     * @throws MalformedRequest
461
     */
462 9
    protected function request($method, $resource, array $options = [])
463
    {
464
        try {
465 9
            $response = $this->guzzle->request($method, $this->buildUri($resource), $this->buildOptions($options));
466
467 9
            $processed = $this->processResponse($resource, $response);
468
469
            // If, not a "gatAll" call, then return the response
470 9
            if (!$this->page) {
471 9
                return $processed;
472
            }
473
474
            // Get all of the other records
475
            while (!$this->isLastPage($response)) {
476
                $this->page = $this->page + 1;
477
478
                // Make next call
479
                $response = $this->guzzle->request($method, $this->buildUri($resource), $this->buildOptions($options));
480
481
                $processed = $processed->merge($this->processResponse($resource, $response));
482
            }
483
484
            return $processed;
485
        } catch (RequestException $e) {
486
            $this->processError($e);
487
        }
488
    }
489
490
    /**
491
     * Set the Client Id
492
     *
493
     * @param string $clientId
494
     *
495
     * @return $this
496
     */
497 23
    public function setClientId($clientId)
498
    {
499 23
        $this->clientId = $clientId;
500
501 23
        return $this;
502
    }
503
504
    /**
505
     * Set the headers
506
     *
507
     * There is an "addHeader" method to push a single header onto the stack.  Otherwise,this replaces the headers.
508
     *
509
     * @param array $headers
510
     *
511
     * @return $this
512
     */
513 1
    public function setHeaders(array $headers)
514
    {
515 1
        $this->headers = $headers;
516
517 1
        return $this;
518
    }
519
520
    /**
521
     * Set the integrator username
522
     *
523
     * @param string $integrator
524
     *
525
     * @return $this
526
     */
527 5
    public function setIntegrator($integrator)
528
    {
529 5
        $this->integrator = $integrator;
530
531 5
        return $this;
532
    }
533
534
    /**
535
     * Set the integrator password
536
     *
537
     * @param string $password
538
     *
539
     * @return $this
540
     */
541 2
    public function setPassword($password)
542
    {
543 2
        $this->password = $password;
544
545 2
        return $this;
546
    }
547
548
    /**
549
     * Set the page size
550
     *
551
     * @param integer $page_size
552
     *
553
     * @return $this
554
     */
555
    public function setPageSize($page_size)
556
    {
557
        $this->page_size = $page_size;
558
559
        return $this;
560
    }
561
562
    /**
563
     * Set the URL to ConnectWise
564
     *
565
     * @param string $url
566
     *
567
     * @return $this
568
     */
569 3
    public function setUrl($url)
570
    {
571 3
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
572 1
            throw new InvalidArgumentException(sprintf("The URL provided[%s] is not a valid format.", $url));
573
        }
574
575 2
        $this->url = rtrim($url, '/');
576
577 2
        return $this;
578
    }
579
580
    /**
581
     * Set the version of the API response models
582
     *
583
     * @param string $version
584
     *
585
     * @return $this
586
     */
587 23
    public function setVersion($version)
588
    {
589 23
        if (!in_array($version, $this->supported)) {
590 1
            throw new InvalidArgumentException(sprintf("The Version provided[%s] is not supported.", $version));
591
        }
592
593 23
        $this->version = $version;
594
595 23
        return $this;
596
    }
597
598
    /**
599
     * Make the resource being request be relative without the leading slash
600
     *
601
     * @param string $resource
602
     *
603
     * @return string
604
     */
605 9
    protected function trimResourceAsNeeded($resource)
606
    {
607 9
        return ltrim(Str::replaceFirst($this->getUrl(), '', $resource), '/');
608
    }
609
}
610