Passed
Push — master ( 3e5cc4...ce331f )
by Eric
12:39
created

LibrariesIO::searchAdditionalParams()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 3
eloc 5
c 1
b 0
f 1
nc 3
nop 1
dl 0
loc 11
rs 10
ccs 6
cts 6
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
 * @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
 * @see \Esi\LibrariesIO\Tests\LibrariesIOTest
78
 */
79
class LibrariesIO
80
{
81
    /**
82
     * GuzzleHttp Client
83
     *
84
     */
85
    public ?Client $client = null;
86
87
    /**
88
     * Base API endpoint.
89
     *
90
     * @var string
91
     */
92
    protected const API_URL = 'https://libraries.io/api/';
93
94
    /**
95
     * Libraries.io API key.
96
     *
97
     * @see https://libraries.io/account
98
     */
99
    private ?string $apiKey = null;
100
101
    /**
102
     * Path to your cache folder on the file system.
103
     *
104
     */
105
    private ?string $cachePath = null;
106
107
    /**
108
     * Constructor.
109
     *
110
     * @param string  $apiKey    Your Libraries.io API Key
111
     * @param ?string $cachePath The path to your cache on the filesystem
112
     */
113 40
    public function __construct(#[SensitiveParameter] string $apiKey, ?string $cachePath = null)
114
    {
115 40
        if (preg_match('/^[0-9a-fA-F]{32}$/', $apiKey) === 0) {
116 1
            throw new InvalidArgumentException('API key appears to be invalid, keys are typically alpha numeric and 32 chars in length');
117
        }
118
119 40
        $this->apiKey = $apiKey;
120
121 40
        if (is_dir((string) $cachePath) && is_writable((string) $cachePath)) {
122 40
            $this->cachePath = $cachePath;
123
        }
124
    }
125
126
    /**
127
     * Builds our GuzzleHttp client.
128
     *
129
     * @access protected
130
     * @param  array<string, int|string> $query
131
     */
132 30
    private function makeClient(?array $query = null): Client
133
    {
134
        // From the test suite/PHPUnit?
135 30
        if ($this->client instanceof Client && $this->apiKey === '098f6bcd4621d373cade4e832627b4f6') {
136 30
            return $this->client;
137
        }
138
139
        //@codeCoverageIgnoreStart
140
        // Some endpoints do not require any query parameters
141
        if ($query === null) {
142
            $query = [];
143
        }
144
145
        // Add the API key to the query
146
        $query['api_key'] = $this->apiKey;
147
148
        // Client options
149
        $options = [
150
            'base_uri' => self::API_URL,
151
            'query'    => $query,
152
            'headers'  => [
153
                'Accept' => 'application/json',
154
            ],
155
            'http_errors' => true,
156
        ];
157
158
        // If we have a cache path, create our Cache handler
159
        if ($this->cachePath !== null) {
160
            // Create default HandlerStack
161
            $stack = HandlerStack::create();
162
163
            // Add this middleware to the top with `push`
164
            $stack->push(new CacheMiddleware(new PrivateCacheStrategy(
165
                new Psr6CacheStorage(new FilesystemAdapter('', 300, $this->cachePath))
166
            )), 'cache');
167
168
            // Add handler to $options
169
            $options += ['handler' => $stack];
170
        }
171
172
        // Build client
173
        $this->client = new Client($options);
174
175
        return $this->client;
176
        //@codeCoverageIgnoreEnd
177
    }
178
179
    /**
180
     * Performs the actual client request.
181
     *
182
     * @throws ClientException|GuzzleException|RateLimitExceededException|RuntimeException
183
     */
184 30
    private function makeRequest(string $endpoint, string $method = 'GET'): ResponseInterface
185
    {
186
        // Attempt the request
187
        try {
188 30
            $method = strtoupper($method);
189
190 30
            $request = match($method) {
191 30
                'GET', 'POST', 'PUT', 'DELETE' => $this->client?->request($method, $endpoint),
192 30
                default => $this->client?->request('GET', $endpoint)
193 30
            };
194
195
            // Shouldn't happen...
196 28
            if (!$request instanceof ResponseInterface) {
197
                //@codeCoverageIgnoreStart
198
                throw new RuntimeException('$this->client does not appear to be a valid \GuzzleHttp\Client instance');
199
                //@codeCoverageIgnoreEnd
200
            }
201
202 28
            return $request;
203 2
        } catch (ClientException $clientException) {
204 2
            if ($clientException->getResponse()->getStatusCode() === 429) {
205 1
                throw new RateLimitExceededException('Libraries.io API rate limit exceeded.', previous: $clientException);
206
            }
207
208 1
            throw $clientException;
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
434
            /** @var string $val **/
435 24
            $format = str_replace(':' . $key, $val, $format);
436
        }
437
438 24
        return $format;
439
    }
440
441
    /**
442
     * Helper function to make sure that the $options passed makeRequest()
443
     * contains the required options listed in the endpoints options.
444
     *
445
     * @param array<int, string>        $endpointOptions
446
     * @param array<string, int|string> $options
447
     * @throws InvalidArgumentException
448
     */
449 31
    private static function verifyEndpointOptions(array $endpointOptions, array $options): void
450
    {
451 31
        foreach ($endpointOptions as $endpointOption) {
452 30
            if (!isset($options[$endpointOption])) {
453 4
                throw new InvalidArgumentException(
454 4
                    '$options has not specified all required parameters. Parameters needed: ' . implode(', ', $endpointOptions)
455 4
                );
456
            }
457
        }
458
    }
459
460
    /**
461
     * Processes the additional parameters that can be used by the search endpoint.
462
     *
463
     * @param array<string, int|string> $options
464
     * @return array<string, int|string>
465
     */
466 2
    private static function searchAdditionalParams(array $options): array
467
    {
468 2
        $additionalParams = [];
469
470 2
        foreach (['languages', 'licenses', 'keywords', 'platforms'] as $option) {
471 2
            if (isset($options[$option])) {
472 2
                $additionalParams[$option] = $options[$option];
473
            }
474
        }
475
476 2
        return $additionalParams;
477
    }
478
479
    /**
480
     * Verifies that the provided sort option is a valid one that libraries.io's API supports.
481
     *
482
     */
483 2
    private static function searchVerifySortOption(string $sort): string
484
    {
485 2
        static $sortOptions = [
486 2
            'rank', 'stars', 'dependents_count',
487 2
            'dependent_repos_count', 'latest_release_published_at',
488 2
            'contributions_count', 'created_at',
489 2
        ];
490
491 2
        if (!in_array($sort, $sortOptions, true)) {
492 1
            return 'rank';
493
        }
494
495 1
        return $sort;
496
    }
497
498
    /**
499
     * Returns the jSON data as-is from the API.
500
     *
501
     * @param ResponseInterface $response The response object from makeRequest()
502
     */
503 3
    public function raw(ResponseInterface $response): string
504
    {
505 3
        return $response->getBody()->getContents();
506
    }
507
508
    /**
509
     * Decodes the jSON returned from the API. Returns as an associative array.
510
     *
511
     * @param ResponseInterface $response The response object from makeRequest()
512
     * @return array<mixed>
513
     * @throws JsonException
514
     */
515 1
    public function toArray(ResponseInterface $response): array
516
    {
517
        /** @var array<mixed> $json **/
518 1
        $json = json_decode($this->raw($response), true, flags: JSON_THROW_ON_ERROR);
519
520 1
        return $json;
521
    }
522
523
    /**
524
     * Decodes the jSON returned from the API. Returns as an array of objects.
525
     *
526
     * @param ResponseInterface $response The response object from makeRequest()
527
     * @throws JsonException
528
     */
529 1
    public function toObject(ResponseInterface $response): stdClass
530
    {
531
        /** @var stdClass $json **/
532 1
        $json = json_decode($this->raw($response), false, flags: JSON_THROW_ON_ERROR);
533
534 1
        return $json;
535
    }
536
}
537