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