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