Passed
Push — master ( 2d3544...07b72c )
by Eric
02:20
created

Api::verifySuccess()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 3
c 0
b 0
f 0
nc 4
nop 1
dl 0
loc 7
ccs 4
cts 4
cp 1
crap 3
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of the Numverify API Client for PHP.
7
 *
8
 * (c) 2024 Eric Sizemore <[email protected]>
9
 * (c) 2018-2021 Mark Rogoyski <[email protected]>
10
 *
11
 * This source file is subject to the MIT license. For the full copyright,
12
 * license information, and credits/acknowledgements, please view the LICENSE
13
 * and README files that were distributed with this source code.
14
 */
15
16
namespace Numverify;
17
18
use GuzzleHttp\Client;
19
use GuzzleHttp\ClientInterface;
20
use GuzzleHttp\Exception\GuzzleException;
21
use GuzzleHttp\Exception\ServerException;
22
use GuzzleHttp\HandlerStack;
23
use Kevinrob\GuzzleCache\CacheMiddleware;
24
use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage;
25
use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy;
26
use Numverify\Country\Collection;
27
use Numverify\Country\Country;
28
use Numverify\Exception\NumverifyApiFailureException;
29
use Numverify\PhoneNumber\Factory;
30
use Numverify\PhoneNumber\PhoneNumberInterface;
31
use Psr\Http\Message\ResponseInterface;
32
use SensitiveParameter;
33
use stdClass;
34
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
35
36
use function array_keys;
37
use function array_map;
38
use function array_merge;
39
use function is_dir;
40
use function is_writable;
41
use function json_decode;
42
use function trim;
43
44
/**
45
 * Main API class.
46
 *
47
 * @see Tests\ApiTest
48
 *
49
 * @psalm-api
50
 *
51
 * @phpstan-type ApiJsonArray array{
52
 *     success?: bool,
53
 *     error?: array{code?: int, type?: string, info?: string},
54
 *     valid?: bool,
55
 *     number?: string,
56
 *     local_format?: string,
57
 *     international_format?: string,
58
 *     country_prefix?: string,
59
 *     country_code?: string,
60
 *     country_name?: string,
61
 *     location?: string,
62
 *     carrier?: string,
63
 *     line_type?: string
64
 * }
65
 * @phpstan-type ApiCountryJsonArray array<string, array{country_name: string, dialling_code: string}>
66
 *
67
 * @phpstan-import-type InvalidPhoneNumberObject from PhoneNumber\Factory
68
 * @phpstan-import-type ValidPhoneNumberObject from PhoneNumber\Factory
69
 */
