Client   F
last analyzed

Complexity

Total Complexity 68

Size/Duplication

Total Lines 685
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 6
Bugs 0 Features 0
Metric Value
wmc 68
eloc 181
c 6
b 0
f 0
dl 0
loc 685
ccs 198
cts 198
cp 1
rs 2.96

35 Methods

Rating   Name   Duplication   Size   Complexity  
A create() 0 3 1
A partial() 0 15 3
A __construct() 0 14 1
A sendPatchRequest() 0 26 4
A handleClientException() 0 18 5
A setKey() 0 5 1
A getChecksum() 0 7 2
A setChecksumAlgorithm() 0 5 1
A getKey() 0 3 1
A setMetadata() 0 7 1
A getMetadata() 0 3 1
A file() 0 14 3
A removeMetadata() 0 5 1
A getFilePath() 0 3 1
A getUploadMetadataHeader() 0 9 2
A setFileName() 0 5 1
A getFileSize() 0 3 1
A delete() 0 9 4
A getPartialOffset() 0 3 1
A isPartial() 0 3 1
A getFileName() 0 3 1
A getOffset() 0 9 2
A addMetadata() 0 5 1
A getClient() 0 3 1
A sendHeadRequest() 0 10 2
A getChecksumAlgorithm() 0 3 1
A concat() 0 21 3
A getUploadChecksumHeader() 0 3 1
A seek() 0 7 1
A getUrl() 0 9 2
B createWithUpload() 0 51 7
A isExpired() 0 5 2
A setChecksum() 0 5 1
A upload() 0 22 6
A getData() 0 12 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 TusPhp\Tus;
4
5
use TusPhp\File;
6
use Carbon\Carbon;
7
use TusPhp\Config;
8
use Ramsey\Uuid\Uuid;
9
use TusPhp\Exception\TusException;
10
use TusPhp\Exception\FileException;
11
use GuzzleHttp\Client as GuzzleClient;
12
use GuzzleHttp\Exception\ClientException;
13
use TusPhp\Exception\ConnectionException;
14
use GuzzleHttp\Exception\GuzzleException;
15
use GuzzleHttp\Exception\ConnectException;
16
use Symfony\Component\HttpFoundation\Response as HttpResponse;
17
18
class Client extends AbstractTus
19
{
20
    /** @var GuzzleClient */
21
    protected $client;
22
23
    /** @var string */
24
    protected $filePath;
25
26
    /** @var int */
27
    protected $fileSize = 0;
28
29
    /** @var string */
30
    protected $fileName;
31
32
    /** @var string */
33
    protected $key;
34
35
    /** @var string */
36
    protected $url;
37
38
    /** @var string */
39
    protected $checksum;
40
41
    /** @var int */
42
    protected $partialOffset = -1;
43
44
    /** @var bool */
45
    protected $partial = false;
46
47
    /** @var string */
48
    protected $checksumAlgorithm = 'sha256';
49
50
    /** @var array */
51
    protected $metadata = [];
52
53
    /** @var array */
54
    protected $headers = [];
55
56
    /**
57
     * Client constructor.
58
     *
59
     * @param string $baseUri
60
     * @param array  $options
61
     *
62
     * @throws \ReflectionException
63
     */
64 3
    public function __construct(string $baseUri, array $options = [])
65
    {
66 3
        $this->headers      = $options['headers'] ?? [];
67 3
        $options['headers'] = [
68 3
            'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
69 3
        ] + ($this->headers);
70
71 3
        $this->client = new GuzzleClient(
72 3
            ['base_uri' => $baseUri] + $options
73
        );
74
75 3
        Config::set(__DIR__ . '/../Config/client.php');
76
77 3
        $this->setCache('file');
78 3
    }
79
80
    /**
81
     * Set file properties.
82
     *
83
     * @param string $file File path.
84
     * @param string $name File name.
85
     *
86
     * @return Client
87
     */
88 2
    public function file(string $file, string $name = null) : self
89
    {
90 2
        $this->filePath = $file;
91
92 2
        if ( ! file_exists($file) || ! is_readable($file)) {
93 1
            throw new FileException('Cannot read file: ' . $file);
94
        }
95
96 1
        $this->fileName = $name ?? basename($this->filePath);
97 1
        $this->fileSize = filesize($file);
98
99 1
        $this->addMetadata('filename', $this->fileName);
100
101 1
        return $this;
102
    }
103
104
    /**
105
     * Get file path.
106
     *
107
     * @return string|null
108
     */
109 1
    public function getFilePath() : ?string
110
    {
111 1
        return $this->filePath;
112
    }
113
114
    /**
115
     * Set file name.
116
     *
117
     * @param string $name
118
     *
119
     * @return Client
120
     */
121 1
    public function setFileName(string $name) : self
122
    {
123 1
        $this->addMetadata('filename', $this->fileName = $name);
124
125 1
        return $this;
126
    }
127
128
    /**
129
     * Get file name.
130
     *
131
     * @return string|null
132
     */
133 2
    public function getFileName() : ?string
134
    {
135 2
        return $this->fileName;
136
    }
137
138
    /**
139
     * Get file size.
140
     *
141
     * @return int
142
     */
143 1
    public function getFileSize() : int
144
    {
145 1
        return $this->fileSize;
146
    }
147
148
    /**
149
     * Get guzzle client.
150
     *
151
     * @return GuzzleClient
152
     */
153 2
    public function getClient() : GuzzleClient
154
    {
155 2
        return $this->client;
156
    }
157
158
    /**
159
     * Set checksum.
160
     *
161
     * @param string $checksum
162
     *
163
     * @return Client
164
     */
165 1
    public function setChecksum(string $checksum) : self
166
    {
167 1
        $this->checksum = $checksum;
168
169 1
        return $this;
170
    }
171
172
    /**
173
     * Get checksum.
174
     *
175
     * @return string
176
     */
177 1
    public function getChecksum() : string
178
    {
179 1
        if (empty($this->checksum)) {
180 1
            $this->setChecksum(hash_file($this->getChecksumAlgorithm(), $this->getFilePath()));
181
        }
182
183 1
        return $this->checksum;
184
    }
185
186
    /**
187
     * Add metadata.
188
     *
189
     * @param string $key
190
     * @param string $value
191
     *
192
     * @return Client
193
     */
194 1
    public function addMetadata(string $key, string $value) : self
195
    {
196 1
        $this->metadata[$key] = base64_encode($value);
197
198 1
        return $this;
199
    }
200
201
    /**
202
     * Remove metadata.
203
     *
204
     * @param string $key
205
     *
206
     * @return Client
207
     */
208 1
    public function removeMetadata(string $key) : self
209
    {
210 1
        unset($this->metadata[$key]);
211
212 1
        return $this;
213
    }
214
215
    /**
216
     * Set metadata.
217
     *
218
     * @param array $items
219
     *
220
     * @return Client
221
     */
222 1
    public function setMetadata(array $items) : self
223
    {
224 1
        $items = array_map('base64_encode', $items);
225
226 1
        $this->metadata = $items;
227
228 1
        return $this;
229
    }
230
231
    /**
232
     * Get metadata.
233
     *
234
     * @return array
235
     */
236 1
    public function getMetadata() : array
237
    {
238 1
        return $this->metadata;
239
    }
240
241
    /**
242
     * Get metadata for Upload-Metadata header.
243
     *
244
     * @return string
245
     */
246 1
    protected function getUploadMetadataHeader() : string
247
    {
248 1
        $metadata = [];
249
250 1
        foreach ($this->getMetadata() as $key => $value) {
251 1
            $metadata[] = "{$key} {$value}";
252
        }
253
254 1
        return implode(',', $metadata);
255
    }
256
257
    /**
258
     * Set key.
259
     *
260
     * @param string $key
261
     *
262
     * @return Client
263
     */
264 1
    public function setKey(string $key) : self
265
    {
266 1
        $this->key = $key;
267
268 1
        return $this;
269
    }
270
271
    /**
272
     * Get key.
273
     *
274
     * @return string
275
     */
276 1
    public function getKey() : string
277
    {
278 1
        return $this->key;
279
    }
280
281
    /**
282
     * Get url.
283
     *
284
     * @return string|null
285
     */
286 2
    public function getUrl() : ?string
287
    {
288 2
        $this->url = $this->getCache()->get($this->getKey())['location'] ?? null;
289
290 2
        if ( ! $this->url) {
291 1
            throw new FileException('File not found.');
292
        }
293
294 1
        return $this->url;
295
    }
296
297
    /**
298
     * Set checksum algorithm.
299
     *
300
     * @param string $algorithm
301
     *
302
     * @return Client
303
     */
304 1
    public function setChecksumAlgorithm(string $algorithm) : self
305
    {
306 1
        $this->checksumAlgorithm = $algorithm;
307
308 1
        return $this;
309
    }
310
311
    /**
312
     * Get checksum algorithm.
313
     *
314
     * @return string
315
     */
316 1
    public function getChecksumAlgorithm() : string
317
    {
318 1
        return $this->checksumAlgorithm;
319
    }
320
321
    /**
322
     * Check if current upload is expired.
323
     *
324
     * @return bool
325
     */
326 2
    public function isExpired() : bool
327
    {
328 2
        $expiresAt = $this->getCache()->get($this->getKey())['expires_at'] ?? null;
329
330 2
        return empty($expiresAt) || Carbon::parse($expiresAt)->lt(Carbon::now());
331
    }
332
333
    /**
334
     * Check if this is a partial upload request.
335
     *
336
     * @return bool
337
     */
338 2
    public function isPartial() : bool
339
    {
340 2
        return $this->partial;
341
    }
342
343
    /**
344
     * Get partial offset.
345
     *
346
     * @return int
347
     */
348 1
    public function getPartialOffset() : int
349
    {
350 1
        return $this->partialOffset;
351
    }
352
353
    /**
354
     * Set offset and force this to be a partial upload request.
355
     *
356
     * @param int $offset
357
     *
358
     * @return self
359
     */
360 1
    public function seek(int $offset) : self
361
    {
362 1
        $this->partialOffset = $offset;
363
364 1
        $this->partial();
365
366 1
        return $this;
367
    }
368
369
    /**
370
     * Upload file.
371
     *
372
     * @param int $bytes Bytes to upload
373
     *
374
     * @throws TusException
375
     * @throws GuzzleException
376
     * @throws ConnectionException
377
     *
378
     * @return int
379
     */
380 6
    public function upload(int $bytes = -1) : int
381
    {
382 6
        $bytes  = $bytes < 0 ? $this->getFileSize() : $bytes;
383 6
        $offset = $this->partialOffset < 0 ? 0 : $this->partialOffset;
0 ignored issues
show
Unused Code introduced by
The assignment to $offset is dead and can be removed.
Loading history...
384
385
        try {
386
            // Check if this upload exists with HEAD request.
387 6
            $offset = $this->sendHeadRequest();
388 3
        } catch (FileException | ClientException $e) {
389
            // Create a new upload.
390 2
            $this->url = $this->create($this->getKey());
391 1
        } catch (ConnectException $e) {
392 1
            throw new ConnectionException("Couldn't connect to server.");
393
        }
394
395
        // Verify that upload is not yet expired.
396 5
        if ($this->isExpired()) {
397 1
            throw new TusException('Upload expired.');
398
        }
399
400
        // Now, resume upload with PATCH request.
401 4
        return $this->sendPatchRequest($bytes, $offset);
402
    }
403
404
    /**
405
     * Returns offset if file is partially uploaded.
406
     *
407
     * @throws GuzzleException
408
     *
409
     * @return bool|int
410
     */
411 3
    public function getOffset()
412
    {
413
        try {
414 3
            $offset = $this->sendHeadRequest();
415 2
        } catch (FileException | ClientException $e) {
416 2
            return false;
417
        }
418
419 1
        return $offset;
420
    }
421
422
    /**
423
     * Create resource with POST request.
424
     *
425
     * @param string $key
426
     *
427
     * @throws FileException
428
     * @throws GuzzleException
429
     *
430
     * @return string
431
     */
432 3
    public function create(string $key) : string
433
    {
434 3
        return $this->createWithUpload($key, 0)['location'];
435
    }
436
437
    /**
438
     * Create resource with POST request and upload data using the creation-with-upload extension.
439
     *
440
     * @see https://tus.io/protocols/resumable-upload.html#creation-with-upload
441
     *
442
     * @param string $key
443
     * @param int    $bytes -1 => all data; 0 => no data
444
     *
445
     * @throws GuzzleException
446
     *
447
     * @return array [
448
     *     'location' => string,
449
     *     'offset' => int
450
     * ]
451
     */
452 5
    public function createWithUpload(string $key, int $bytes = -1) : array
453
    {
454 5
        $bytes = $bytes < 0 ? $this->fileSize : $bytes;
455
456 5
        $headers = $this->headers + [
457 5
            'Upload-Length' => $this->fileSize,
458 5
            'Upload-Key' => $key,
459 5
            'Upload-Checksum' => $this->getUploadChecksumHeader(),
460 5
            'Upload-Metadata' => $this->getUploadMetadataHeader(),
461
        ];
462
463 5
        $data = '';
464 5
        if ($bytes > 0) {
465 2
            $data = $this->getData(0, $bytes);
466
467
            $headers += [
468 2
                'Content-Type' => self::HEADER_CONTENT_TYPE,
469 2
                'Content-Length' => \strlen($data),
470
            ];
471
        }
472
473 5
        if ($this->isPartial()) {
474 1
            $headers += ['Upload-Concat' => 'partial'];
475
        }
476
477
        try {
478 5
            $response = $this->getClient()->post($this->apiPath, [
479 5
                'body' => $data,
480 5
                'headers' => $headers,
481
            ]);
482 1
        } catch (ClientException $e) {
483 1
            $response = $e->getResponse();
484
        }
485
486 5
        $statusCode = $response->getStatusCode();
487
488 5
        if (HttpResponse::HTTP_CREATED !== $statusCode) {
489 1
            throw new FileException('Unable to create resource.');
490
        }
491
492 4
        $uploadOffset   = $bytes > 0 ? current($response->getHeader('upload-offset')) : 0;
493 4
        $uploadLocation = current($response->getHeader('location'));
494
495 4
        $this->getCache()->set($this->getKey(), [
496 4
            'location' => $uploadLocation,
497 4
            'expires_at' => Carbon::now()->addSeconds($this->getCache()->getTtl())->format($this->getCache()::RFC_7231),
498
        ]);
499
500
        return [
501 4
            'location' => $uploadLocation,
502 4
            'offset' => $uploadOffset,
503
        ];
504
    }
505
506
    /**
507
     * Concatenate 2 or more partial uploads.
508
     *
509
     * @param string $key
510
     * @param mixed  $partials
511
     *
512
     * @throws GuzzleException
513
     *
514
     * @return string
515
     */
516 3
    public function concat(string $key, ...$partials) : string
517
    {
518 3
        $response = $this->getClient()->post($this->apiPath, [
519 3
            'headers' => $this->headers + [
520 3
                'Upload-Length' => $this->fileSize,
521 3
                'Upload-Key' => $key,
522 3
                'Upload-Checksum' => $this->getUploadChecksumHeader(),
523 3
                'Upload-Metadata' => $this->getUploadMetadataHeader(),
524 3
                'Upload-Concat' => self::UPLOAD_TYPE_FINAL . ';' . implode(' ', $partials),
525
            ],
526
        ]);
527
528 3
        $data       = json_decode($response->getBody(), true);
529 3
        $checksum   = $data['data']['checksum'] ?? null;
530 3
        $statusCode = $response->getStatusCode();
531
532 3
        if (HttpResponse::HTTP_CREATED !== $statusCode || ! $checksum) {
533 2
            throw new FileException('Unable to create resource.');
534
        }
535
536 1
        return $checksum;
537
    }
538
539
    /**
540
     * Send DELETE request.
541
     *
542
     * @throws FileException
543
     * @throws GuzzleException
544
     *
545
     * @return void
546
     */
547 3
    public function delete()
548
    {
549
        try {
550 3
            $this->getClient()->delete($this->getUrl());
551 2
        } catch (ClientException $e) {
552 2
            $statusCode = $e->getResponse()->getStatusCode();
553
554 2
            if (HttpResponse::HTTP_NOT_FOUND === $statusCode || HttpResponse::HTTP_GONE === $statusCode) {
555 2
                throw new FileException('File not found.');
556
            }
557
        }
558 1
    }
559
560
    /**
561
     * Set as partial request.
562
     *
563
     * @param bool $state
564
     *
565
     * @return void
566
     */
567 3
    protected function partial(bool $state = true)
568
    {
569 3
        $this->partial = $state;
570
571 3
        if ( ! $this->partial) {
572 1
            return;
573
        }
574
575 2
        $key = $this->getKey();
576
577 2
        if (false !== strpos($key, self::PARTIAL_UPLOAD_NAME_SEPARATOR)) {
578 1
            [$key, /* $partialKey */] = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key);
579
        }
580
581 2
        $this->key = $key . self::PARTIAL_UPLOAD_NAME_SEPARATOR . Uuid::uuid4()->toString();
582 2
    }
