1 <?php
2
3 /**
4 * ArangoDB PHP client: connection
5 *
6 * @package triagens\ArangoDb
7 * @author Jan Steemann
8 * @copyright Copyright 2012, triagens GmbH, Cologne, Germany
9 */
10
11 namespace triagens\ArangoDb;
12
13 /**
14 * Provides access to the ArangoDB server
15 *
16 * As all access is done using HTTP, we do not need to establish a
17 * persistent connection and keep its state.<br>
18 * Instead, connections are established on the fly for each request
19 * and are destroyed afterwards.<br>
20 *
21 * @package triagens\ArangoDb
22 * @since 0.2
23 */
24 class Connection
25 {
26 /**
27 * Connection options
28 *
29 * @var array
30 */
31 private $_options;
32
33 /**
34 * Pre-assembled HTTP headers string for connection
35 * This is pre-calculated when connection options are set/changed, to avoid
36 * calculation of the same HTTP header values in each request done via the
37 * connection
38 *
39 * @var string
40 */
41 private $_httpHeader = '';
42
43 /**
44 * Pre-assembled base URL for the current database
45 * This is pre-calculated when connection options are set/changed, to avoid
46 * calculation of the same base URL in each request done via the
47 * connection
48 *
49 * @var string
50 */
51 private $_baseUrl = '';
52
53 /**
54 * Connection handle, used in case of keep-alive
55 *
56 * @var resource
57 */
58 private $_handle;
59
60 /**
61 * Flag if keep-alive connections are used
62 *
63 * @var bool
64 */
65 private $_useKeepAlive;
66
67 /**
68 * Batches Array
69 *
70 * @var array
71 */
72 private $_batches = [];
73
74 /**
75 * $_activeBatch object
76 *
77 * @var Batch
78 */
79 private $_activeBatch;
80
81 /**
82 * $_captureBatch boolean
83 *
84 * @var boolean
85 */
86 private $_captureBatch = false;
87
88 /**
89 * $_batchRequest boolean
90 *
91 * @var boolean
92 */
93 private $_batchRequest = false;
94
95 /**
96 * $_database string
97 *
98 * @var string
99 */
100 private $_database = '';
101
102 /**
103 * Set up the connection object, validate the options provided
104 *
105 * @throws Exception
106 *
107 * @param array $options - initial connection options
108 *
109 */
110 public function __construct(array $options)
111 {
112 $this->_options = new ConnectionOptions($options);
113 $this->_useKeepAlive = ($this->_options[ConnectionOptions::OPTION_CONNECTION] === 'Keep-Alive');
114 $this->setDatabase($this->_options[ConnectionOptions::OPTION_DATABASE]);
115
116 $this->updateHttpHeader();
117 }
118
119 /**
120 * Close existing connection handle if a keep-alive connection was used
121 *
122 * @return void
123 */
124 public function __destruct()
125 {
126 if ($this->_useKeepAlive && is_resource($this->_handle)) {
127 @fclose($this->_handle);
128 }
129 }
130
131 /**
132 * Set an option set for the connection
133 *
134 * @throws ClientException
135 *
136 * @param string $name - name of option
137 * @param string $value - value of option
138 */
139 public function setOption($name, $value)
140 {
141 if ($name === ConnectionOptions::OPTION_ENDPOINT ||
142 $name === ConnectionOptions::OPTION_HOST ||
143 $name === ConnectionOptions::OPTION_PORT ||
144 $name === ConnectionOptions::OPTION_VERIFY_CERT ||
145 $name === ConnectionOptions::OPTION_CIPHERS ||
146 $name === ConnectionOptions::OPTION_ALLOW_SELF_SIGNED
147 ) {
148 throw new ClientException('Must not set option ' . $value . ' after connection is created.');
149 }
150
151 $this->_options[$name] = $value;
152
153 // special handling for several options
154 if ($name === ConnectionOptions::OPTION_TIMEOUT) {
155 // set the timeout option: patch the stream of an existing connection
156 if (is_resource($this->_handle)) {
157 stream_set_timeout($this->_handle, $value);
158 }
159 } else if ($name === ConnectionOptions::OPTION_CONNECTION) {
160 // set keep-alive flag
161 $this->_useKeepAlive = (strtolower($value) === 'keep-alive');
162 } else if ($name === ConnectionOptions::OPTION_DATABASE) {
163 // set database
164 $this->setDatabase($value);
165 }
166
167 $this->updateHttpHeader();
168 }
169
170 /**
171 * Get the options set for the connection
172 *
173 * @return ConnectionOptions
174 */
175 public function getOptions()
176 {
177 return $this->_options;
178 }
179
180 /**
181 * Get an option set for the connection
182 *
183 * @throws ClientException
184 *
185 * @param string $name - name of option
186 *
187 * @return mixed
188 */
189 public function getOption($name)
190 {
191 assert(is_string($name));
192
193 return $this->_options[$name];
194 }
195
196
197 /**
198 * Issue an HTTP GET request
199 *
200 * @throws Exception
201 *
202 * @param string $url - GET URL
203 * @param array $customHeaders
204 *
205 * @return HttpResponse
206 */
207 public function get($url, array $customHeaders = [])
208 {
209 $response = $this->executeRequest(HttpHelper::METHOD_GET, $url, '', $customHeaders);
210
211 return $this->parseResponse($response);
212 }
213
214 /**
215 * Issue an HTTP POST request with the data provided
216 *
217 * @throws Exception
218 *
219 * @param string $url - POST URL
220 * @param string $data - body to post
221 * @param array $customHeaders
222 *
223 * @return HttpResponse
224 */
225 public function post($url, $data, array $customHeaders = [])
226 {
227 $response = $this->executeRequest(HttpHelper::METHOD_POST, $url, $data, $customHeaders);
228
229 return $this->parseResponse($response);
230 }
231
232 /**
233 * Issue an HTTP PUT request with the data provided
234 *
235 * @throws Exception
236 *
237 * @param string $url - PUT URL
238 * @param string $data - body to post
239 * @param array $customHeaders
240 *
241 * @return HttpResponse
242 */
243 public function put($url, $data, array $customHeaders = [])
244 {
245 $response = $this->executeRequest(HttpHelper::METHOD_PUT, $url, $data, $customHeaders);
246
247 return $this->parseResponse($response);
248 }
249
250 /**
251 * Issue an HTTP Head request with the data provided
252 *
253 * @throws Exception
254 *
255 * @param string $url - PUT URL
256 * @param array $customHeaders
257 *
258 * @return HttpResponse
259 */
260 public function head($url, array $customHeaders = [])
261 {
262 $response = $this->executeRequest(HttpHelper::METHOD_HEAD, $url, '', $customHeaders);
263
264 return $this->parseResponse($response);
265 }
266
267 /**
268 * Issue an HTTP PATCH request with the data provided
269 *
270 * @throws Exception
271 *
272 * @param string $url - PATCH URL
273 * @param string $data - patch body
274 * @param array $customHeaders
275 *
276 * @return HttpResponse
277 */
278 public function patch($url, $data, array $customHeaders = [])
279 {
280 $response = $this->executeRequest(HttpHelper::METHOD_PATCH, $url, $data, $customHeaders);
281
282 return $this->parseResponse($response);
283 }
284
285 /**
286 * Issue an HTTP DELETE request with the data provided
287 *
288 * @throws Exception
289 *
290 * @param string $url - DELETE URL
291 * @param array $customHeaders
292 *
293 * @return HttpResponse
294 */
295 public function delete($url, array $customHeaders = [])
296 {
297 $response = $this->executeRequest(HttpHelper::METHOD_DELETE, $url, '', $customHeaders);
298
299 return $this->parseResponse($response);
300 }
301
302
303 /**
304 * Recalculate the static HTTP header string used for all HTTP requests in this connection
305 */
306 private function updateHttpHeader()
307 {
308 $this->_httpHeader = HttpHelper::EOL;
309
310 $endpoint = $this->_options[ConnectionOptions::OPTION_ENDPOINT];
311 if (Endpoint::getType($endpoint) !== Endpoint::TYPE_UNIX) {
312 $this->_httpHeader .= sprintf('Host: %s%s', Endpoint::getHost($endpoint), HttpHelper::EOL);
313 }
314
315 if (isset($this->_options[ConnectionOptions::OPTION_AUTH_TYPE], $this->_options[ConnectionOptions::OPTION_AUTH_USER])) {
316 // add authorization header
317 $authorizationValue = base64_encode(
318 $this->_options[ConnectionOptions::OPTION_AUTH_USER] . ':' .
319 $this->_options[ConnectionOptions::OPTION_AUTH_PASSWD]
320 );
321
322 $this->_httpHeader .= sprintf(
323 'Authorization: %s %s%s',
324 $this->_options[ConnectionOptions::OPTION_AUTH_TYPE],
325 $authorizationValue,
326 HttpHelper::EOL
327 );
328 }
329
330 if (isset($this->_options[ConnectionOptions::OPTION_CONNECTION])) {
331 // add connection header
332 $this->_httpHeader .= sprintf('Connection: %s%s', $this->_options[ConnectionOptions::OPTION_CONNECTION], HttpHelper::EOL);
333 }
334
335 if ($this->_database === '') {
336 $this->_baseUrl = '/_db/_system';
337 } else {
338 $this->_baseUrl = '/_db/' . urlencode($this->_database);
339 }
340 }
341
342 /**
343 * Get a connection handle
344 *
345 * If keep-alive connections are used, the handle will be stored and re-used
346 *
347 * @throws ClientException
348 * @return resource - connection handle
349 * @throws \triagens\ArangoDb\ConnectException
350 */
351 private function getHandle()
352 {
353 if ($this->_useKeepAlive && $this->_handle && is_resource($this->_handle)) {
354 // keep-alive and handle was created already
355 $handle = $this->_handle;
356
357 // check if connection is still valid
358 if (!feof($handle)) {
359 // connection still valid
360 return $handle;
361 }
362
363 // close handle
364 @fclose($this->_handle);
365 $this->_handle = 0;
366
367 if (!$this->_options[ConnectionOptions::OPTION_RECONNECT]) {
368 // if reconnect option not set, this is the end
369 throw new ClientException('Server has closed the connection already.');
370 }
371 }
372
373 // no keep-alive or no handle available yet or a reconnect
374 $handle = HttpHelper::createConnection($this->_options);
375
376 if ($this->_useKeepAlive && is_resource($handle)) {
377 $this->_handle = $handle;
378 }
379
380 return $handle;
381 }
382
383 /**
384 * Execute an HTTP request and return the results
385 *
386 * This function will throw if no connection to the server can be established or if
387 * there is a problem during data exchange with the server.
388 *
389 * will restore it.
390 *
391 * @throws Exception
392 *
393 * @param string $method - HTTP request method
394 * @param string $url - HTTP URL
395 * @param string $data - data to post in body
396 * @param array $customHeaders - any array containing header elements
397 *
398 * @return HttpResponse
399 */
400 private function executeRequest($method, $url, $data, array $customHeaders = [])
401 {
402 assert($this->_httpHeader !== '');
403 $wasAsync = false;
404 if (is_array($customHeaders) && isset($customHeaders[HttpHelper::ASYNC_HEADER])) {
405 $wasAsync = true;
406 }
407
408 HttpHelper::validateMethod($method);
409 $url = $this->_baseUrl . $url;
410
411 // create request data
412 if ($this->_batchRequest === false) {
413
414 if ($this->_captureBatch === true) {
415 $this->_options->offsetSet(ConnectionOptions::OPTION_BATCHPART, true);
416 $request = HttpHelper::buildRequest($this->_options, $this->_httpHeader, $method, $url, $data, $customHeaders);
417 $this->_options->offsetSet(ConnectionOptions::OPTION_BATCHPART, false);
418 } else {
419 $request = HttpHelper::buildRequest($this->_options, $this->_httpHeader, $method, $url, $data, $customHeaders);
420 }
421
422 if ($this->_captureBatch === true) {
423 $batchPart = $this->doBatch($method, $request);
424 if (null !== $batchPart) {
425 return $batchPart;
426 }
427 }
428 } else {
429 $this->_batchRequest = false;
430
431 $this->_options->offsetSet(ConnectionOptions::OPTION_BATCH, true);
432 $request = HttpHelper::buildRequest($this->_options, $this->_httpHeader, $method, $url, $data, $customHeaders);
433 $this->_options->offsetSet(ConnectionOptions::OPTION_BATCH, false);
434 }
435
436
437 $traceFunc = $this->_options[ConnectionOptions::OPTION_TRACE];
438 if ($traceFunc) {
439 // call tracer func
440 if ($this->_options[ConnectionOptions::OPTION_ENHANCED_TRACE]) {
441 list($header) = HttpHelper::parseHttpMessage($request, $url, $method);
442 $headers = HttpHelper::parseHeaders($header);
443 $traceFunc(new TraceRequest($headers[2], $method, $url, $data));
444 } else {
445 $traceFunc('send', $request);
446 }
447 }
448
449
450 // open the socket. note: this might throw if the connection cannot be established
451 $handle = $this->getHandle();
452
453 if ($handle) {
454 // send data and get response back
455
456 if ($traceFunc) {
457 // only issue syscall if we need it
458 $startTime = microtime(true);
459 }
460
461 $result = HttpHelper::transfer($handle, $request);
462
463 if ($traceFunc) {
464 // only issue syscall if we need it
465 $timeTaken = microtime(true) - $startTime;
466 }
467
468 $status = socket_get_status($handle);
469 if ($status['timed_out']) {
470 throw new ClientException('Got a timeout while waiting for the server\'s response', 408);
471 }
472
473 if (!$this->_useKeepAlive) {
474 // must close the connection
475 fclose($handle);
476 }
477
478 $response = new HttpResponse($result, $url, $method, $wasAsync);
479
480 if ($traceFunc) {
481 // call tracer func
482 if ($this->_options[ConnectionOptions::OPTION_ENHANCED_TRACE]) {
483 $traceFunc(
484 new TraceResponse(
485 $response->getHeaders(), $response->getHttpCode(), $response->getBody(),
486 $timeTaken
487 )
488 );
489 } else {
490 $traceFunc('receive', $result);
491 }
492 }
493
494 return $response;
495 }
496
497 throw new ClientException('Whoops, this should never happen');
498 }
499
500 /**
501 * Parse the response return the body values as an assoc array
502 *
503 * @throws Exception
504 *
505 * @param HttpResponse $response - the response as supplied by the server
506 *
507 * @return HttpResponse
508 */
509 public function parseResponse(HttpResponse $response)
510 {
511 $httpCode = $response->getHttpCode();
512
513 if ($httpCode < 200 || $httpCode >= 400) {
514 // failure on server
515
516 $body = $response->getBody();
517 if ($body !== '') {
518 // check if we can find details in the response body
519 $details = json_decode($body, true);
520 if (is_array($details) && isset($details['errorMessage'])) {
521 // yes, we got details
522 $exception = new ServerException($details['errorMessage'], $details['code']);
523 $exception->setDetails($details);
524 throw $exception;
525 }
526 }
527
528 // no details found, throw normal exception
529 throw new ServerException($response->getResult(), $httpCode);
530 }
531
532 return $response;
533 }
534
535 /**
536 * Stop capturing commands
537 *
538 * @return Batch - Returns the active batch object
539 */
540 public function stopCaptureBatch()
541 {
542 $this->_captureBatch = false;
543
544 return $this->_activeBatch;
545 }
546
547
548 /**
549 * Sets the active Batch for this connection
550 *
551 * @param Batch $batch - Sets the given batch as active
552 *
553 * @return Batch active batch
554 */
555 public function setActiveBatch($batch)
556 {
557 $this->_activeBatch = $batch;
558
559 return $this->_activeBatch;
560 }
561
562 /**
563 * returns the active batch
564 *
565 * @return Batch active batch
566 */
567 public function getActiveBatch()
568 {
569 return $this->_activeBatch;
570 }
571
572
573 /**
574 * Sets the batch capture state (true, if capturing)
575 *
576 * @param boolean $state true to turn on capture batch mode, false to turn it off
577 */
578 public function setCaptureBatch($state)
579 {
580 $this->_captureBatch = $state;
581 }
582
583
584 /**
585 * Sets connection into Batch-request mode. This is needed for some operations to act differently when in this mode.
586 *
587 * @param boolean $state sets the connection state to batch request, meaning it is currently doing a batch request.
588 */
589 public function setBatchRequest($state)
590 {
591 $this->_batchRequest = $state;
592 }
593
594
595 /**
596 * Returns true if this connection is in Batch-Capture mode
597 *
598 * @return bool
599 */
600 public function isInBatchCaptureMode()
601 {
602 return $this->_captureBatch;
603 }
604
605
606 /**
607 * returns the active batch
608 */
609 public function getBatches()
610 {
611 return $this->_batches;
612 }
613
614
615 /**
616 * This is a helper function to executeRequest that captures requests if we're in batch mode
617 *
618 * @param mixed $method - The method of the request (GET, POST...)
619 *
620 * @param string $request - The request to process
621 *
622 * This checks if we're in batch mode and returns a placeholder object,
623 * since we need to return some object that is expected by the caller.
624 * if we're not in batch mode it does not return anything, and
625 *
626 * @return mixed Batchpart or null if not in batch capturing mode
627 * @throws \triagens\ArangoDb\ClientException
628 */
629 private function doBatch($method, $request)
630 {
631 $batchPart = null;
632 if ($this->_captureBatch === true) {
633
634 /** @var $batch Batch */
635 $batch = $this->getActiveBatch();
636
637 $batchPart = $batch->append($method, $request);
638 }
639
640 # do batch processing
641 return $batchPart;
642 }
643
644 /**
645 * This function checks that the encoding of a string is utf.
646 * It only checks for printable characters.
647 *
648 *
649 * @param array $string the data to check
650 *
651 * @return boolean true if string is UTF-8, false if not
652 */
653 public static function detect_utf($string)
654 {
655 if (preg_match('//u', $string)) {
656 return true;
657 } else {
658 return false;
659 }
660 }
661
662
663 /**
664 * This function checks that the encoding of the keys and
665 * values of the array are utf-8, recursively.
666 * It will raise an exception if it encounters wrong encoded strings.
667 *
668 * @param array $data the data to check
669 *
670 * @throws ClientException
671 */
672 public static function check_encoding($data)
673 {
674 if (!is_array($data)) {
675 return;
676 }
677
678 foreach ($data as $key => $value) {
679 if (!is_array($value)) {
680 // check if the multibyte library function is installed and use it.
681 if (function_exists('mb_detect_encoding')) {
682 // check with mb library
683 if (is_string($key) && mb_detect_encoding($key, 'UTF-8', true) === false) {
684 throw new ClientException('Only UTF-8 encoded keys allowed. Wrong encoding in key string: ' . $key);
685 }
686 if (is_string($value) && mb_detect_encoding($value, 'UTF-8', true) === false) {
687 throw new ClientException('Only UTF-8 encoded values allowed. Wrong encoding in value string: ' . $value);
688 }
689 } else {
690 // fallback to preg_match checking
691 if (is_string($key) && self::detect_utf($key) === false) {
692 throw new ClientException('Only UTF-8 encoded keys allowed. Wrong encoding in key string: ' . $key);
693 }
694 if (is_string($value) && self::detect_utf($value) === false) {
695 throw new ClientException('Only UTF-8 encoded values allowed. Wrong encoding in value string: ' . $value);
696 }
697 }
698 } else {
699 self::check_encoding($value);
700 }
701 }
702 }
703
704
705 /**
706 * This is a json_encode() wrapper that also checks if the data is utf-8 conform.
707 * internally it calls the check_encoding() method. If that method does not throw
708 * an Exception, this method will happily return the json_encoded data.
709 *
710 * @param mixed $data the data to encode
711 * @param mixed $options the options for the json_encode() call
712 *
713 * @return string the result of the json_encode
714 * @throws \triagens\ArangoDb\ClientException
715 */
716 public function json_encode_wrapper($data, $options = null)
717 {
718 if ($this->_options[ConnectionOptions::OPTION_CHECK_UTF8_CONFORM] === true) {
719 self::check_encoding($data);
720 }
721 if (empty($data)) {
722 $response = json_encode($data, $options | JSON_FORCE_OBJECT);
723 } else {
724 $response = json_encode($data, $options);
725 }
726
727 return $response;
728 }
729
730
731 /**
732 * Set the database to use with this connection
733 *
734 * Sets the database to use with this connection, for example: 'my_database'<br>
735 * Further calls to the database will be addressed to the given database.
736 *
737 * @param string $database the database to use
738 */
739 public function setDatabase($database)
740 {
741 $this->_options[ConnectionOptions::OPTION_DATABASE] = $database;
742 $this->_database = $database;
743
744 $this->updateHttpHeader();
745 }
746
747 /**
748 * Get the database that is currently used with this connection
749 *
750 * Get the database to use with this connection, for example: 'my_database'
751 *
752 * @return string
753 */
754 public function getDatabase()
755 {
756 return $this->_database;
757 }
758 }
759