Passed
Push — master ( f87186...3e5cc4 )
by Eric
22:31 queued 09:43
created

LibrariesIO   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 453
Duplicated Lines 0 %

Test Coverage

Coverage 98.68%

Importance

Changes 4
Bugs 0 Features 1
Metric Value
eloc 146
c 4
b 0
f 1
dl 0
loc 453
rs 9.36
ccs 149
cts 151
cp 0.9868
wmc 38

16 Methods

Rating   Name   Duplication   Size   Complexity  
A toArray() 0 6 1
A toObject() 0 6 1
A platform() 0 11 2
A repository() 0 21 1
A user() 0 20 1
A __construct() 0 14 4
A searchVerifySortOption() 0 12 2
A makeClient() 0 44 5
A searchAdditionalParams() 0 10 3
A processEndpointFormat() 0 14 4
A endpointParameters() 0 41 1
A project() 0 38 3
A makeRequest() 0 23 4
A verifyEndpointOptions() 0 6 3
A subscription() 0 21 2
A raw() 0 3 1
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
 * @version   1.1.0
10
 * @copyright (C) 2023 Eric Sizemore
11
 * @license   The MIT License (MIT)
12
 *
13
 * Copyright (C) 2023 Eric Sizemore <https://www.secondversion.com/>.
14
 *
15
 * Permission is hereby granted, free of charge, to any person obtaining a copy
16
 * of this software and associated documentation files (the "Software"), to
17
 * deal in the Software without restriction, including without limitation the
18
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
19
 * sell copies of the Software, and to permit persons to whom the Software is
20
 * furnished to do so, subject to the following conditions:
21
 *
22
 * The above copyright notice and this permission notice shall be included in
23
 * all copies or substantial portions of the Software.
24
 *
25
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
31
 * THE SOFTWARE.
32
 */
33
34
namespace Esi\LibrariesIO;
35
36
// Exceptions and Attributes
37
use GuzzleHttp\Exception\{
38
    GuzzleException,
39
    ClientException
40
};
41
use Esi\LibrariesIO\Exception\RateLimitExceededException;
42
use InvalidArgumentException;
43
use JsonException;
44
use RuntimeException;
45
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...
46
47
// HTTP
48
use GuzzleHttp\{
49
    Client,
50
    HandlerStack
51
};
52
use Psr\Http\Message\ResponseInterface;
53
54
// Cache
55
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
56
use Kevinrob\GuzzleCache\{
57
    CacheMiddleware,
58
    Strategy\PrivateCacheStrategy,
59
    Storage\Psr6CacheStorage
60
};
61
62
use stdClass;
63
64
// Functions and constants
65
use function is_dir;
66
use function is_writable;
67
use function json_decode;
68
use function preg_match;
69
use function str_contains;
70
use function in_array;
71
use function implode;
72
73
use const JSON_THROW_ON_ERROR;
74
75
/**
76
 * Main class
77
 */
