1<?php
2
3namespace MaxMind\WebService;
4
5use Composer\CaBundle\CaBundle;
6use MaxMind\Exception\AuthenticationException;
7use MaxMind\Exception\HttpException;
8use MaxMind\Exception\InsufficientFundsException;
9use MaxMind\Exception\InvalidInputException;
10use MaxMind\Exception\InvalidRequestException;
11use MaxMind\Exception\IpAddressNotFoundException;
12use MaxMind\Exception\PermissionRequiredException;
13use MaxMind\Exception\WebServiceException;
14use MaxMind\WebService\Http\RequestFactory;
15
16/**
17 * This class is not intended to be used directly by an end-user of a
18 * MaxMind web service. Please use the appropriate client API for the service
19 * that you are using.
20 *
21 * @internal
22 */
23class Client
24{
25    const VERSION = '0.2.0';
26
27    private $caBundle;
28    private $connectTimeout;
29    private $host = 'api.maxmind.com';
30    private $httpRequestFactory;
31    private $licenseKey;
32    private $proxy;
33    private $timeout;
34    private $userAgentPrefix;
35    private $accountId;
36
37    /**
38     * @param int    $accountId  your MaxMind account ID
39     * @param string $licenseKey your MaxMind license key
40     * @param array  $options    an array of options. Possible keys:
41     *                           * `host` - The host to use when connecting to the web service.
42     *                           * `userAgent` - The prefix of the User-Agent to use in the request.
43     *                           * `caBundle` - The bundle of CA root certificates to use in the request.
44     *                           * `connectTimeout` - The connect timeout to use for the request.
45     *                           * `timeout` - The timeout to use for the request.
46     *                           * `proxy` - The HTTP proxy to use. May include a schema, port,
47     *                           username, and password, e.g., `http://username:password@127.0.0.1:10`.
48     */
49    public function __construct(
50        $accountId,
51        $licenseKey,
52        $options = []
53    ) {
54        $this->accountId = $accountId;
55        $this->licenseKey = $licenseKey;
56
57        $this->httpRequestFactory = isset($options['httpRequestFactory'])
58            ? $options['httpRequestFactory']
59            : new RequestFactory();
60
61        if (isset($options['host'])) {
62            $this->host = $options['host'];
63        }
64        if (isset($options['userAgent'])) {
65            $this->userAgentPrefix = $options['userAgent'] . ' ';
66        }
67
68        $this->caBundle = isset($options['caBundle']) ?
69            $this->caBundle = $options['caBundle'] : $this->getCaBundle();
70
71        if (isset($options['connectTimeout'])) {
72            $this->connectTimeout = $options['connectTimeout'];
73        }
74        if (isset($options['timeout'])) {
75            $this->timeout = $options['timeout'];
76        }
77
78        if (isset($options['proxy'])) {
79            $this->proxy = $options['proxy'];
80        }
81    }
82
83    /**
84     * @param string $service name of the service querying
85     * @param string $path    the URI path to use
86     * @param array  $input   the data to be posted as JSON
87     *
88     * @throws InvalidInputException      when the request has missing or invalid
89     *                                    data
90     * @throws AuthenticationException    when there is an issue authenticating the
91     *                                    request
92     * @throws InsufficientFundsException when your account is out of funds
93     * @throws InvalidRequestException    when the request is invalid for some
94     *                                    other reason, e.g., invalid JSON in the POST.
95     * @throws HttpException              when an unexpected HTTP error occurs
96     * @throws WebServiceException        when some other error occurs. This also
97     *                                    serves as the base class for the above exceptions.
98     *
99     * @return array The decoded content of a successful response
100     */
101    public function post($service, $path, $input)
102    {
103        $body = json_encode($input);
104        if ($body === false) {
105            throw new InvalidInputException(
106                'Error encoding input as JSON: '
107                . $this->jsonErrorDescription()
108            );
109        }
110
111        $request = $this->createRequest(
112            $path,
113            ['Content-Type: application/json']
114        );
115
116        list($statusCode, $contentType, $body) = $request->post($body);
117
118        return $this->handleResponse(
119            $statusCode,
120            $contentType,
121            $body,
122            $service,
123            $path
124        );
125    }
126
127    public function get($service, $path)
128    {
129        $request = $this->createRequest($path);
130
131        list($statusCode, $contentType, $body) = $request->get();
132
133        return $this->handleResponse(
134            $statusCode,
135            $contentType,
136            $body,
137            $service,
138            $path
139        );
140    }
141
142    private function userAgent()
143    {
144        $curlVersion = curl_version();
145
146        return $this->userAgentPrefix . 'MaxMind-WS-API/' . self::VERSION . ' PHP/' . PHP_VERSION .
147           ' curl/' . $curlVersion['version'];
148    }
149
150    private function createRequest($path, $headers = [])
151    {
152        array_push(
153            $headers,
154            'Authorization: Basic '
155            . base64_encode($this->accountId . ':' . $this->licenseKey),
156            'Accept: application/json'
157        );
158
159        return $this->httpRequestFactory->request(
160            $this->urlFor($path),
161            [
162                'caBundle' => $this->caBundle,
163                'connectTimeout' => $this->connectTimeout,
164                'headers' => $headers,
165                'proxy' => $this->proxy,
166                'timeout' => $this->timeout,
167                'userAgent' => $this->userAgent(),
168            ]
169        );
170    }
171
172    /**
173     * @param int    $statusCode  the HTTP status code of the response
174     * @param string $contentType the Content-Type of the response
175     * @param string $body        the response body
176     * @param string $service     the name of the service
177     * @param string $path        the path used in the request
178     *
179     * @throws AuthenticationException    when there is an issue authenticating the
180     *                                    request
181     * @throws InsufficientFundsException when your account is out of funds
182     * @throws InvalidRequestException    when the request is invalid for some
183     *                                    other reason, e.g., invalid JSON in the POST.
184     * @throws HttpException              when an unexpected HTTP error occurs
185     * @throws WebServiceException        when some other error occurs. This also
186     *                                    serves as the base class for the above exceptions
187     *
188     * @return array The decoded content of a successful response
189     */
190    private function handleResponse(
191        $statusCode,
192        $contentType,
193        $body,
194        $service,
195        $path
196    ) {
197        if ($statusCode >= 400 && $statusCode <= 499) {
198            $this->handle4xx($statusCode, $contentType, $body, $service, $path);
199        } elseif ($statusCode >= 500) {
200            $this->handle5xx($statusCode, $service, $path);
201        } elseif ($statusCode !== 200) {
202            $this->handleUnexpectedStatus($statusCode, $service, $path);
203        }
204
205        return $this->handleSuccess($body, $service);
206    }
207
208    /**
209     * @return string describing the JSON error
210     */
211    private function jsonErrorDescription()
212    {
213        $errno = json_last_error();
214        switch ($errno) {
215            case JSON_ERROR_DEPTH:
216                return 'The maximum stack depth has been exceeded.';
217            case JSON_ERROR_STATE_MISMATCH:
218                return 'Invalid or malformed JSON.';
219            case JSON_ERROR_CTRL_CHAR:
220                return 'Control character error.';
221            case JSON_ERROR_SYNTAX:
222                return 'Syntax error.';
223            case JSON_ERROR_UTF8:
224                return 'Malformed UTF-8 characters.';
225            default:
226                return "Other JSON error ($errno).";
227        }
228    }
229
230    /**
231     * @param string $path the path to use in the URL
232     *
233     * @return string the constructed URL
234     */
235    private function urlFor($path)
236    {
237        return 'https://' . $this->host . $path;
238    }
239
240    /**
241     * @param int    $statusCode  the HTTP status code
242     * @param string $contentType the response content-type
243     * @param string $body        the response body
244     * @param string $service     the service name
245     * @param string $path        the path used in the request
246     *
247     * @throws AuthenticationException
248     * @throws HttpException
249     * @throws InsufficientFundsException
250     * @throws InvalidRequestException
251     */
252    private function handle4xx(
253        $statusCode,
254        $contentType,
255        $body,
256        $service,
257        $path
258    ) {
259        if (strlen($body) === 0) {
260            throw new HttpException(
261                "Received a $statusCode error for $service with no body",
262                $statusCode,
263                $this->urlFor($path)
264            );
265        }
266        if (!strstr($contentType, 'json')) {
267            throw new HttpException(
268                "Received a $statusCode error for $service with " .
269                'the following body: ' . $body,
270                $statusCode,
271                $this->urlFor($path)
272            );
273        }
274
275        $message = json_decode($body, true);
276        if ($message === null) {
277            throw new HttpException(
278                "Received a $statusCode error for $service but could " .
279                'not decode the response as JSON: '
280                . $this->jsonErrorDescription() . ' Body: ' . $body,
281                $statusCode,
282                $this->urlFor($path)
283            );
284        }
285
286        if (!isset($message['code']) || !isset($message['error'])) {
287            throw new HttpException(
288                'Error response contains JSON but it does not ' .
289                'specify code or error keys: ' . $body,
290                $statusCode,
291                $this->urlFor($path)
292            );
293        }
294
295        $this->handleWebServiceError(
296            $message['error'],
297            $message['code'],
298            $statusCode,
299            $path
300        );
301    }
302
303    /**
304     * @param string $message    the error message from the web service
305     * @param string $code       the error code from the web service
306     * @param int    $statusCode the HTTP status code
307     * @param string $path       the path used in the request
308     *
309     * @throws AuthenticationException
310     * @throws InvalidRequestException
311     * @throws InsufficientFundsException
312     */
313    private function handleWebServiceError(
314        $message,
315        $code,
316        $statusCode,
317        $path
318    ) {
319        switch ($code) {
320            case 'IP_ADDRESS_NOT_FOUND':
321            case 'IP_ADDRESS_RESERVED':
322                throw new IpAddressNotFoundException(
323                    $message,
324                    $code,
325                    $statusCode,
326                    $this->urlFor($path)
327                );
328            case 'ACCOUNT_ID_REQUIRED':
329            case 'ACCOUNT_ID_UNKNOWN':
330            case 'AUTHORIZATION_INVALID':
331            case 'LICENSE_KEY_REQUIRED':
332            case 'USER_ID_REQUIRED':
333            case 'USER_ID_UNKNOWN':
334                throw new AuthenticationException(
335                    $message,
336                    $code,
337                    $statusCode,
338                    $this->urlFor($path)
339                );
340            case 'OUT_OF_QUERIES':
341            case 'INSUFFICIENT_FUNDS':
342                throw new InsufficientFundsException(
343                    $message,
344                    $code,
345                    $statusCode,
346                    $this->urlFor($path)
347                );
348            case 'PERMISSION_REQUIRED':
349                throw new PermissionRequiredException(
350                    $message,
351                    $code,
352                    $statusCode,
353                    $this->urlFor($path)
354                );
355            default:
356                throw new InvalidRequestException(
357                    $message,
358                    $code,
359                    $statusCode,
360                    $this->urlFor($path)
361                );
362        }
363    }
364
365    /**
366     * @param int    $statusCode the HTTP status code
367     * @param string $service    the service name
368     * @param string $path       the URI path used in the request
369     *
370     * @throws HttpException
371     */
372    private function handle5xx($statusCode, $service, $path)
373    {
374        throw new HttpException(
375            "Received a server error ($statusCode) for $service",
376            $statusCode,
377            $this->urlFor($path)
378        );
379    }
380
381    /**
382     * @param int    $statusCode the HTTP status code
383     * @param string $service    the service name
384     * @param string $path       the URI path used in the request
385     *
386     * @throws HttpException
387     */
388    private function handleUnexpectedStatus($statusCode, $service, $path)
389    {
390        throw new HttpException(
391            'Received an unexpected HTTP status ' .
392            "($statusCode) for $service",
393            $statusCode,
394            $this->urlFor($path)
395        );
396    }
397
398    /**
399     * @param string $body    the successful request body
400     * @param string $service the service name
401     *
402     * @throws WebServiceException if the request body cannot be decoded as
403     *                             JSON
404     *
405     * @return array the decoded request body
406     */
407    private function handleSuccess($body, $service)
408    {
409        if (strlen($body) === 0) {
410            throw new WebServiceException(
411                "Received a 200 response for $service but did not " .
412                'receive a HTTP body.'
413            );
414        }
415
416        $decodedContent = json_decode($body, true);
417        if ($decodedContent === null) {
418            throw new WebServiceException(
419                "Received a 200 response for $service but could " .
420                'not decode the response as JSON: '
421                . $this->jsonErrorDescription() . ' Body: ' . $body
422            );
423        }
424
425        return $decodedContent;
426    }
427
428    private function getCaBundle()
429    {
430        $curlVersion = curl_version();
431
432        // On OS X, when the SSL version is "SecureTransport", the system's
433        // keychain will be used.
434        if ($curlVersion['ssl_version'] === 'SecureTransport') {
435            return;
436        }
437        $cert = CaBundle::getSystemCaRootBundlePath();
438
439        // Check if the cert is inside a phar. If so, we need to copy the cert
440        // to a temp file so that curl can see it.
441        if (substr($cert, 0, 7) === 'phar://') {
442            $tempDir = sys_get_temp_dir();
443            $newCert = tempnam($tempDir, 'geoip2-');
444            if ($newCert === false) {
445                throw new \RuntimeException(
446                    "Unable to create temporary file in $tempDir"
447                );
448            }
449            if (!copy($cert, $newCert)) {
450                throw new \RuntimeException(
451                    "Could not copy $cert to $newCert: "
452                    . var_export(error_get_last(), true)
453                );
454            }
455
456            // We use a shutdown function rather than the destructor as the
457            // destructor isn't called on a fatal error such as an uncaught
458            // exception.
459            register_shutdown_function(
460                function () use ($newCert) {
461                    unlink($newCert);
462                }
463            );
464            $cert = $newCert;
465        }
466        if (!file_exists($cert)) {
467            throw new \RuntimeException("CA cert does not exist at $cert");
468        }
469
470        return $cert;
471    }
472}
473