Passed
Push — master ( c2b789...51b57c )
by Eric
03:37 queued 01:57
created

LibrariesIO::repository()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 31
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 3
eloc 13
c 1
b 0
f 1
nc 3
nop 2
dl 0
loc 31
rs 9.8333
ccs 17
cts 17
cp 1
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * LibrariesIO - A simple API wrapper/client for the Libraries.io API.
7
 *
8
 * @author    Eric Sizemore <[email protected]>
9
 * @package   LibrariesIO
10
 * @link      https://www.secondversion.com/
11
 * @version   1.1.0
12
 * @copyright (C) 2023 Eric Sizemore
13
 * @license   The MIT License (MIT)
14
 */
15
namespace Esi\LibrariesIO;
16
17
// Exceptions and Attributes
18
use GuzzleHttp\Exception\{
19
    GuzzleException,
20
    ClientException
21
};
22
use Esi\LibrariesIO\Exception\RateLimitExceededException;
23
use InvalidArgumentException, JsonException;
24
use SensitiveParameter;
1 ignored issue
show
Bug introduced by
The type SensitiveParameter was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
25
26
// HTTP
27
use GuzzleHttp\{
28
    Client,
29
    HandlerStack
30
};
31
use Psr\Http\Message\ResponseInterface;
32
33
// Cache
34
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
35
use Kevinrob\GuzzleCache\{
36
    CacheMiddleware,
37
    Strategy\PrivateCacheStrategy,
38
    Storage\Psr6CacheStorage
39
};
40
41
use stdClass;
42
43
// Functions and constants
44
use function is_dir, is_writable, json_decode, preg_match;
45
use function str_contains, in_array, implode;
46
47
use const JSON_THROW_ON_ERROR;
48
49
/**
50
 * LibrariesIO - A simple API wrapper/client for the Libraries.io API.
51
 *
52
 * @author    Eric Sizemore <[email protected]>
53
 * @package   LibrariesIO
54
 * @link      https://www.secondversion.com/
55
 * @version   1.1.0
56
 * @copyright (C) 2023 Eric Sizemore
57
 * @license   The MIT License (MIT)
58
 *
59
 * Copyright (C) 2023 Eric Sizemore. All rights reserved.
60
 *
61
 * Permission is hereby granted, free of charge, to any person obtaining a copy
62
 * of this software and associated documentation files (the "Software"), to
63
 * deal in the Software without restriction, including without limitation the
64
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
65
 * sell copies of the Software, and to permit persons to whom the Software is
66
 * furnished to do so, subject to the following conditions:
67
 *
68
 * The above copyright notice and this permission notice shall be included in
69
 * all copies or substantial portions of the Software.
70
 *
71
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
72
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
73
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
74
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
75
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
76
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
77
 * THE SOFTWARE.
78
 */
