1: <?php
2: namespace Worldline\Connect\Sdk\Communication;
3:
4: use ErrorException;
5: use Exception;
6: use Robtimus\Multipart\MultipartFormData;
7: use UnexpectedValueException;
8: use Worldline\Connect\Sdk\CommunicatorConfiguration;
9: use Worldline\Connect\Sdk\Logging\BodyObfuscator;
10: use Worldline\Connect\Sdk\Logging\CommunicatorLogger;
11: use Worldline\Connect\Sdk\Logging\HeaderObfuscator;
12: use Worldline\Connect\Sdk\ProxyConfiguration;
13:
14: /**
15: * Class ApiException
16: *
17: * @package Worldline\Connect\Sdk\Communication
18: */
19: class DefaultConnection implements Connection
20: {
21: /**
22: * @var resource|null
23: */
24: protected $multiHandle = null;
25:
26: /**
27: * @var CommunicatorLogger|null
28: */
29: protected ?CommunicatorLogger $communicatorLogger = null;
30:
31: /**
32: * @var CommunicatorLoggerHelper|null
33: */
34: private ?CommunicatorLoggerHelper $communicatorLoggerHelper = null;
35:
36: /**
37: * @var int
38: */
39: private int $connectTimeout = -1;
40:
41: /**
42: * @var int
43: */
44: private int $readTimeout = -1;
45:
46: /**
47: * @var ProxyConfiguration|null
48: */
49: private ?ProxyConfiguration $proxyConfiguration = null;
50:
51: /**
52: * @param CommunicatorConfiguration|null $communicatorConfiguration
53: */
54: public function __construct(?CommunicatorConfiguration $communicatorConfiguration = null)
55: {
56: if ($communicatorConfiguration) {
57: $this->connectTimeout = $communicatorConfiguration->getConnectTimeout();
58: $this->readTimeout = $communicatorConfiguration->getReadTimeout();
59: $this->proxyConfiguration = $communicatorConfiguration->getProxyConfiguration();
60: }
61: }
62:
63: public function __destruct()
64: {
65: if (!is_null($this->multiHandle)) {
66: curl_multi_close($this->multiHandle);
67: $this->multiHandle = null;
68: }
69: }
70:
71: /**
72: * @param string $requestUri
73: * @param string[] $requestHeaders
74: * @param callable $responseHandler Callable accepting the response status code, a response body chunk and the response headers
75: *
76: * @return void
77: */
78: public function get(string $requestUri, array $requestHeaders, callable $responseHandler): void
79: {
80: $requestId = UuidGenerator::generatedUuid();
81: $this->logRequest($requestId, 'GET', $requestUri, $requestHeaders);
82: try {
83: $response = $this->executeRequest('GET', $requestUri, $requestHeaders, '', $responseHandler);
84: if ($response) {
85: $this->logResponse($requestId, $requestUri, $response);
86: }
87: } catch (Exception $exception) {
88: $this->logException($requestId, $requestUri, $exception);
89: throw $exception;
90: }
91: }
92:
93: /**
94: * @param string $requestUri
95: * @param string[] $requestHeaders
96: * @param callable $responseHandler Callable accepting the response status code, a response body chunk and the response headers
97: *
98: * @return void
99: */
100: public function delete(string $requestUri, array $requestHeaders, callable $responseHandler): void
101: {
102: $requestId = UuidGenerator::generatedUuid();
103: $this->logRequest($requestId, 'DELETE', $requestUri, $requestHeaders);
104: try {
105: $response = $this->executeRequest('DELETE', $requestUri, $requestHeaders, '', $responseHandler);
106: if ($response) {
107: $this->logResponse($requestId, $requestUri, $response);
108: }
109: } catch (Exception $exception) {
110: $this->logException($requestId, $requestUri, $exception);
111: throw $exception;
112: }
113: }
114:
115: /**
116: * @param string $requestUri
117: * @param string[] $requestHeaders
118: * @param string|MultipartFormDataObject $body
119: * @param callable $responseHandler Callable accepting the response status code,
120: * a response body chunk and the response headers
121: *
122: * @return void
123: */
124: public function post(string $requestUri, array $requestHeaders, $body, callable $responseHandler): void
125: {
126: $requestId = UuidGenerator::generatedUuid();
127: $bodyToLog = is_string($body) ? $body : '<binary content>';
128: $this->logRequest($requestId, 'POST', $requestUri, $requestHeaders, $bodyToLog);
129: try {
130: $response = $this->executeRequest('POST', $requestUri, $requestHeaders, $body, $responseHandler);
131: if ($response) {
132: $this->logResponse($requestId, $requestUri, $response);
133: }
134: } catch (Exception $exception) {
135: $this->logException($requestId, $requestUri, $exception);
136: throw $exception;
137: }
138: }
139:
140: /**
141: * @param string $requestUri
142: * @param string[] $requestHeaders
143: * @param string|MultipartFormDataObject $body
144: * @param callable $responseHandler Callable accepting the response status code,
145: * a response body chunk and the response headers
146: *
147: * @return void
148: */
149: public function put(string $requestUri, array $requestHeaders, $body, callable $responseHandler): void
150: {
151: $requestId = UuidGenerator::generatedUuid();
152: $bodyToLog = is_string($body) ? $body : '<binary content>';
153: $this->logRequest($requestId, 'PUT', $requestUri, $requestHeaders, $bodyToLog);
154: try {
155: $response = $this->executeRequest('PUT', $requestUri, $requestHeaders, $body, $responseHandler);
156: if ($response) {
157: $this->logResponse($requestId, $requestUri, $response);
158: }
159: } catch (Exception $exception) {
160: $this->logException($requestId, $requestUri, $exception);
161: throw $exception;
162: }
163: }
164:
165: /**
166: * @param CommunicatorLogger $communicatorLogger
167: *
168: * @return void
169: */
170: public function enableLogging(CommunicatorLogger $communicatorLogger): void
171: {
172: $this->communicatorLogger = $communicatorLogger;
173: }
174:
175: /**
176: * @return void
177: */
178: public function disableLogging(): void
179: {
180: $this->communicatorLogger = null;
181: }
182:
183: /**
184: * @param string $httpMethod
185: * @param string $requestUri
186: * @param string[] $requestHeaders
187: * @param string|MultipartFormDataObject $body
188: * @param callable $responseHandler Callable accepting the response status code,
189: * a response body chunk and the response headers
190: *
191: * @return ConnectionResponse|null
192: * @throws ErrorException
193: */
194: protected function executeRequest(
195: string $httpMethod,
196: string $requestUri,
197: array $requestHeaders,
198: $body,
199: callable $responseHandler
200: ): ?ConnectionResponse {
201: if (!in_array($httpMethod, array('GET', 'DELETE', 'POST', 'PUT'))) {
202: throw new UnexpectedValueException(sprintf('Http method \'%s\' is not supported', $httpMethod));
203: }
204: $curlHandle = $this->getCurlHandle();
205: $this->setCurlOptions($curlHandle, $httpMethod, $requestUri, $requestHeaders, $body);
206: return $this->executeCurlHandle($curlHandle, $responseHandler);
207: }
208:
209: /**
210: * @return resource
211: * @throws ErrorException
212: */
213: protected function getCurlHandle()
214: {
215: if (!$curlHandle = curl_init()) {
216: throw new ErrorException('Cannot initialize cUrl curlHandle');
217: }
218: return $curlHandle;
219: }
220:
221: /**
222: * @param resource $multiHandle
223: *
224: * @return void
225: * @throws ErrorException
226: */
227: private function executeCurlHandleShared($multiHandle): void
228: {
229: $running = 0;
230: do {
231: $status = curl_multi_exec($multiHandle, $running);
232: if ($status > CURLM_OK) {
233: $errorMessage = 'cURL error ' . $status;
234: if (function_exists('curl_multi_strerror')) {
235: $errorMessage .= ' (' . curl_multi_strerror($status) . ')';
236: }
237: throw new ErrorException($errorMessage);
238: }
239: $info = curl_multi_info_read($multiHandle);
240: if ($info && isset($info['result']) && $info['result'] != CURLE_OK) {
241: $errorMessage = 'cURL error ' . $info['result'];
242: if (function_exists('curl_strerror')) {
243: $errorMessage .= ' (' . curl_strerror($info['result']) . ')';
244: }
245: throw new ErrorException($errorMessage);
246: }
247: curl_multi_select($multiHandle);
248: } while ($running > 0);
249: }
250:
251: /**
252: * @param resource $curlHandle
253: * @param callable $responseHandler
254: *
255: * @return ConnectionResponse|null
256: * @throws Exception
257: */
258: private function executeCurlHandle($curlHandle, callable $responseHandler): ?ConnectionResponse
259: {
260: $multiHandle = $this->getCurlMultiHandle();
261: curl_multi_add_handle($multiHandle, $curlHandle);
262:
263: $headerBuilder = new ResponseHeaderBuilder();
264: $headerFunction = function ($ch, $data) use ($headerBuilder) {
265: $headerBuilder->append($data);
266: return strlen($data);
267: };
268:
269: $responseBuilder = $this->communicatorLogger ? new ResponseBuilder() : null;
270: $writeFunction = function ($ch, $data) use ($headerBuilder, $responseBuilder, $responseHandler) {
271: $httpStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
272: $headers = $headerBuilder->getHeaders();
273: call_user_func($responseHandler, $httpStatusCode, $data, $headers);
274: if ($responseBuilder) {
275: $responseBuilder->setHttpStatusCode($httpStatusCode);
276: $responseBuilder->setHeaders($headers);
277: if ($this->isBinaryResponse($headerBuilder)) {
278: $responseBuilder->setBody('<binary content>');
279: } else {
280: $responseBuilder->appendBody($data);
281: }
282: }
283: return strlen($data);
284: };
285:
286: curl_setopt($curlHandle, CURLOPT_HEADERFUNCTION, $headerFunction);
287: curl_setopt($curlHandle, CURLOPT_WRITEFUNCTION, $writeFunction);
288:
289: try {
290: $this->executeCurlHandleShared($multiHandle);
291:
292: // always emit an empty chunk, to make sure that the status code and headers are sent,
293: // even if there is no response body
294: call_user_func($writeFunction, $curlHandle, '');
295:
296: curl_multi_remove_handle($multiHandle, $curlHandle);
297:
298: return $responseBuilder ? $responseBuilder->getResponse() : null;
299: } catch (Exception $e) {
300: curl_multi_remove_handle($multiHandle, $curlHandle);
301:
302: throw $e;
303: }
304: }
305:
306: /**
307: * @param resource $curlHandle
308: * @param string $httpMethod
309: * @param string $requestUri
310: * @param string[] $requestHeaders
311: * @param string|MultipartFormDataObject $body
312: *
313: * @return void
314: */
315: protected function setCurlOptions(
316: $curlHandle,
317: string $httpMethod,
318: string $requestUri,
319: array $requestHeaders,
320: $body
321: ): void {
322: curl_setopt($curlHandle, CURLOPT_HEADER, false);
323: curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, true);
324: curl_setopt($curlHandle, CURLOPT_CUSTOMREQUEST, $httpMethod);
325: curl_setopt($curlHandle, CURLOPT_URL, $requestUri);
326:
327: if ($this->connectTimeout > 0) {
328: curl_setopt($curlHandle, CURLOPT_CONNECTTIMEOUT, $this->connectTimeout);
329: }
330: if ($this->readTimeout > 0) {
331: curl_setopt($curlHandle, CURLOPT_TIMEOUT, $this->readTimeout);
332: }
333:
334: if (in_array($httpMethod, array('PUT', 'POST')) && $body) {
335: if (is_string($body)) {
336: curl_setopt($curlHandle, CURLOPT_POSTFIELDS, $body);
337: } elseif ($body instanceof MultipartFormDataObject) {
338: $multipart = new MultipartFormData($body->getBoundary());
339: foreach ($body->getValues() as $name => $value) {
340: $multipart->addValue($name, $value);
341: }
342: foreach ($body->getFiles() as $name => $file) {
343: $multipart->addFile($name, $file->getFileName(), $file->getContent(), $file->getContentType(), $file->getContentLength());
344: }
345: $multipart->finish();
346:
347: $contentLength = $multipart->getContentLength();
348: if ($contentLength >= 0) {
349: $requestHeaders[] = 'Content-Length: ' . $contentLength;
350: }
351: curl_setopt($curlHandle, CURLOPT_READFUNCTION, array($multipart, 'curl_read'));
352: curl_setopt($curlHandle, CURLOPT_UPLOAD, true);
353: } else {
354: $type = is_object($body) ? get_class($body) : gettype($body);
355: throw new UnexpectedValueException('Unsupported body type: ' . $type);
356: }
357: }
358:
359: if (!empty($requestHeaders)) {
360: curl_setopt($curlHandle, CURLOPT_HTTPHEADER, HttpHeaderHelper::generateRawHeaders($requestHeaders));
361: }
362:
363: if (!is_null($this->proxyConfiguration)) {
364: $curlProxy = $this->proxyConfiguration->getCurlProxy();
365: if (!empty($curlProxy)) {
366: curl_setopt($curlHandle, CURLOPT_PROXY, $curlProxy);
367: }
368: $curlProxyUserPwd = $this->proxyConfiguration->getCurlProxyUserPwd();
369: if (!empty($curlProxyUserPwd)) {
370: curl_setopt($curlHandle, CURLOPT_PROXYUSERPWD, $curlProxyUserPwd);
371: }
372: }
373: }
374:
375: /**
376: * @return resource
377: * @throws Exception
378: */
379: private function getCurlMultiHandle()
380: {
381: if (is_null($this->multiHandle)) {
382: $multiHandle = curl_multi_init();
383: // In PHP 7 the result is not CurlHandle but resource or FALSE
384: // @phpstan-ignore identical.alwaysFalse
385: if ($multiHandle === false) {
386: throw new ErrorException('Failed to initialize cURL multi curlHandle');
387: }
388: $this->multiHandle = $multiHandle;
389: }
390: return $this->multiHandle;
391: }
392:
393: /**
394: * @param ResponseHeaderBuilder $headerBuilder
395: *
396: * @return bool
397: */
398: private function isBinaryResponse(ResponseHeaderBuilder $headerBuilder): bool
399: {
400: $contentType = $headerBuilder->getContentType();
401: return $contentType
402: // does not start with text/
403: && strrpos($contentType, 'text/', -strlen($contentType)) === false
404: // does not contain json
405: && strrpos($contentType, 'json') === false
406: // does not contain xml
407: && strrpos($contentType, 'xml') === false;
408: }
409:
410: /**
411: * @param string $requestId
412: * @param string $requestMethod
413: * @param string $requestUri
414: * @param array $requestHeaders
415: * @param string $requestBody
416: *
417: * @return void
418: */
419: protected function logRequest(
420: string $requestId,
421: string $requestMethod,
422: string $requestUri,
423: array $requestHeaders,
424: string $requestBody = ''
425: ): void {
426: if ($this->communicatorLogger) {
427: $this->getCommunicatorLoggerHelper()->logRequest(
428: $this->communicatorLogger,
429: $requestId,
430: $requestMethod,
431: $requestUri,
432: $requestHeaders,
433: $requestBody
434: );
435: }
436: }
437:
438: /**
439: * @param string $requestId
440: * @param string $requestUri
441: * @param ConnectionResponse $response
442: *
443: * @return void
444: */
445: protected function logResponse(
446: string $requestId,
447: string $requestUri,
448: ConnectionResponse $response
449: ): void {
450: if ($this->communicatorLogger) {
451: $this->getCommunicatorLoggerHelper()->logResponse(
452: $this->communicatorLogger,
453: $requestId,
454: $requestUri,
455: $response
456: );
457: }
458: }
459:
460: /**
461: * @param string $requestId
462: * @param string $requestUri
463: * @param Exception $exception
464: *
465: * @return void
466: */
467: protected function logException(
468: string $requestId,
469: string $requestUri,
470: Exception $exception
471: ): void {
472: if ($this->communicatorLogger) {
473: $this->getCommunicatorLoggerHelper()->logException(
474: $this->communicatorLogger,
475: $requestId,
476: $requestUri,
477: $exception
478: );
479: }
480: }
481:
482: /**
483: * @return CommunicatorLoggerHelper
484: */
485: protected function getCommunicatorLoggerHelper(): CommunicatorLoggerHelper
486: {
487: if (is_null($this->communicatorLoggerHelper)) {
488: $this->communicatorLoggerHelper = new CommunicatorLoggerHelper;
489: }
490: return $this->communicatorLoggerHelper;
491: }
492:
493: /**
494: * @param BodyObfuscator $bodyObfuscator
495: *
496: * @return void
497: */
498: public function setBodyObfuscator(BodyObfuscator $bodyObfuscator): void
499: {
500: $this->getCommunicatorLoggerHelper()->setBodyObfuscator($bodyObfuscator);
501: }
502:
503: /**
504: * @param HeaderObfuscator $headerObfuscator
505: *
506: * @return void
507: */
508: public function setHeaderObfuscator(HeaderObfuscator $headerObfuscator): void
509: {
510: $this->getCommunicatorLoggerHelper()->setHeaderObfuscator($headerObfuscator);
511: }
512: }
513: