Client.php 17 KB

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