Completed
Pull Request — develop (#15)
by Ben
02:27
created

Api::getLastUpdatedTimestamp()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 2
Metric Value
c 3
b 0
f 2
dl 0
loc 4
rs 10
cc 1
eloc 2
nc 1
nop 1
1
<?php
2
3
namespace Potherca\Flysystem\Github;
4
5
use Github\Api\ApiInterface;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Potherca\Flysystem\Github\ApiInterface.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
6
use Github\Api\GitData;
7
use Github\Api\Repo;
8
use Github\Client;
9
use Github\Exception\RuntimeException;
10
use League\Flysystem\AdapterInterface;
11
use League\Flysystem\Util\MimeType;
12
13
/**
14
 * Facade class for the Github Api Library
15
 */
16
class Api implements \Potherca\Flysystem\Github\ApiInterface
17
{
18
    ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\
19
    const ERROR_NO_NAME = 'Could not set name for entry';
20
    const ERROR_NOT_FOUND = 'Not Found';
21
22
    const API_GIT_DATA = 'gitData';
23
    const API_REPOSITORY = 'repo';
24
    const API_REPOSITORY_COMMITS = 'commits';
25
    const API_REPOSITORY_CONTENTS = 'contents';
26
27
    const KEY_BLOB = 'blob';
28
    const KEY_DIRECTORY = 'dir';
29
    const KEY_FILE = 'file';
30
    const KEY_FILENAME = 'basename';
31
    const KEY_MODE = 'mode';
32
    const KEY_NAME = 'name';
33
    const KEY_PATH = 'path';
34
    const KEY_SHA = 'sha';
35
    const KEY_SIZE = 'size';
36
    const KEY_STREAM = 'stream';
37
    const KEY_TIMESTAMP = 'timestamp';
38
    const KEY_TREE = 'tree';
39
    const KEY_TYPE = 'type';
40
    const KEY_VISIBILITY = 'visibility';
41
42
    const GITHUB_API_URL = 'https://api.github.com';
43
    const GITHUB_URL = 'https://github.com';
44
45
    const MIME_TYPE_DIRECTORY = 'directory';    // or application/x-directory
46
47
    const NOT_RECURSIVE = false;
48
    const RECURSIVE = true;
49
50
    /** @var ApiInterface[] */
51
    private $apiCollection = [];
52
    /** @var Client */
53
    private $client;
54
    /** @var array */
55
    private $commits = [];
56
    /** @var bool */
57
    private $isAuthenticationAttempted = false;
58
    /** @var array */
59
    private $metadata = [];
60
    /** @var SettingsInterface */
61
    private $settings;
62
63
    //////////////////////////// SETTERS AND GETTERS \\\\\\\\\\\\\\\\\\\\\\\\\\\
64
    /**
65
     * @param string $name
66
     *
67
     * @return \Github\Api\ApiInterface
68
     *
69
     * @throws \Github\Exception\InvalidArgumentException
70
     */
71 View Code Duplication
    private function getApi($name)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
72
    {
73
        $this->assureAuthenticated();
74
75
        if ($this->hasKey($this->apiCollection, $name) === false) {
76
            $this->apiCollection[$name] = $this->client->api($name);
77
        }
78
79
        return $this->apiCollection[$name];
80
    }
81
82
    /**
83
     * @param $name
84
     * @param $api
85
     * @return ApiInterface
86
     */
87 View Code Duplication
    private function getApiFrom($name, $api)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
88
    {
89
        if ($this->hasKey($this->apiCollection, $name) === false) {
90
            $this->apiCollection[$name] = $api->{$name}();
91
        }
92
        return $this->apiCollection[$name];
93
    }
94
95
    /**
96
     * @return \Github\Api\Repository\Commits
97
     *
98
     * @throws \Github\Exception\InvalidArgumentException
99
     */
100
    private function getCommitsApi()
101
    {
102
        return $this->getApiFrom(self::API_REPOSITORY_COMMITS, $this->getRepositoryApi());
103
    }
104
105
    /**
106
     * @return \Github\Api\Repository\Contents
107
     *
108
     * @throws \Github\Exception\InvalidArgumentException
109
     */
110
    private function getContentApi()
111
    {
112
        return $this->getApiFrom(self::API_REPOSITORY_CONTENTS, $this->getRepositoryApi());
113
    }
114
115
    /**
116
     * @return GitData
117
     *
118
     * @throws \Github\Exception\InvalidArgumentException
119
     */
120
    private function getGitDataApi()
121
    {
122
        return $this->getApi(self::API_GIT_DATA);
123
    }
124
125
    /**
126
     * @return Repo
127
     *
128
     * @throws \Github\Exception\InvalidArgumentException
129
     */
130
    private function getRepositoryApi()
131
    {
132
        return $this->getApi(self::API_REPOSITORY);
133
    }
134
135
    //////////////////////////////// PUBLIC API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
136
    final public function __construct(Client $client, SettingsInterface $settings)
137
    {
138
        /* @NOTE: If $settings contains `credentials` but not an `author` we are
139
         * still in `read-only` mode.
140
         */
141
142
        $this->client = $client;
143
        $this->settings = $settings;
144
    }
145
146
    /**
147
     * @param string $path
148
     *
149
     * @return bool
150
     *
151
     * @throws \Github\Exception\InvalidArgumentException
152
     */
153 View Code Duplication
    final public function exists($path)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
154
    {
155
        $path = $this->normalizePathName($path);
156
157
        return $this->getContentApi()->exists(
158
            $this->settings->getVendor(),
159
            $this->settings->getPackage(),
160
            $path,
161
            $this->settings->getReference()
162
        );
163
    }
164
165
    /**
166
     * @param $path
167
     *
168
     * @return null|string
169
     * @throws \Github\Exception\InvalidArgumentException
170
     *
171
     * @throws \Github\Exception\ErrorException
172
     */
173 View Code Duplication
    final public function getFileContents($path)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
174
    {
175
        $path = $this->normalizePathName($path);
176
177
        return $this->getContentApi()->download(
178
            $this->settings->getVendor(),
179
            $this->settings->getPackage(),
180
            $path,
181
            $this->settings->getReference()
182
        );
183
    }
184
185
    /**
186
     * @param string $path
187
     *
188
     * @return array
189
     *
190
     * @throws \Github\Exception\InvalidArgumentException
191
     */
192
    final public function getLastUpdatedTimestamp($path)
193
    {
194
        return $this->filterCommits($path, 'reset');
195
    }
196
197
    /**
198
     * @param string $path
199
     *
200
     * @return array
201
     *
202
     * @throws \Github\Exception\InvalidArgumentException
203
     */
204
    final public function getCreatedTimestamp($path)
205
    {
206
        return $this->filterCommits($path, 'end');
207
    }
208
209
    /**
210
     * @param string $path
211
     *
212
     * @return array|bool
213
     *
214
     * @throws \Github\Exception\InvalidArgumentException
215
     * @throws \Github\Exception\RuntimeException
216
     * @throws \League\Flysystem\NotSupportedException
217
     */
218
    final public function getMetaData($path)
219
    {
220
        $path = $this->normalizePathName($path);
221
222
        if ($this->hasKey($this->metadata, $path) === false) {
223
            try {
224
                $metadata = $this->getContentApi()->show(
225
                    $this->settings->getVendor(),
226
                    $this->settings->getPackage(),
227
                    $path,
228
                    $this->settings->getReference()
229
                );
230
            } catch (RuntimeException $exception) {
231
                if ($exception->getMessage() === self::ERROR_NOT_FOUND) {
232
                    $metadata = false;
233
                } else {
234
                    throw $exception;
235
                }
236
            }
237
    
238
            if ($this->isMetadataForDirectory($metadata) === true) {
239
                $metadata = $this->metadataForDirectory($path);
240
            }
241
242
            $this->metadata[$path] = $metadata;
243
        }
244
245
        return $this->metadata[$path];
246
    }
247
248
    /**
249
     * @param string $path
250
     * @param bool $recursive
251
     *
252
     * @return array
253
     *
254
     * @throws \Github\Exception\InvalidArgumentException
255
     */
256
    final public function getDirectoryContents($path, $recursive)
257
    {
258
        $path = $this->normalizePathName($path);
259
260
        // If $info['truncated'] is `true`, the number of items in the tree array
261
        // exceeded the github maximum limit. If we need to fetch more items,
262
        // multiple calls will be needed
263
264
        $info = $this->getGitDataApi()->trees()->show(
265
            $this->settings->getVendor(),
266
            $this->settings->getPackage(),
267
            $this->settings->getReference(),
268
            self::RECURSIVE //@NOTE: To retrieve all needed date the 'recursive' flag should always be 'true'
269
        );
270
271
        $treeData = $this->addTimestamps($info[self::KEY_TREE]);
272
273
        $filteredTreeData = $this->filterTreeData($treeData, $path, $recursive);
274
275
        return $this->normalizeTreeData($filteredTreeData);
276
    }
277
278
    /**
279
     * @param string $path
280
     *
281
     * @return null|string
282
     *
283
     * @throws \Github\Exception\ErrorException
284
     * @throws \Github\Exception\InvalidArgumentException
285
     * @throws \Github\Exception\RuntimeException
286
     */
287
    final public function guessMimeType($path)
288
    {
289
        $path = $this->normalizePathName($path);
290
291
        //@NOTE: The github API does not return a MIME type, so we have to guess :-(
292
        $meta = $this->getMetaData($path);
293
294
        /** @noinspection OffsetOperationsInspection *//* @NOTE: The existence of $meta[self::KEY_TYPE] has been validated by `hasKey`. */
295
        if ($this->hasKey($meta, self::KEY_TYPE) && $meta[self::KEY_TYPE] === self::KEY_DIRECTORY) {
296
            $mimeType = self::MIME_TYPE_DIRECTORY;
297
        } else {
298
            $content = $this->getFileContents($path);
299
            $mimeType = MimeType::detectByContent($content);
300
        }
301
302
        return $mimeType;
303
    }
304
305
    ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\
306
    /**
307
     *
308
     * @throws \Github\Exception\InvalidArgumentException If no authentication method was given
309
     */
310
    private function assureAuthenticated()
311
    {
312
        if ($this->isAuthenticationAttempted === false) {
313
            $credentials = $this->settings->getCredentials();
314
315
            if (count($credentials) !== 0) {
316
                $credentials = array_replace(
317
                    [null, null, null],
318
                    $credentials
319
                );
320
321
                $this->client->authenticate(
322
                    $credentials[1],
323
                    $credentials[2],
324
                    $credentials[0]
325
                );
326
            }
327
            $this->isAuthenticationAttempted = true;
328
        }
329
    }
330
331
    /**
332
     * @param array $tree
333
     * @param string $path
334
     * @param bool $recursive
335
     *
336
     * @return array
337
     */
338
    private function filterTreeData(array $tree, $path, $recursive)
339
    {
340
        $length = strlen($path);
341
342
        $metadata = array_filter($tree, function ($entry) use ($path, $recursive, $length) {
343
            $match = false;
344
345
            if ($path === '' || strpos($entry[self::KEY_PATH], $path) === 0) {
346
                if ($recursive === self::RECURSIVE) {
347
                    $match = true;
348
                } else {
349
                    $match = ($path !== '' || strpos($entry[self::KEY_PATH], '/', $length) === false);
350
                }
351
            }
352
353
            return $match;
354
        });
355
356
        return array_values($metadata);
357
    }
358
359
    /**
360
     * @param $permissions
361
     * @return string
362
     */
363
    private function guessVisibility($permissions)
364
    {
365
        $visibility = AdapterInterface::VISIBILITY_PUBLIC;
366
367
        if (! substr($permissions, -4) & 0044) {
368
            $visibility = AdapterInterface::VISIBILITY_PRIVATE;
369
        }
370
371
        return $visibility;
372
    }
373
374
    /**
375
     * @param array $treeData
376
     *
377
     * @return array
378
     *
379
     * @throws \Github\Exception\InvalidArgumentException
380
     */
381
    private function normalizeTreeData($treeData)
382
    {
383
        if (is_array(current($treeData)) === false) {
384
            $treeData = [$treeData];
385
        }
386
387
        $normalizedTreeData = array_map(function ($entry) {
388
            $this->setEntryName($entry);
389
            $this->setEntryType($entry);
390
            $this->setEntryVisibility($entry);
391
392
            $this->setDefaultValue($entry, self::KEY_CONTENTS);
393
            $this->setDefaultValue($entry, self::KEY_STREAM);
394
395
            return $entry;
396
        }, $treeData);
397
398
        return $normalizedTreeData;
399
    }
400
401
    /**
402
     * @param $path
403
     *
404
     * @return array
405
     *
406
     * @throws \Github\Exception\InvalidArgumentException
407
     */
408
    private function commitsForFile($path)
409
    {
410
        if ($this->hasKey($this->commits, $path) === false) {
411
            $this->commits[$path] = $this->getCommitsApi()->all(
412
                $this->settings->getVendor(),
413
                $this->settings->getPackage(),
414
                array(
415
                    'sha' => $this->settings->getBranch(),
416
                    'path' => $path
417
                )
418
            );
419
        }
420
421
        return $this->commits[$path];
422
    }
423
424
    /**
425
     * @param array $entry
426
     * @param string $key
427
     * @param bool $default
428
     *
429
     * @return mixed
430
     */
431
    private function setDefaultValue(array &$entry, $key, $default = false)
432
    {
433
        if ($this->hasKey($entry, $key) === false) {
434
            $entry[$key] = $default;
435
        }
436
    }
437
438
    /**
439
     * @param $entry
440
     */
441
    private function setEntryType(&$entry)
442
    {
443
        if ($this->hasKey($entry, self::KEY_TYPE) === true) {
444
            switch ($entry[self::KEY_TYPE]) {
445
                case self::KEY_BLOB:
446
                    $entry[self::KEY_TYPE] = self::KEY_FILE;
447
                    break;
448
449
                case self::KEY_TREE:
450
                    $entry[self::KEY_TYPE] = self::KEY_DIRECTORY;
451
                    break;
452
                //@CHECKME: what should the 'default' be? Throw exception for unknown?
453
            }
454
        } else {
455
            $entry[self::KEY_TYPE] = false;
456
        }
457
    }
458
459
    /**
460
     * @param $entry
461
     */
462
    private function setEntryVisibility(&$entry)
463
    {
464
        if ($this->hasKey($entry, self::KEY_MODE)) {
465
            $entry[self::KEY_VISIBILITY] = $this->guessVisibility($entry[self::KEY_MODE]);
466
        } else {
467
            /* Assume public by default */
468
            $entry[self::KEY_VISIBILITY] = GithubAdapter::VISIBILITY_PUBLIC;
469
        }
470
    }
471
472
    /**
473
     * @param $entry
474
     */
475
    private function setEntryName(&$entry)
476
    {
477
        if ($this->hasKey($entry, self::KEY_NAME) === false) {
478
            if ($this->hasKey($entry, self::KEY_FILENAME) === true) {
479
                $entry[self::KEY_NAME] = $entry[self::KEY_FILENAME];
480
            } elseif ($this->hasKey($entry, self::KEY_PATH) === true) {
481
                $entry[self::KEY_NAME] = $entry[self::KEY_PATH];
482
            } else {
483
                $entry[self::KEY_NAME] = null;
484
            }
485
        }
486
    }
487
488
    /**
489
     * @param $metadata
490
     * @return bool
491
     */
492
    private function isMetadataForDirectory($metadata)
493
    {
494
        $isDirectory = false;
495
496
        if (is_array($metadata) === true) {
497
            $keys = array_keys($metadata);
498
499
            if ($keys[0] === 0) {
500
                $isDirectory = true;
501
            }
502
        }
503
504
        return $isDirectory;
505
    }
506
507
    /**
508
     * @param $subject
509
     * @param $key
510
     * @return mixed
511
     */
512
    private function hasKey(&$subject, $key)
513
    {
514
        $keyExists = false;
515
516
        if (is_array($subject)) {
517
        /** @noinspection ReferenceMismatchInspection */
518
            $keyExists = array_key_exists($key, $subject);
519
        }
520
521
        return $keyExists;
522
    }
523
524
    /**
525
     * @param array $treeMetadata
526
     * @param $path
527
     *
528
     * @return int
529
     *
530
     * @throws \Github\Exception\InvalidArgumentException
531
     */
532
    private function getDirectoryTimestamp(array $treeMetadata, $path)
533
    {
534
        $directoryTimestamp = 0000000000;
535
536
        $filteredTreeData = $this->filterTreeData($treeMetadata, $path, self::RECURSIVE);
537
538
        array_walk($filteredTreeData, function ($entry) use (&$directoryTimestamp, $path) {
539
            if ($entry[self::KEY_TYPE] === self::KEY_FILE
540
                && strpos($entry[self::KEY_PATH], $path) === 0
541
            ) {
542
                // @CHECKME: Should the directory Timestamp reflect the `getCreatedTimestamp` or `getLastUpdatedTimestamp`?
543
                $timestamp = $this->getCreatedTimestamp($entry[self::KEY_PATH])[self::KEY_TIMESTAMP];
544
545
                if ($timestamp > $directoryTimestamp) {
546
                    $directoryTimestamp = $timestamp;
547
                }
548
            }
549
        });
550
551
        return $directoryTimestamp;
552
    }
553
554
    private function normalizePathName($path)
555
    {
556
        return trim($path, '/');
557
    }
558
559
    /**
560
     * @param $path
561
     * @return array
562
     * @throws \League\Flysystem\NotSupportedException
563
     * @throws \Github\Exception\RuntimeException
564
     *
565
     * @throws \Github\Exception\InvalidArgumentException
566
     */
567
    private function metadataForDirectory($path)
568
    {
569
        $reference = $this->settings->getReference();
570
        $project = sprintf('%s/%s', $this->settings->getVendor(), $this->settings->getPackage());
571
572
        $url = sprintf(
573
            '%s/repos/%s/contents/%s?ref=%s',
574
            self::GITHUB_API_URL,
575
            $project,
576
            $path,
577
            $reference
578
        );
579
        $htmlUrl = sprintf(
580
            '%s/%s/blob/%s/%s',
581
            self::GITHUB_URL,
582
            $project,
583
            $reference,
584
            $path
585
        );
586
587
        $directoryContents =  $this->getDirectoryContents($path, self::RECURSIVE);
588
589
        $directoryMetadata = array_filter($directoryContents, function ($entry) use ($path) {
590
            return $entry[self::KEY_PATH] === $path;
591
        });
592
593
        $metadata = array_merge(
594
            $directoryMetadata[0],
595
            [
596
                self::KEY_TYPE => self::KEY_DIRECTORY,
597
                'url' => $url,
598
                'html_url' => $htmlUrl,
599
                '_links' => [
600
                    'self' => $url,
601
                    'html' => $htmlUrl
602
                ]
603
            ]
604
        );
605
        
606
        return $metadata;
607
    }
608
609
    /**
610
     * @param array $treeData
611
     *
612
     * @return array
613
     *
614
     * @throws \Github\Exception\InvalidArgumentException
615
     */
616
    private function addTimestamps(array $treeData)
617
    {
618
        return array_map(function ($entry) use ($treeData) {
619
            if ($entry[self::KEY_TYPE] === self::KEY_DIRECTORY) {
620
                $timestamp = $this->getDirectoryTimestamp($treeData, $entry[self::KEY_PATH]);
621
            } else {
622
                // @CHECKME: Should the Timestamp reflect the `getCreatedTimestamp` or `getLastUpdatedTimestamp`?
623
                $timestamp = $this->getCreatedTimestamp($entry[self::KEY_PATH])[self::KEY_TIMESTAMP];
624
            }
625
            $entry[self::KEY_TIMESTAMP] = $timestamp;
626
627
            return $entry;
628
        }, $treeData);
629
    }
630
631
    /**
632
     * @param $path
633
     * @param $function
634
     * @return array
635
     */
636
    private function filterCommits($path, callable $function)
637
    {
638
        $path = $this->normalizePathName($path);
639
640
        $commits = $this->commitsForFile($path);
641
642
        $subject = $function($commits);
643
644
        $time = new \DateTime($subject['commit']['committer']['date']);
645
646
        return [self::KEY_TIMESTAMP => $time->getTimestamp()];
647
    }
648
}
649