79
class LibrariesIO
80
{
81
    /**
82
     * GuzzleHttp Client
83
     *
84
     * @var ?Client
85
     */
86
    public ?Client $client = null;
87
88
    /**
89
     * Base API endpoint.
90
     *
91
     * @var string
92
     */
93
    protected const API_URL = 'https://libraries.io/api/';
94
95
    /**
96
     * Libraries.io API key.
97
     *
98
     * @see https://libraries.io/account
99
     * @var ?string
100
     */
101
    protected ?string $apiKey = null;
102
103
    /**
104
     * Path to your cache folder on the file system.
105
     *
106
     * @var ?string
107
     */
108
    protected ?string $cachePath = null;
109
110
    /**
111
     * Constructor.
112
     *
113
     * @param string  $apiKey    Your Libraries.io API Key
114
     * @param ?string $cachePath The path to your cache on the filesystem
115
     */
116 40
    public function __construct(#[SensitiveParameter] string $apiKey, ?string $cachePath = null)
117
    {
118 40
        if (preg_match('/^[0-9a-fA-F]{32}$/', $apiKey) === 0) {
119 1
            throw new InvalidArgumentException('API key appears to be invalid, keys are typically alpha numeric and 32 chars in length');
120
        }
121
122 40
        $this->apiKey = $apiKey;
123
124 40
        if ($cachePath !== null && is_dir($cachePath) && is_writable($cachePath)) {
125 40
            $this->cachePath = $cachePath;
126
        }
127
    }
128
129
    /**
130
     * Builds our GuzzleHttp client.
131
     *
132
     * @access protected
133
     * @param  array<string, int|string> $query
134
     * @return Client
135
     */
136 30
    protected function makeClient(?array $query = null): Client
137
    {
138 30
        if ($this->client !== null) {
139 30
            return $this->client;
140
        }
141
142
        //@codeCoverageIgnoreStart
143
        // Some endpoints do not require any query parameters
144
        if ($query === null) {
145
            $query = [];
146
        }
147
148
        // Add the API key to the query
149
        $query['api_key'] = $this->apiKey; 
150
151
        // Client options
152
        $options = [
153
            'base_uri' => self::API_URL,
154
            'query'    => $query,
155
            'headers' => [
156
                'Accept' => 'application/json'
157
            ],
158
            'http_errors' => true
159
        ];
160
161
        // If we have a cache path, create our Cache handler
162
        if ($this->cachePath !== null) {
163
            // Create default HandlerStack
164
            $stack = HandlerStack::create();
165
166
            // Add this middleware to the top with `push`
167
            $stack->push(new CacheMiddleware(new PrivateCacheStrategy(
168
                new Psr6CacheStorage(new FilesystemAdapter('', 300, $this->cachePath))
169
            )), 'cache');
170
171
            // Add handler to $options
172
            $options += ['handler' => $stack];
173
        }
174
175
        // Build client
176
        $this->client = new Client($options);
177
178
        return $this->client;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->client returns the type null which is incompatible with the type-hinted return GuzzleHttp\Client.
Loading history...
179
        //@codeCoverageIgnoreEnd
180
    }
181
182
    /**
183
     * Performs the actual client request.
184
     *
185
     * @param string $endpoint
186
     * @param string $method
187
     * @return ResponseInterface
188
     * @throws ClientException|GuzzleException
189
     */
190 30
    protected function makeRequest(string $endpoint, string $method = 'get'): ResponseInterface
191
    {
192
        // Attempt the request
193
        try {
194
            /** @phpstan-ignore-next-line **/
195 30
            return match($method) {
196 30
                'get'    => $this->client->get($endpoint),   /** @phpstan-ignore-line **/
0 ignored issues
show
Bug introduced by
The method get() does not exist on null. ( Ignorable by Annotation )

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

196
                'get'    => $this->client->/** @scrutinizer ignore-call */ get($endpoint),   /** @phpstan-ignore-line **/

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
197 30
                'post'   => $this->client->post($endpoint),  /** @phpstan-ignore-line **/
198 30
                'put'    => $this->client->put($endpoint),   /** @phpstan-ignore-line **/
199 30
                'delete' => $this->client->delete($endpoint) /** @phpstan-ignore-line **/
200 30
            };
201 2
        } catch (ClientException $e) {
202 2
            if ($e->getResponse()->getStatusCode() === 429) {
203 1
                throw new RateLimitExceededException('Libraries.io API rate limit exceeded.', previous: $e);
204
            } else {
205 1
                throw $e;
206
            }
207
        }
208
    }
209
210
    /**
211
     * Performs a request to the 'platforms' endpoint.
212
     *
213
     * @param string $endpoint
214
     * @return ResponseInterface
215
     * @throws InvalidArgumentException|ClientException|GuzzleException
216
     */
217 4
    public function platform(string $endpoint = 'platforms'): ResponseInterface
218
    {
219
        // The only valid endpoint is 'platforms' currently
220 4
        if ($endpoint !== 'platforms') {
221 1
            throw new InvalidArgumentException('Invalid endpoint specified. Must be one of: platforms');
222
        }
223
224
        // Build query
225 3
        $this->makeClient();
226
227 3
        return $this->makeRequest($endpoint);
228
    }
229
230
    /**
231
     * Performs a request to the 'project' endpoint and a subset endpoint, which can be:
232
     * contributors, dependencies, dependent_repositories, dependents, search, sourcerank, or project
233
     *
234
     * @param string $endpoint
235
     * @param array<string, int|string> $options
236
     * @return ResponseInterface
237
     * @throws InvalidArgumentException|ClientException|GuzzleException
238
     */
239 10
    public function project(string $endpoint, array $options): ResponseInterface
240
    {
241
        // Make sure we have the format and options for $endpoint
242 10
        $endpointParameters = $this->endpointParameters('project', $endpoint);
243
244 10
        if ($endpointParameters === []) {
245 1
            throw new InvalidArgumentException(
246 1
                'Invalid endpoint specified. Must be one of: contributors, dependencies, dependent_repositories, dependents, search, sourcerank, or project'
247 1
            );
248
        }
249
250
        /** @var array<int, string> $endpointOptions **/        
251 9
        $endpointOptions = $endpointParameters['options'];
252
253 9
        if (!$this->verifyEndpointOptions($endpointOptions, $options)) {
254 1
            throw new InvalidArgumentException(
255 1
                '$options has not specified all required parameters. Parameters needed: ' . implode(', ', $endpointOptions)
256 1
            );
257
        }
258
259
        // Build query
260 8
        $query = [
261 8
            'page'     => $options['page'] ?? 1,
262 8
            'per_page' => $options['per_page'] ?? 30
263 8
        ];
264
265
        // If on the 'search' endpoint, we have to provide the query and sort parameters.
266 8
        if ($endpoint === 'search') {
267 2
            $query += [
268 2
                'q'    => $options['query'],
269 2
                'sort' => $this->searchVerifySortOption(/** @phpstan-ignore-line **/$options['sort']),
270 2
            ];
271
272
            // Search can also have: 'languages', 'licenses', 'keywords', 'platforms' as additional parameters
273 2
            $additionalParams = $this->searchAdditionalParams($options);
274
275 2
            if ($additionalParams !== []) {
276 2
                $query += $additionalParams;
277
            }
278
        }
279
280
        // Build the client
281 8
        $this->makeClient($query);
282
283
        // Attempt the request
284 8
        $endpointParameters['format'] = $this->processEndpointFormat(/** @phpstan-ignore-line **/$endpointParameters['format'], $options);
285
286 8
        return $this->makeRequest($endpointParameters['format']);
287
    }
288
289
    /**
290
     * Performs a request to the 'repository' endpoint and a subset endpoint, which can be:
291
     * dependencies, projects, or repository
292
     *
293
     * @param string $endpoint
294
     * @param array<string, int|string> $options
295
     * @return ResponseInterface
296
     * @throws InvalidArgumentException|ClientException|GuzzleException
297
     */
298 8
    public function repository(string $endpoint, array $options): ResponseInterface
299
    {
300
        // Make sure we have the format and options for $endpoint
301 8
        $endpointParameters = $this->endpointParameters('repository', $endpoint);
302
303 8
        if ($endpointParameters === []) {
304 1
            throw new InvalidArgumentException(
305 1
                'Invalid endpoint specified. Must be one of: dependencies, projects, or repository'
306 1
            );
307
        }
308
309
        /** @var array<int, string> $endpointOptions **/
310 7
        $endpointOptions = $endpointParameters['options'];
311
312 7
        if (!$this->verifyEndpointOptions($endpointOptions, $options)) {
313 1
            throw new InvalidArgumentException(
314 1
                '$options has not specified all required parameters. Parameters needed: ' . implode(', ', $endpointOptions)
315 1
            );
316
        }
317
318
        // Build query
319 6
        $this->makeClient([
320
            // Using pagination?
321 6
            'page' => $options['page'] ?? 1,
322 6
            'per_page' => $options['per_page'] ?? 30
323 6
        ]);
324
325
        // Attempt the request
326 6
        $endpointParameters['format'] = $this->processEndpointFormat(/** @phpstan-ignore-line **/$endpointParameters['format'], $options);
327
328 6
        return $this->makeRequest($endpointParameters['format']);
329
    }
330
331
    /**
332
     * Performs a request to the 'user' endpoint and a subset endpoint, which can be:
333
     * dependencies, package_contributions, packages, repositories, repository_contributions, or subscriptions
334
     *
335
     * @param string $endpoint
336
     * @param array<string, int|string> $options
337
     * @return ResponseInterface
338
     * @throws InvalidArgumentException|ClientException|GuzzleException
339
     */
340 11
    public function user(string $endpoint, array $options): ResponseInterface
341
    {
342
        // Make sure we have the format and options for $endpoint
343 11
        $endpointParameters = $this->endpointParameters('user', $endpoint);
344
345 11
        if ($endpointParameters === []) {
346 1
            throw new InvalidArgumentException(
347 1
                'Invalid endpoint specified. Must be one of: dependencies, package_contributions, packages, repositories, repository_contributions, or subscriptions'
348 1
            );
349
        }
350
351
        /** @var array<int, string> $endpointOptions **/
352 10
        $endpointOptions = $endpointParameters['options'];
353
354 10
        if (!$this->verifyEndpointOptions($endpointOptions, $options)) {
355 1
            throw new InvalidArgumentException(
356 1
                '$options has not specified all required parameters. Parameters needed: ' . implode(', ', $endpointOptions)
357 1
                . '. (login can be: username or username/repo) depending on the endpoint)'
358 1
            );
359
        }
360
361
        // Build query
362 9
        $this->makeClient([
363 9
            'page' => $options['page'] ?? 1,
364 9
            'per_page' => $options['per_page'] ?? 30
365 9
        ]);
366
367
        // Attempt the request
368 9
        $endpointParameters['format'] = $this->processEndpointFormat(/** @phpstan-ignore-line **/$endpointParameters['format'], $options);
369
370 9
        return $this->makeRequest($endpointParameters['format']);
371
    }
372
373
    /**
374
     * Performs a request to the 'subscription' endpoint and a subset endpoint, which can be:
375
     * subscribe, check, update, unsubscribe
376
     *
377
     * @param string $endpoint
378
     * @param array<string, int|string> $options
379
     * @return ResponseInterface
380
     * @throws InvalidArgumentException|ClientException|GuzzleException
381
     */
382 6
    public function subscription(string $endpoint, array $options): ResponseInterface
383
    {
384
        // Make sure we have the format and options for $endpoint
385 6
        $endpointParameters = $this->endpointParameters('subscription', $endpoint);
386
387 6
        if ($endpointParameters === []) {
388 1
            throw new InvalidArgumentException(
389 1
                'Invalid endpoint specified. Must be one of: subscribe, check, update, or unsubscribe'
390 1
            );
391
        }
392
393
        /** @var array<int, string> $endpointOptions **/
394 5
        $endpointOptions = $endpointParameters['options'];
395
396 5
        if (!$this->verifyEndpointOptions($endpointOptions, $options)) {
397 1
            throw new InvalidArgumentException(
398 1
                '$options has not specified all required parameters. Parameters needed: ' . implode(', ', $endpointOptions)
399 1
            );
400
        }
401
402
        // Build query
403 4
        $query = [];
404
405 4
        if (isset($options['include_prerelease']) && ($endpoint === 'subscribe' || $endpoint === 'update')) {
406 2
            $query['include_prerelease'] = $options['include_prerelease'];
407
        }
408
409
        /** @phpstan-ignore-next-line **/
410 4
        $method = match($endpoint) {
411 4
            'subscribe'   => 'post',
412 4
            'check'       => 'get',
413 4
            'update'      => 'put',
414 4
            'unsubscribe' => 'delete'
415 4
        };
416
417 4
        $this->makeClient($query);
418
419
        // Attempt the request
420 4
        $endpointParameters['format'] = $this->processEndpointFormat(/** @phpstan-ignore-line **/$endpointParameters['format'], $options);
421
422 4
        return $this->makeRequest($endpointParameters['format'], $method);
423
    }
424
425
    /**
426
     * Processes the available parameters for a given endpoint.
427
     *
428
     * @param string $endpoint
429
     * @param string $subset
430
     * @return array<string, array<string>|string>
431
     */
432 35
    protected function endpointParameters(string $endpoint, string $subset): array
433
    {
434 35
        static $projectParameters = [
435 35
            'contributors'           => ['format' => ':platform/:name/contributors'          , 'options' => ['platform', 'name']],
436 35
            'dependencies'           => ['format' => ':platform/:name/:version/dependencies' , 'options' => ['platform', 'name', 'version']],
437 35
            'dependent_repositories' => ['format' => ':platform/:name/dependent_repositories', 'options' => ['platform', 'name']],
438 35
            'dependents'             => ['format' => ':platform/:name/dependents'            , 'options' => ['platform', 'name']],
439 35
            'search'                 => ['format' => 'search'                                , 'options' => ['query', 'sort']],
440 35
            'sourcerank'             => ['format' => ':platform/:name/sourcerank'            , 'options' => ['platform', 'name']],
441 35
            'project'                => ['format' => ':platform/:name'                       , 'options' => ['platform', 'name']]
442 35
        ];
443
444 35
        static $repositoryParameters = [
445 35
            'dependencies' => ['format' => 'github/:owner/:name/dependencies', 'options' => ['owner', 'name']],
446 35
            'projects'     => ['format' => 'github/:owner/:name/projects'    , 'options' => ['owner', 'name']],
447 35
            'repository'   => ['format' => 'github/:owner/:name'             , 'options' => ['owner', 'name']]
448 35
        ];
449
450 35
        static $userParameters = [
451 35
            'dependencies'             => ['format' => 'github/:login/dependencies'            , 'options' => ['login']],
452 35
            'package_contributions'    => ['format' => 'github/:login/project-contributions'   , 'options' => ['login']],
453 35
            'packages'                 => ['format' => 'github/:login/projects'                , 'options' => ['login']],
454 35
            'repositories'             => ['format' => 'github/:login/repositories'            , 'options' => ['login']],
455 35
            'repository_contributions' => ['format' => 'github/:login/repository-contributions', 'options' => ['login']],
456 35
            'subscriptions'            => ['format' => 'subscriptions'                         , 'options' => []],
457 35
            'user'                     => ['format' => 'github/:login'                         , 'options' => ['login']]
458 35
        ];
459
460 35
        static $subscriptionParameters = [
461 35
            'subscribe'   => ['format' => 'subscriptions/:platform/:name', 'options' => ['platform', 'name', 'include_prerelease']],
462 35
            'check'       => ['format' => 'subscriptions/:platform/:name', 'options' => ['platform', 'name']],
463 35
            'update'      => ['format' => 'subscriptions/:platform/:name', 'options' => ['platform', 'name', 'include_prerelease']],
464 35
            'unsubscribe' => ['format' => 'subscriptions/:platform/:name', 'options' => ['platform', 'name']]
465 35
        ];
466
467 35
        return match($endpoint) {
468 35
            'project'      => $projectParameters[$subset] ?? [],
469 35
            'repository'   => $repositoryParameters[$subset] ?? [],
470 35
            'user'         => $userParameters[$subset] ?? [],
471 35
            'subscription' => $subscriptionParameters[$subset] ?? [],
472 35
            default        => []
473 35
        };
474
    }
475
476
    /**
477
     * Each endpoint class will have a 'subset' of endpoints that fall under it. This 
478
     * function handles returning a formatted endpoint for the Client.
479
     *
480
     * @param string $format
481
     * @param array<string, int|string> $options
482
     * @return string
483
     */
484 27
    protected function processEndpointFormat(string $format, array $options): string
485
    {
486 27
        if (str_contains($format, ':') === false) {
487 3
            return $format;
488
        }
489
490 24
        foreach ($options as $key => $val) {
491 24
            if ($key === 'page' || $key === 'per_page') {
492 3
                continue;
493
            }
494
            /** @var string $val **/
495 24
            $format = str_replace(":$key", $val, $format);
496
        }
497 24
        return $format;
498
    }
499
500
    /**
501
     * Helper function to make sure that the $options passed to the child class' makeRequest() 
502
     * contains the required options listed in the endpoints options.
503
     *
504
     * @param array<int, string>        $endpointOptions
505
     * @param array<string, int|string> $options
506
     * @return bool
507
     */
508 31
    protected function verifyEndpointOptions(array $endpointOptions, array $options): bool
509
    {
510 31
        $noError = true;
511
512 31
        foreach ($endpointOptions as $endpointOption) {
513 30
            if (!isset($options[$endpointOption])) {
514 4
                $noError = false;
515 4
                break;
516
            }
517
        }
518 31
        return $noError;
519
    }
520
521
    /**
522
     * Processes the additional parameters that can be used by the search endpoint.
523
     *
524
     * @param array<string, int|string> $options
525
     * @return array<string, int|string>
526
     */
527 2
    protected function searchAdditionalParams(array $options): array
528
    {
529 2
        $additionalParams = [];
530
531 2
        foreach (['languages', 'licenses', 'keywords', 'platforms'] as $option) {
532 2
            if (isset($options[$option])) {
533 2
                $additionalParams[$option] = $options[$option];
534
            }
535
        }
536 2
        return $additionalParams;
537
    }
538
539
    /**
540
     * Verifies that the provided sort option is a valid one that libraries.io's API supports.
541
     *
542
     * @param string $sort
543
     * @return string
544
     */
545 2
    protected function searchVerifySortOption(string $sort): string
546
    {
547 2
        static $sortOptions = [
548 2
            'rank', 'stars', 'dependents_count', 
549 2
            'dependent_repos_count', 'latest_release_published_at', 
550 2
            'contributions_count', 'created_at'
551 2
        ];
552
553 2
        if (!in_array($sort, $sortOptions, true)) {
554 1
            $sort = 'rank';
555
        }
556 2
        return $sort;
557
    }
558
559
    /**
560
     * Returns the jSON data as-is from the API.
561
     *
562
     * @param ResponseInterface $response The response object from makeRequest()
563
     * @return string
564
     */
565 3
    public function raw(ResponseInterface $response): string
566
    {
567 3
        return $response->getBody()->getContents();
568
    }
569
570
    /**
571
     * Decodes the jSON returned from the API. Returns as an associative array.
572
     *
573
     * @param ResponseInterface $response The response object from makeRequest()
574
     * @return array<mixed>
575
     * @throws JsonException
576
     */
577 1
    public function toArray(ResponseInterface $response): array
578
    {
579
        /** @var array<mixed> $json **/
580 1
        $json = json_decode($this->raw($response), true, flags: JSON_THROW_ON_ERROR);
581
582 1
        return $json;
583
    }
584
585
    /**
586
     * Decodes the jSON returned from the API. Returns as an array of objects.
587
     *
588
     * @param ResponseInterface $response The response object from makeRequest()
589
     * @return stdClass
590
     * @throws JsonException
591
     */
592 1
    public function toObject(ResponseInterface $response): stdClass
593
    {
594
        /** @var stdClass $json **/
595 1
        $json = json_decode($this->raw($response), false, flags: JSON_THROW_ON_ERROR);
596
        
597 1
        return $json;
598
    }
599
}
600