Passed
Pull Request — develop (#52)
by Stephen
43:14 queued 13:00
created

Client::buildAuth()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 3
c 2
b 0
f 0
dl 0
loc 7
ccs 4
cts 4
cp 1
rs 10
cc 2
nc 2
nop 0
crap 2
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
        return [
228 11
            'body'    => json_encode($options ?? []),
229 11
            'headers' => $this->getHeaders(),
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 12
    public function buildUri($resource)
242
    {
243 12
        $uri = $this->getUrl($resource);
244
245
        // For getAll calls, make sure to add pageSize & page to request
246 12
        if ($this->page) {
247 1
            $uri .= (preg_match('/\\?/u', $uri) ? '&' : '?') . 'pageSize=' . $this->page_size . '&page=' . $this->page;
248
        }
249
250 12
        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 12
        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 13
    public function getClientId()
277
    {
278 13
        return $this->clientId;
279
    }
280
281
    /**
282
     * The headers to send
283
     *
284
     * When making an integrator call (expired token), then you only have to send the "x-cw-usertype" header.
285
     *
286
     * @return array
287
     */
288 13
    public function getHeaders()
289
    {
290
        $authorization_headers = [
291 13
            'clientId'      => $this->getClientId(),
292 13
            'Authorization' => $this->buildAuth(),
293
        ];
294
295 13
        if ($this->token->isForUser($this->getIntegrator())) {
296 1
            return array_merge(
297
                [
298 1
                    'x-cw-usertype' => 'integrator',
299
                ],
300 1
                $authorization_headers
301
            );
302
        }
303
304 12
        return array_merge(
305
            [
306 12
                'x-cw-usertype' => 'member',
307 12
                'Accept'        => 'application/vnd.connectwise.com+json; version=' . $this->getVersion(),
308
            ],
309
            $authorization_headers,
310 12
            $this->headers
311
        );
312
    }
313
314
    /**
315
     * Expose the integrator username
316
     *
317
     * @return string
318
     */
319 14
    public function getIntegrator()
320
    {
321 14
        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 12
    public function getUrl($path = null)
342
    {
343 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

343
        return $this->url . '/v4_6_release/apis/3.0/' . ltrim(/** @scrutinizer ignore-type */ $path, '/');
Loading history...
344
    }
345
346
    /**
347
     * Expose the version
348
     *
349
     * @return string
350
     */
351 13
    public function getVersion()
352
    {
353 13
        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 9
    protected function isCollection(array $array)
364
    {
365
        // Keys of the array
366 9
        $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 9
        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 the 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 1
    protected function isLastPage(ResponseInterface $response)
395
    {
396 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

396
        return !(bool)preg_match('/rel="last"$/u', /** @scrutinizer ignore-type */ $response->getHeader('Link')[0] ?? null);
Loading history...
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|array
423
     */
424 10
    protected function processResponse($resource, ResponseInterface $response)
425
    {
426 10
        $response = (array)json_decode($response->getBody(), true);
427
428
        // Nothing to map the response to, so just return it as-is
429 10
        if (!$model = $this->resolver->find($resource, $this->getVersion())) {
430 1
            return $response;
431
        }
432
433
        // Not a collection of records, so cast to a model
434 9
        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 8
        return new Collection(
440 8
            array_map(
441
                function ($item) use ($model) {
442 1
                    $item = new $model($item, $this);
443
444 1
                    return $item;
445 8
                },
446 8
                $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 10
    protected function request($method, $resource, array $options = [])
463
    {
464
        try {
465 10
            $response = $this->guzzle->request($method, $this->buildUri($resource), $this->buildOptions($options));
466
467 10
            $processed = $this->processResponse($resource, $response);
468
469
            // If, not a "getAll" call, then return the response
470 10
            if (!$this->page) {
471 9
                return $processed;
472
            }
473
474
            // Get all of the other records
475 1
            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 1
            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 24
    public function setClientId($clientId)
498
    {
499 24
        $this->clientId = $clientId;
500
501 24
        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, not allowing < 0
550
     *
551
     * @param integer $page_size
552
     *
553
     * @return $this
554
     */
555
    public function setPageSize($page_size)
556
    {
557
        $this->page_size = $page_size >= 0 ? $page_size : 0;
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 24
    public function setVersion($version)
588
    {
589 24
        if (!in_array($version, $this->supported)) {
590 1
            throw new InvalidArgumentException(sprintf("The Version provided[%s] is not supported.", $version));
591
        }
592
593 24
        $this->version = $version;
594
595 24
        return $this;
596
    }
597
598
    /**
599
     * Make the resource being requested be relative without the leading slash
600
     *
601
     * @param string $resource
602
     *
603
     * @return string
604
     */
605 10
    protected function trimResourceAsNeeded($resource)
606
    {
607 10
        return ltrim(Str::replaceFirst($this->getUrl(), '', $resource), '/');
608
    }
609
}
610