70
class Api
71
{
72
    /**
73
     * Current version of the Numverify package.
74
     *
75
     * @var string
76
     */
77
    public const LIBRARY_VERSION = '3.0.1';
78
79
    /**
80
     * URL for Numverify's "Free" plan.
81
     *
82
     * @see https://numverify.com/product
83
     */
84
    private const HTTP_URL = 'http://apilayer.net/api';
85
86
    /**
87
     * URL for Numverify's paid plans. ("Basic", "Professional", or "Enterprise").
88
     *
89
     * @see https://numverify.com/product
90
     */
91
    private const HTTPS_URL = 'https://apilayer.net/api';
92
93
    /**
94
     * Guzzle Client.
95
     */
96
    private ClientInterface $client;
97
98
    /**
99
     * Api constructor.
100
     *
101
     * Requires an access key. You can get one from Numverify.
102
     *
103
     * @see https://numverify.com/product
104
     *
105
     * Note: If you are on their free plan, $useHttps = true will not work for you.
106
     *
107
     * @param string               $accessKey API access key.
108
     * @param bool                 $useHttps  (optional) Flag to determine if API calls should use http or https.
109
     * @param null|ClientInterface $client    (optional) Parameter to provide your own Guzzle client.
110
     * @param array<string, mixed> $options   (optional) Array of options to pass to the Guzzle client.
111
     */
112 34
    public function __construct(
113
        #[SensitiveParameter]
114
        private readonly string $accessKey,
115
        bool $useHttps = false,
116
        ?ClientInterface $client = null,
117
        array $options = []
118
    ) {
119
        // If we already have a client.
120 34
        if ($client instanceof ClientInterface) {
121 2
            $this->client = $client;
122
123 2
            return;
124
        }
125
126
        // Build client
127 32
        $clientOptions = ['base_uri' => $this->getUrl($useHttps)];
128
129
        // If $options has 'cachePath' key, and it is a valid directory, then buildCacheHandler() will
130
        // add Cache to the Guzzle handler stack.
131 32
        $options = $this->buildCacheHandler($options);
132
133
        // Merge $options into main client options.
134 32
        $clientOptions = array_merge($clientOptions, $options);
135
136 32
        $this->client = new Client($clientOptions);
137
    }
138
139
    /**
140
     * Get list of countries.
141
     *
142
     * @throws NumverifyApiFailureException If the response is non 200 or success field is false.
143
     * @throws GuzzleException              If Guzzle encounters an issue.
144
     */
145 12
    public function getCountries(): Collection
146
    {
147
        try {
148 12
            $response = $this->client->request('GET', '/countries', [
149 12
                'query' => [
150 12
                    'access_key' => $this->accessKey,
151 12
                ],
152 12
            ]);
153 2
        } catch (ServerException $serverException) {
154
            // >= 400 <= 500 status code
155
            // wrap ServerException with NumverifyApiFailureException
156 2
            throw new NumverifyApiFailureException($serverException->getResponse());
157
        }
158
159
        /** @var ApiCountryJsonArray $body */
160 10
        $body = $this->validateAndDecodeResponse($response, true);
161
162 6
        $countries = array_map(
163 6
            static fn (array $country, string $countryCode): Country => new Country($countryCode, $country['country_name'], $country['dialling_code']),
164 6
            $body,
165 6
            array_keys($body)
166 6
        );
167
168 6
        return new Collection(...$countries);
169
    }
170
171
    /**
172
     * Validate a phone number.
173
     *
174
     * Will return ValidPhoneNumber for a valid number, InvalidPhoneNumber otherwise (both PhoneNumberInterface).
175
     *
176
     * @param string $countryCode (Optional) Use to provide a phone number in a local format (non E.164).
177
     *
178
     * @throws NumverifyApiFailureException If the response is non 200 or success field is false.
179
     * @throws GuzzleException              If Guzzle encounters an issue.
180
     */
181 14
    public function validatePhoneNumber(string $phoneNumber, string $countryCode = ''): PhoneNumberInterface
182
    {
183 14
        $phoneNumber = trim($phoneNumber);
184 14
        $countryCode = trim($countryCode);
185
186 14
        $query = [
187 14
            'access_key' => $this->accessKey,
188 14
            'number'     => $phoneNumber,
189 14
        ];
190
191 14
        if ($countryCode !== '') {
192 2
            $query['country_code'] = $countryCode;
193
        }
194
195
        try {
196 14
            $result = $this->client->request('GET', '/validate', [
197 14
                'query' => $query,
198 14
            ]);
199 2
        } catch (ServerException $serverException) {
200
            // >= 400 <= 500 status code
201
            // wrap ServerException with NumverifyApiFailureException
202 2
            throw new NumverifyApiFailureException($serverException->getResponse());
203
        }
204
205 12
        $body = $this->validateAndDecodeResponse($result);
206
207 8
        return Factory::create($body);
1 ignored issue
show
Bug introduced by
It seems like $body can also be of type array; however, parameter $validatedPhoneNumber of Numverify\PhoneNumber\Factory::create() does only seem to accept stdClass, maybe add an additional type check? ( Ignorable by Annotation )

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

207
        return Factory::create(/** @scrutinizer ignore-type */ $body);
Loading history...
208
    }
209
210
    /**
211
     * Creates a Guzzle HandlerStack and adds the CacheMiddleware if 'cachePath' exists within the
212
     * given $options array, and it is a valid directory.
213
     *
214
     * Returns given $options as passed, minus the 'cachePath' as it is not a valid Guzzle option for
215
     * the client.
216
     *
217
     * @param array<string, mixed> $options
218
     *
219
     * @return array<string, mixed>
220
     */
221 32
    private function buildCacheHandler(array $options): array
222
    {
223 32
        $cachePath = (string) ($options['cachePath'] ?? null); // @phpstan-ignore-line
224
225 32
        if (is_dir($cachePath) && is_writable($cachePath)) {
226 2
            $handlerStack = HandlerStack::create();
227 2
            $handlerStack->push(middleware: new CacheMiddleware(cacheStrategy: new PrivateCacheStrategy(
228 2
                cache: new Psr6CacheStorage(cachePool: new FilesystemAdapter(namespace: 'numverify', defaultLifetime: 300, directory: $cachePath))
229 2
            )), name: 'cache');
230
231 2
            unset($options['cachePath']);
232 2
            $options += ['handler' => $handlerStack];
233
        }
234
235 32
        return $options;
236
    }
237
238
    /**
239
     * Get the URL to use for API calls.
240
     */
241 32
    private function getUrl(bool $useHttps): string
242
    {
243 32
        return $useHttps ? self::HTTPS_URL : self::HTTP_URL;
244
    }
245
246
    /**
247
     * Given a response object, checks the status code and checks the 'success' field, if it exists.
248
     *
249
     * If everything looks good, it returns the decoded jSON data based on $asArray.
250
     *
251
     * @param bool $asArray If true, returns the decoded jSON as an assoc. array, stdClass otherwise.
252
     *
253
     * @throws NumverifyApiFailureException If the response is non 200 or success field is false.
254
     *
255
     * @return ($asArray is true ? ApiCountryJsonArray|ApiJsonArray : InvalidPhoneNumberObject|ValidPhoneNumberObject)
1 ignored issue
show
Documentation Bug introduced by
The doc comment ($asArray at position 1 could not be parsed: Unknown type name '$asArray' at position 1 in ($asArray.
Loading history...
256
     */
257 22
    private function validateAndDecodeResponse(ResponseInterface $response, bool $asArray = false): array|stdClass
258
    {
259
        // If not 200 ok
260 22
        if ($response->getStatusCode() !== 200) {
261 4
            throw new NumverifyApiFailureException($response);
262
        }
263
264
        /**
265
         * @var ApiCountryJsonArray|ApiJsonArray|InvalidPhoneNumberObject|ValidPhoneNumberObject $body
266
         */
267 18
        $body = json_decode($response->getBody()->getContents(), $asArray);
268
269 18
        if (!$this->verifySuccess($body)) {
270 4
            throw new NumverifyApiFailureException($response);
271
        }
272
273 14
        return $body;
274
    }
275
276
    /**
277
     * @param ApiCountryJsonArray|ApiJsonArray|InvalidPhoneNumberObject|ValidPhoneNumberObject $body
278
     */
279 18
    private function verifySuccess(array|stdClass $body): bool
280
    {
281 18
        if (!\is_array($body)) {
1 ignored issue
show
introduced by
The condition is_array($body) is always true.
Loading history...
282 10
            $body = (array) $body;
283
        }
284
285 18
        return !(isset($body['success']) && $body['success'] === false);
286
    }
287
}
288