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