583
584
    /**
585
     * Send HEAD request.
586
     *
587
     * @throws FileException
588
     * @throws GuzzleException
589
     *
590
     * @return int
591
     */
592 2
    protected function sendHeadRequest() : int
593
    {
594 2
        $response   = $this->getClient()->head($this->getUrl());
595 2
        $statusCode = $response->getStatusCode();
596
597 2
        if (HttpResponse::HTTP_OK !== $statusCode) {
598 1
            throw new FileException('File not found.');
599
        }
600
601 1
        return (int) current($response->getHeader('upload-offset'));
602
    }
603
604
    /**
605
     * Send PATCH request.
606
     *
607
     * @param int $bytes
608
     * @param int $offset
609
     *
610
     * @throws TusException
611
     * @throws FileException
612
     * @throws GuzzleException
613
     * @throws ConnectionException
614
     *
615
     * @return int
616
     */
617 7
    protected function sendPatchRequest(int $bytes, int $offset) : int
618
    {
619 7
        $data    = $this->getData($offset, $bytes);
620 7
        $headers = $this->headers + [
621 7
            'Content-Type' => self::HEADER_CONTENT_TYPE,
622 7
            'Content-Length' => \strlen($data),
623 7
            'Upload-Checksum' => $this->getUploadChecksumHeader(),
624
        ];
625
626 7
        if ($this->isPartial()) {
627 1
            $headers += ['Upload-Concat' => self::UPLOAD_TYPE_PARTIAL];
628
        } else {
629 6
            $headers += ['Upload-Offset' => $offset];
630
        }
631
632
        try {
633 7
            $response = $this->getClient()->patch($this->getUrl(), [
634 7
                'body' => $data,
635 7
                'headers' => $headers,
636
            ]);
637
638 2
            return (int) current($response->getHeader('upload-offset'));
639 5
        } catch (ClientException $e) {
640 4
            throw $this->handleClientException($e);
641 1
        } catch (ConnectException $e) {
642 1
            throw new ConnectionException("Couldn't connect to server.");
643
        }
644
    }