78
class LibrariesIO
79
{
80
    /**
81
     * GuzzleHttp Client
82
     *
83
     */
84
    public ?Client $client = null;
85
86
    /**
87
     * Base API endpoint.
88
     *
89
     * @var string
90
     */
91
    protected const API_URL = 'https://libraries.io/api/';
92
93
    /**
94
     * Libraries.io API key.
95
     *
96
     * @see https://libraries.io/account
97
     */
98
    private ?string $apiKey = null;
99
100
    /**
101
     * Path to your cache folder on the file system.
102
     *
103
     */
104
    private ?string $cachePath = null;
105
106
    /**
107
     * Constructor.
108
     *
109
     * @param string  $apiKey    Your Libraries.io API Key
110
     * @param ?string $cachePath The path to your cache on the filesystem
111
     */
112 40
    public function __construct(#[SensitiveParameter] string $apiKey, ?string $cachePath = null)
113
    {
114 40
        if (preg_match('/^[0-9a-fA-F]{32}$/', $apiKey) === 0) {
115 1
            throw new InvalidArgumentException('API key appears to be invalid, keys are typically alpha numeric and 32 chars in length');
116
        }
117
118 40
        $this->apiKey = $apiKey;
119 40
        if (!is_dir((string) $cachePath)) {
120
            return;
121
        }
122 40
        if (!is_writable((string) $cachePath)) {
123
            return;
124
        }
125 40
        $this->cachePath = $cachePath;
126
    }
127
128
    /**
129
     * Builds our GuzzleHttp client.
130
     *
131
     * @access protected
132
     * @param  array<string, int|string> $query
133
     */
134 30
    private function makeClient(?array $query = null): Client
135
    {
136
        // From the test suite/PHPUnit?
137 30
        if ($this->client instanceof Client && $this->apiKey === '098f6bcd4621d373cade4e832627b4f6') {
138 30
            return $this->client;
139
        }
140
141
        //@codeCoverageIgnoreStart
142
        // Some endpoints do not require any query parameters
143
        if ($query === null) {
144
            $query = [];
145
        }
146
147
        // Add the API key to the query
148
        $query['api_key'] = $this->apiKey;
149
150
        // Client options
151
        $options = [
152
            'base_uri' => self::API_URL,
153
            'query'    => $query,
154
            'headers' => [
155
                'Accept' => 'application/json'
156
            ],
157
            'http_errors' => true
158
        ];
159
160
        // If we have a cache path, create our Cache handler
161
        if ($this->cachePath !== null) {
162
            // Create default HandlerStack
163
            $stack = HandlerStack::create();
164
165
            // Add this middleware to the top with `push`
166
            $stack->push(new CacheMiddleware(new PrivateCacheStrategy(
167
                new Psr6CacheStorage(new FilesystemAdapter('', 300, $this->cachePath))
168
            )), 'cache');
169
170
            // Add handler to $options
171
            $options += ['handler' => $stack];
172
        }
173
174
        // Build client
175
        $this->client = new Client($options);
176
177
        return $this->client;
178
        //@codeCoverageIgnoreEnd
179
    }
180
181
    /**
182
     * Performs the actual client request.
183
     *
184
     * @throws ClientException|GuzzleException|RateLimitExceededException|RuntimeException
185
     */
186 30
    private function makeRequest(string $endpoint, string $method = 'GET'): ResponseInterface
187
    {
188
        // Attempt the request
189
        try {
190 30
            $method = strtoupper($method);
191
192 30
            $request = match($method) {
193 30
                'GET', 'POST', 'PUT', 'DELETE' => $this->client?->request($method, $endpoint),
194 30
                default => $this->client?->request('GET', $endpoint)
195 30
            };
196
197
            // Shouldn't happen...
198 28
            if (!$request instanceof ResponseInterface) {
199
                //@codeCoverageIgnoreStart
200
                throw new RuntimeException('$this->client does not appear to be a valid \GuzzleHttp\Client instance');
201
                //@codeCoverageIgnoreEnd
202
            }
203 28
            return $request;
204 2
        } catch (ClientException $e) {
205 2
            if ($e->getResponse()->getStatusCode() === 429) {
206 1
                throw new RateLimitExceededException('Libraries.io API rate limit exceeded.', previous: $e);
207
            }
208 1
            throw $e;
209
        }
210
    }
211
212
    /**
213
     * Performs a request to the 'platforms' endpoint.
214
     *
215
     * @throws InvalidArgumentException|ClientException|GuzzleException|RateLimitExceededException
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 array<string, int|string> $options
235
     * @throws InvalidArgumentException|ClientException|GuzzleException|RateLimitExceededException
236
     */
237 10
    public function project(string $endpoint, array $options): ResponseInterface
238
    {
239
        // Make sure we have the format and options for $endpoint
240 10
        $endpointParameters = self::endpointParameters('project', $endpoint);
241
242
        /** @var array<int, string> $endpointOptions **/
243 9
        $endpointOptions = $endpointParameters['options'];
244
245 9
        self::verifyEndpointOptions($endpointOptions, $options);
246
247
        // Build query
248 8
        $query = [
249 8
            'page'     => $options['page'] ?? 1,
250 8
            'per_page' => $options['per_page'] ?? 30
251 8
        ];
252
253
        // If on the 'search' endpoint, we have to provide the query and sort parameters.
254 8
        if ($endpoint === 'search') {
255 2
            $query += [
256 2
                'q'    => $options['query'],
257 2
                'sort' => self::searchVerifySortOption(/** @phpstan-ignore-line **/$options['sort']),
258 2
            ];
259
260
            // Search can also have: 'languages', 'licenses', 'keywords', 'platforms' as additional parameters
261 2
            $additionalParams = self::searchAdditionalParams($options);
262
263 2
            if ($additionalParams !== []) {
264 2
                $query += $additionalParams;
265
            }
266
        }
267
268
        // Build the client
269 8
        $this->makeClient($query);
270
271
        // Attempt the request
272 8
        $endpointParameters['format'] = self::processEndpointFormat(/** @phpstan-ignore-line **/$endpointParameters['format'], $options);
273
274 8
        return $this->makeRequest($endpointParameters['format']);
275
    }
276
277
    /**
278
     * Performs a request to the 'repository' endpoint and a subset endpoint, which can be:
279
     * dependencies, projects, or repository
280
     *
281
     * @param array<string, int|string> $options
282
     * @throws InvalidArgumentException|ClientException|GuzzleException|RateLimitExceededException
283
     */
284 8
    public function repository(string $endpoint, array $options): ResponseInterface
285
    {
286
        // Make sure we have the format and options for $endpoint
287 8
        $endpointParameters = self::endpointParameters('repository', $endpoint);
288
289
        /** @var array<int, string> $endpointOptions **/
290 7
        $endpointOptions = $endpointParameters['options'];
291
292 7
        self::verifyEndpointOptions($endpointOptions, $options);
293
294
        // Build query
295 6
        $this->makeClient([
296
            // Using pagination?
297 6
            'page' => $options['page'] ?? 1,
298 6
            'per_page' => $options['per_page'] ?? 30
299 6
        ]);
300
301
        // Attempt the request
302 6
        $endpointParameters['format'] = self::processEndpointFormat(/** @phpstan-ignore-line **/$endpointParameters['format'], $options);
303
304 6
        return $this->makeRequest($endpointParameters['format']);
305
    }
306
307
    /**
308
     * Performs a request to the 'user' endpoint and a subset endpoint, which can be:
309
     * dependencies, package_contributions, packages, repositories, repository_contributions, or subscriptions
310
     *
311
     * @param array<string, int|string> $options
312
     * @throws InvalidArgumentException|ClientException|GuzzleException|RateLimitExceededException
313
     */
314 11
    public function user(string $endpoint, array $options): ResponseInterface
315
    {
316
        // Make sure we have the format and options for $endpoint
317 11
        $endpointParameters = self::endpointParameters('user', $endpoint);
318
319
        /** @var array<int, string> $endpointOptions **/
320 10
        $endpointOptions = $endpointParameters['options'];
321
322 10
        self::verifyEndpointOptions($endpointOptions, $options);
323
324
        // Build query
325 9
        $this->makeClient([
326 9
            'page' => $options['page'] ?? 1,
327 9
            'per_page' => $options['per_page'] ?? 30
328 9
        ]);
329
330
        // Attempt the request
331 9
        $endpointParameters['format'] = self::processEndpointFormat(/** @phpstan-ignore-line **/$endpointParameters['format'], $options);
332
333 9
        return $this->makeRequest($endpointParameters['format']);
334
    }
335
336
    /**
337
     * Performs a request to the 'subscription' endpoint and a subset endpoint, which can be:
338
     * subscribe, check, update, unsubscribe
339
     *
340
     * @param array<string, int|string> $options
341
     * @throws InvalidArgumentException|ClientException|GuzzleException|RateLimitExceededException
342
     */
343 6
    public function subscription(string $endpoint, array $options): ResponseInterface
344
    {
345
        // Make sure we have the format and options for $endpoint
346 6
        $endpointParameters = self::endpointParameters('subscription', $endpoint);
347
348
        /** @var array<int, string> $endpointOptions **/
349 5
        $endpointOptions = $endpointParameters['options'];
350
351 5
        self::verifyEndpointOptions($endpointOptions, $options);
352
353
        // Build query
354 4
        if (isset($options['include_prerelease'])) {
355 2
            $query = ['include_prerelease' => $options['include_prerelease']];
356
        }
357
358 4
        $this->makeClient($query ?? []);
359
360
        // Attempt the request
361 4
        $endpointParameters['format'] = self::processEndpointFormat(/** @phpstan-ignore-line **/$endpointParameters['format'], $options);
362
363 4
        return $this->makeRequest($endpointParameters['format'], /** @phpstan-ignore-line **/$endpointParameters['method']);
364
    }
365
366
    /**
367
     * Processes the available parameters for a given endpoint.
368
     *
369
     *
370
     * @return array<string, array<string>|string>
371
     * @throws InvalidArgumentException
372
     */
373 35
    private static function endpointParameters(string $endpoint, string $subset): array
374
    {
375 35
        static $projectParameters = [
376 35
            'contributors'           => ['format' => ':platform/:name/contributors'          , 'options' => ['platform', 'name']],
377 35
            'dependencies'           => ['format' => ':platform/:name/:version/dependencies' , 'options' => ['platform', 'name', 'version']],
378 35
            'dependent_repositories' => ['format' => ':platform/:name/dependent_repositories', 'options' => ['platform', 'name']],
379 35
            'dependents'             => ['format' => ':platform/:name/dependents'            , 'options' => ['platform', 'name']],
380 35
            'search'                 => ['format' => 'search'                                , 'options' => ['query', 'sort']],
381 35
            'sourcerank'             => ['format' => ':platform/:name/sourcerank'            , 'options' => ['platform', 'name']],
382 35
            'project'                => ['format' => ':platform/:name'                       , 'options' => ['platform', 'name']]
383 35
        ];
384
385 35
        static $repositoryParameters = [
386 35
            'dependencies' => ['format' => 'github/:owner/:name/dependencies', 'options' => ['owner', 'name']],
387 35
            'projects'     => ['format' => 'github/:owner/:name/projects'    , 'options' => ['owner', 'name']],
388 35
            'repository'   => ['format' => 'github/:owner/:name'             , 'options' => ['owner', 'name']]
389 35
        ];
390
391 35
        static $userParameters = [
392 35
            'dependencies'             => ['format' => 'github/:login/dependencies'            , 'options' => ['login']],
393 35
            'package_contributions'    => ['format' => 'github/:login/project-contributions'   , 'options' => ['login']],
394 35
            'packages'                 => ['format' => 'github/:login/projects'                , 'options' => ['login']],
395 35
            'repositories'             => ['format' => 'github/:login/repositories'            , 'options' => ['login']],
396 35
            'repository_contributions' => ['format' => 'github/:login/repository-contributions', 'options' => ['login']],
397 35
            'subscriptions'            => ['format' => 'subscriptions'                         , 'options' => []],
398 35
            'user'                     => ['format' => 'github/:login'                         , 'options' => ['login']]
399 35
        ];
400
401 35
        static $subscriptionParameters = [
402 35
            'subscribe'   => ['format' => 'subscriptions/:platform/:name', 'options' => ['platform', 'name', 'include_prerelease'], 'method' => 'post'],
403 35
            'check'       => ['format' => 'subscriptions/:platform/:name', 'options' => ['platform', 'name'], 'method' => 'get'],
404 35
            'update'      => ['format' => 'subscriptions/:platform/:name', 'options' => ['platform', 'name', 'include_prerelease'], 'method' => 'put'],
405 35
            'unsubscribe' => ['format' => 'subscriptions/:platform/:name', 'options' => ['platform', 'name'], 'method' => 'delete']
406 35
        ];
407
408 35
        return match($endpoint) {
409 35
            'project'      => $projectParameters[$subset] ?? throw new InvalidArgumentException('Invalid endpoint subset specified.'),
410 35
            'repository'   => $repositoryParameters[$subset] ?? throw new InvalidArgumentException('Invalid endpoint subset specified.'),
411 35
            'user'         => $userParameters[$subset] ?? throw new InvalidArgumentException('Invalid endpoint subset specified.'),
412 35
            'subscription' => $subscriptionParameters[$subset] ?? throw new InvalidArgumentException('Invalid endpoint subset specified.'),
413 35
            default        => throw new InvalidArgumentException('Invalid endpoint subset specified.')
414 35
        };
415
    }
416
417
    /**
418
     * Each endpoint class will have a 'subset' of endpoints that fall under it. This
419
     * function handles returning a formatted endpoint for the Client.
420
     *
421
     * @param array<string, int|string> $options
422
     */
423 27
    private static function processEndpointFormat(string $format, array $options): string
424
    {
425 27
        if (str_contains($format, ':') === false) {
426 3
            return $format;
427
        }
428
429 24
        foreach ($options as $key => $val) {
430 24
            if (in_array($key, ['page', 'per_page'], true)) {
431 3
                continue;
432
            }
433
            /** @var string $val **/
434 24
            $format = str_replace(":$key", $val, $format);
435
        }
436 24
        return $format;
437
    }
438
439
    /**
440
     * Helper function to make sure that the $options passed makeRequest()
441
     * contains the required options listed in the endpoints options.
442
     *
443
     * @param array<int, string>        $endpointOptions
444
     * @param array<string, int|string> $options
445
     * @throws InvalidArgumentException
446
     */
447 31
    private static function verifyEndpointOptions(array $endpointOptions, array $options): void
448
    {
449 31
        foreach ($endpointOptions as $endpointOption) {
450 30
            if (!isset($options[$endpointOption])) {
451 4
                throw new InvalidArgumentException(
452 4
                    '$options has not specified all required parameters. Parameters needed: ' . implode(', ', $endpointOptions)
453 4
                );
454
            }
455
        }
456
    }
457
458
    /**
459
     * Processes the additional parameters that can be used by the search endpoint.
460
     *
461
     * @param array<string, int|string> $options
462
     * @return array<string, int|string>
463
     */
464 2
    private static function searchAdditionalParams(array $options): array
465
    {
466 2
        $additionalParams = [];
467
468 2
        foreach (['languages', 'licenses', 'keywords', 'platforms'] as $option) {
469 2
            if (isset($options[$option])) {
470 2
                $additionalParams[$option] = $options[$option];
471
            }
472
        }
473 2
        return $additionalParams;
474
    }
475
476
    /**
477
     * Verifies that the provided sort option is a valid one that libraries.io's API supports.
478
     *
479
     */
480 2
    private static function searchVerifySortOption(string $sort): string
481
    {
482 2
        static $sortOptions = [
483 2
            'rank', 'stars', 'dependents_count',
484 2
            'dependent_repos_count', 'latest_release_published_at',
485 2
            'contributions_count', 'created_at'
486 2
        ];
487
488 2
        if (!in_array($sort, $sortOptions, true)) {
489 1
            return 'rank';
490
        }
491 1
        return $sort;
492
    }
493
494
    /**
495
     * Returns the jSON data as-is from the API.
496
     *
497
     * @param ResponseInterface $response The response object from makeRequest()
498
     */
499 3
    public function raw(ResponseInterface $response): string
500
    {
501 3
        return $response->getBody()->getContents();
502
    }
503
504
    /**
505
     * Decodes the jSON returned from the API. Returns as an associative array.
506
     *
507
     * @param ResponseInterface $response The response object from makeRequest()
508
     * @return array<mixed>
509
     * @throws JsonException
510
     */
511 1
    public function toArray(ResponseInterface $response): array
512
    {
513
        /** @var array<mixed> $json **/
514 1
        $json = json_decode($this->raw($response), true, flags: JSON_THROW_ON_ERROR);
515
516 1
        return $json;
517
    }
518
519
    /**
520
     * Decodes the jSON returned from the API. Returns as an array of objects.
521
     *
522
     * @param ResponseInterface $response The response object from makeRequest()
523
     * @throws JsonException
524
     */
525 1
    public function toObject(ResponseInterface $response): stdClass
526
    {
527
        /** @var stdClass $json **/
528 1
        $json = json_decode($this->raw($response), false, flags: JSON_THROW_ON_ERROR);
529
530 1
        return $json;
531
    }
532
}
533