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