645
646
    /**
647
     * Handle client exception during patch request.
648
     *
649
     * @param ClientException $e
650
     *
651
     * @return mixed
652
     */
653 4
    protected function handleClientException(ClientException $e)
654
    {
655 4
        $response   = $e->getResponse();
656 4
        $statusCode = $response !== null ? $response->getStatusCode() : HttpResponse::HTTP_INTERNAL_SERVER_ERROR;
657
658 4
        if (HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE === $statusCode) {
659 1
            return new FileException('The uploaded file is corrupt.');
660
        }
661
662 3
        if (HttpResponse::HTTP_CONTINUE === $statusCode) {
663 1
            return new ConnectionException('Connection aborted by user.');
664
        }
665
666 2
        if (HttpResponse::HTTP_UNSUPPORTED_MEDIA_TYPE === $statusCode) {
667 1
            return new TusException('Unsupported media types.');
668
        }
669
670 1
        return new TusException((string) $response->getBody(), $statusCode);
671
    }
672
673
    /**
674
     * Get X bytes of data from file.
675
     *
676
     * @param int $offset
677
     * @param int $bytes
678
     *
679
     * @return string
680
     */
681 2
    protected function getData(int $offset, int $bytes) : string
682
    {
683 2
        $file   = new File;
684 2
        $handle = $file->open($this->getFilePath(), $file::READ_BINARY);
685
686 2
        $file->seek($handle, $offset);
687
688 2
        $data = $file->read($handle, $bytes);
689
690 2
        $file->close($handle);
691
692 2
        return $data;
693
    }
694
695
    /**
696
     * Get upload checksum header.
697
     *
698
     * @return string
699
     */
700 1
    protected function getUploadChecksumHeader() : string
701
    {
702 1
        return $this->getChecksumAlgorithm() . ' ' . base64_encode($this->getChecksum());
703
    }
704
}
705