1 <?php
2
3 /**
4 * ArangoDB PHP client: http helper methods
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 * Helper methods for HTTP request/response handling
15 *
16 * @package triagens\ArangoDb
17 * @since 0.2
18 */
19 class HttpHelper
20 {
21 /**
22 * HTTP POST string constant
23 */
24 const METHOD_POST = 'POST';
25
26 /**
27 * HTTP PUT string constant
28 */
29 const METHOD_PUT = 'PUT';
30
31 /**
32 * HTTP DELETE string constant
33 */
34 const METHOD_DELETE = 'DELETE';
35
36 /**
37 * HTTP GET string constant
38 */
39 const METHOD_GET = 'GET';
40
41 /**
42 * HTTP HEAD string constant
43 */
44 const METHOD_HEAD = 'HEAD';
45
46 /**
47 * HTTP PATCH string constant
48 */
49 const METHOD_PATCH = 'PATCH';
50
51 /**
52 * Chunk size (number of bytes processed in one batch)
53 */
54 const CHUNK_SIZE = 8192;
55
56 /**
57 * End of line mark used in HTTP
58 */
59 const EOL = "\r\n";
60
61 /**
62 * Separator between header and body
63 */
64 const SEPARATOR = "\r\n\r\n";
65
66 /**
67 * HTTP protocol version used, hard-coded to version 1.1
68 */
69 const PROTOCOL = 'HTTP/1.1';
70
71 /**
72 * Create a one-time HTTP connection by opening a socket to the server
73 *
74 * It is the caller's responsibility to close the socket
75 *
76 * @throws ConnectException
77 *
78 * @param ConnectionOptions $options - connection options
79 *
80 * @return resource - socket with server connection, will throw when no connection can be established
81 */
82 public static function createConnection(ConnectionOptions $options)
83 {
84 $endpoint = $options[ConnectionOptions::OPTION_ENDPOINT];
85
86 $context = stream_context_create();
87
88 if (Endpoint::getType($endpoint) === Endpoint::TYPE_SSL) {
89 // set further SSL options for the endpoint
90 stream_context_set_option($context, 'ssl', 'verify_peer', $options[ConnectionOptions::OPTION_VERIFY_CERT]);
91 stream_context_set_option($context, 'ssl', 'allow_self_signed', $options[ConnectionOptions::OPTION_ALLOW_SELF_SIGNED]);
92
93 if ($options[ConnectionOptions::OPTION_CIPHERS] !== null) {
94 // SSL ciphers
95 stream_context_set_option($context, 'ssl', 'ciphers', $options[ConnectionOptions::OPTION_CIPHERS]);
96 }
97 }
98
99 $fp = @stream_socket_client(
100 $endpoint,
101 $errNo,
102 $message,
103 $options[ConnectionOptions::OPTION_TIMEOUT],
104 STREAM_CLIENT_CONNECT,
105 $context
106 );
107
108 if (!$fp) {
109 throw new ConnectException(
110 'cannot connect to endpoint \'' .
111 $options[ConnectionOptions::OPTION_ENDPOINT] . '\': ' . $message, $errNo
112 );
113 }
114
115 stream_set_timeout($fp, $options[ConnectionOptions::OPTION_TIMEOUT]);
116
117 return $fp;
118 }
119
120 /**
121 * Boundary string for batch request parts
122 */
123 const MIME_BOUNDARY = 'XXXsubpartXXX';
124
125 /**
126 * HTTP Header for making an operation asynchronous
127 */
128 const ASYNC_HEADER = 'X-Arango-Async';
129
130 /**
131 * Create a request string (header and body)
132 *
133 * @param ConnectionOptions $options - connection options
134 * @param string $connectionHeader - pre-assembled header string for connection
135 * @param string $method - HTTP method
136 * @param string $url - HTTP URL
137 * @param string $body - optional body to post
138 * @param array $customHeaders - any array containing header elements
139 *
140 * @return string - assembled HTTP request string
141 * @throws ClientException
142 *
143 */
144 public static function buildRequest(ConnectionOptions $options, $connectionHeader, $method, $url, $body, array $customHeaders = [])
145 {
146 if (!is_string($body)) {
147 throw new ClientException('Invalid value for body. Expecting string, got ' . gettype($body));
148 }
149
150 $length = strlen($body);
151
152 if ($options[ConnectionOptions::OPTION_BATCH] === true) {
153 $contentType = 'Content-Type: multipart/form-data; boundary=' . self::MIME_BOUNDARY . self::EOL;
154 } else {
155 $contentType = '';
156
157 if ($length > 0 && $options[ConnectionOptions::OPTION_BATCHPART] === false) {
158 // if body is set, we should set a content-type header
159 $contentType = 'Content-Type: application/json' . self::EOL;
160 }
161 }
162
163 $customHeader = '';
164 foreach ($customHeaders as $headerKey => $headerValue) {
165 $customHeader .= $headerKey . ': ' . $headerValue . self::EOL;
166 }
167
168 // finally assemble the request
169 $request = sprintf('%s %s %s', $method, $url, self::PROTOCOL) .
170 $connectionHeader . // note: this one starts with an EOL
171 $customHeader .
172 $contentType .
173 sprintf('Content-Length: %s', $length) . self::EOL . self::EOL .
174 $body;
175
176 return $request;
177 }
178
179 /**
180 * Validate an HTTP request method name
181 *
182 * @throws ClientException
183 *
184 * @param string $method - method name
185 *
186 * @return bool - always true, will throw if an invalid method name is supplied
187 */
188 public static function validateMethod($method)
189 {
190 if ($method === self::METHOD_POST ||
191 $method === self::METHOD_PUT ||
192 $method === self::METHOD_DELETE ||
193 $method === self::METHOD_GET ||
194 $method === self::METHOD_HEAD ||
195 $method === self::METHOD_PATCH
196 ) {
197 return true;
198 }
199
200 throw new ClientException('Invalid request method \'' . $method . '\'');
201 }
202
203 /**
204 * Execute an HTTP request on an opened socket
205 *
206 * It is the caller's responsibility to close the socket
207 *
208 * @param resource $socket - connection socket (must be open)
209 * @param string $request - complete HTTP request as a string
210 *
211 * @throws ClientException
212 * @return string - HTTP response string as provided by the server
213 */
214 public static function transfer($socket, $request)
215 {
216 if (!is_resource($socket)) {
217 throw new ClientException('Invalid socket used');
218 }
219
220 assert(is_string($request));
221
222 @fwrite($socket, $request);
223 @fflush($socket);
224
225 $contentLength = null;
226 $expectedLength = null;
227 $totalRead = 0;
228 $contentLengthPos = 0;
229
230 $result = '';
231 $first = true;
232
233 while ($first || !feof($socket)) {
234 $read = @fread($socket, self::CHUNK_SIZE);
235 if ($read === false || $read === '') {
236 break;
237 }
238
239 $totalRead += strlen($read);
240
241 if ($first) {
242 $result = $read;
243 $first = false;
244 } else {
245 $result .= $read;
246 }
247
248 if ($contentLength === null) {
249 // check if content-length header is present
250
251 // 12 = minimum offset (i.e. strlen("HTTP/1.1 xxx") -
252 // after that we could see "content-length:"
253 $pos = stripos($result, 'content-length: ', 12);
254
255 if ($pos !== false) {
256 $contentLength = (int) substr($result, $pos + 16, 10); // 16 = strlen("content-length: ")
257 $contentLengthPos = $pos + 17; // 17 = 16 + 1 one digit
258 }
259 }
260
261 if ($contentLength !== null && $expectedLength === null) {
262 $bodyStart = strpos($result, "\r\n\r\n", $contentLengthPos);
263 if ($bodyStart !== false) {
264 $bodyStart += 4; // 4 = strlen("\r\n\r\n")
265 $expectedLength = $bodyStart + $contentLength;
266 }
267 }
268
269 if ($expectedLength !== null && $totalRead >= $expectedLength) {
270 break;
271 }
272 }
273
274 return $result;
275 }
276
277 /**
278 * Splits a http message into its header and body.
279 *
280 * @param string $httpMessage The http message string.
281 * @param string $originUrl The original URL the response is coming from
282 * @param string $originMethod The HTTP method that was used when sending data to the origin URL
283 *
284 * @throws ClientException
285 * @return array
286 */
287 public static function parseHttpMessage($httpMessage, $originUrl = null, $originMethod = null)
288 {
289 return explode(self::SEPARATOR, $httpMessage, 2);
290 }
291
292 /**
293 * Process a string of HTTP headers into an array of header => values.
294 *
295 * @param string $headers - the headers string
296 *
297 * @return array
298 */
299 public static function parseHeaders($headers)
300 {
301 $httpCode = null;
302 $result = null;
303 $processed = [];
304
305 foreach (explode(HttpHelper::EOL, $headers) as $lineNumber => $line) {
306 if ($lineNumber === 0) {
307 // first line of result is special
308 if (preg_match("/^HTTP\/\d+\.\d+\s+(\d+)/", $line, $matches)) {
309 $httpCode = (int) $matches[1];
310 }
311 $result = $line;
312 } else {
313 // other lines contain key:value-like headers
314 // the following is a performance optimization to get rid of
315 // the two trims (which are expensive as they are executed over and over)
316 if (strpos($line, ': ') !== false) {
317 list($key, $value) = explode(': ', $line, 2);
318 } else {
319 list($key, $value) = explode(':', $line, 2);
320 }
321 $processed[strtolower($key)] = $value;
322 }
323 }
324
325 return [$httpCode, $result, $processed];
326 }
327 }
328