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