Removed cloudfiles dependency in SwiftFileBackend
[lhc/web/wiklou.git] / includes / libs / MultiHttpClient.php
1 <?php
2
3 /**
4 * Class to handle concurrent HTTP requests
5 *
6 * HTTP request maps use the following format:
7 * - method : GET/HEAD/PUT/POST/DELETE
8 * - url : HTTP/HTTPS URL
9 * - query : <query parameter field/value associative array>
10 * - headers : <header name/value associative array>
11 * - body : source to get the HTTP request body from;
12 * this can simply be a string (always), a resource for
13 * PUT requests, and a field/value array for POST request
14 * - stream : resource to stream the HTTP response body to
15 *
16 * @author Aaron Schulz
17 * @since 1.23
18 */
19 class MultiHttpClient {
20 /** @var resource */
21 protected $multiHandle = null; // curl_multi handle
22 /** @var string|null SSL certificates path */
23 protected $caBundlePath;
24 /** @var integer */
25 protected $connTimeout;
26 /** @var integer */
27 protected $reqTimeout;
28
29 /**
30 * @param array $options
31 */
32 public function __construct( array $options ) {
33 if ( isset( $options['caBundlePath'] ) ) {
34 $this->caBundlePath = $options['caBundlePath'];
35 if ( !file_exists( $this->caBundlePath ) ) {
36 throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath );
37 }
38 }
39 static $defaults = array( 'connTimeout' => 10, 'reqTimeout' => 300 );
40 foreach ( $defaults as $key => $default ) {
41 $this->$key = isset( $options[$key] ) ? $options[$key] : $default;
42 }
43 }
44
45 /**
46 * Execute an HTTP(S) request
47 *
48 * This method returns a response map of:
49 * - code : HTTP response code or 0 if there was a serious cURL error
50 * - reason : HTTP response reason (empty if there was a serious cURL error)
51 * - headers : <header name/value associative array>
52 * - body : HTTP response body or resource (if "stream" was set)
53 * - err : Any cURL error string
54 * The map also stores integer-indexed copies of these values. This lets callers do:
55 * <code>
56 * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req;
57 * </code>
58 * @param array $req HTTP request array
59 * @return array Response array for request
60 */
61 public function run( array $req ) {
62 $req = $this->runMulti( array( $req ) );
63 return $req[0]['response'];
64 }
65
66 /**
67 * Execute a set of HTTP(S) request concurrently
68 *
69 * The maps are returned by this method with the 'response' field set to a map of:
70 * - code : HTTP response code or 0 if there was a serious cURL error
71 * - reason : HTTP response reason (empty if there was a serious cURL error)
72 * - headers : <header name/value associative array>
73 * - body : HTTP response body or resource (if "stream" was set)
74 * - err : Any cURL error string
75 * The map also stores integer-indexed copies of these values. This lets callers do:
76 * <code>
77 * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req;
78 * </code>
79 * All headers in the 'headers' field are normalized to use lower case names.
80 * This is true for the request headers and the response headers.
81 *
82 * @param array $req Map of HTTP request arrays
83 * @return array $reqs With response array populated for each
84 */
85 public function runMulti( array $reqs ) {
86 $multiHandle = $this->getCurlMulti();
87
88 // Normalize $reqs and add all of the required cURL handles...
89 $handles = array();
90 foreach ( $reqs as $index => &$req ) {
91 $req['response'] = array(
92 'code' => 0,
93 'reason' => '',
94 'headers' => array(),
95 'body' => '',
96 'error' => ''
97 );
98 if ( !isset( $req['method'] ) ) {
99 throw new Exception( "Request has no 'method' field set." );
100 } elseif ( !isset( $req['url'] ) ) {
101 throw new Exception( "Request has no 'url' field set." );
102 }
103 $req['query'] = isset( $req['query'] ) ? $req['query'] : array();
104 $headers = array(); // normalized headers
105 if ( isset( $req['headers'] ) ) {
106 foreach ( $req['headers'] as $name => $value ) {
107 $headers[strtolower( $name )] = $value;
108 }
109 }
110 $req['headers'] = $headers;
111 if ( !isset( $req['body'] ) ) {
112 $req['body'] = '';
113 $req['headers']['content-length'] = 0;
114 }
115 $handles[$index] = $this->getCurlHandle( $req );
116 if ( count( $reqs ) > 1 ) {
117 // https://github.com/guzzle/guzzle/issues/349
118 curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true );
119 }
120 curl_multi_add_handle( $multiHandle, $handles[$index] );
121 }
122
123 // Execute the cURL handles concurrently...
124 $active = null; // handles still being processed
125 do {
126 // Do any available work...
127 do {
128 $mrc = curl_multi_exec( $multiHandle, $active );
129 } while ( $mrc == CURLM_CALL_MULTI_PERFORM );
130 // Wait (if possible) for available work...
131 if ( $active > 0 && $mrc == CURLM_OK ) {
132 if ( curl_multi_select( $multiHandle, 10 ) == -1 ) {
133 // PHP bug 63411; http://curl.haxx.se/libcurl/c/curl_multi_fdset.html
134 usleep( 5000 ); // 5ms
135 }
136 }
137 } while ( $active > 0 && $mrc == CURLM_OK );
138
139 // Remove all of the added cURL handles and check for errors...
140 foreach ( $reqs as $index => &$req ) {
141 $ch = $handles[$index];
142 curl_multi_remove_handle( $multiHandle, $ch );
143 if ( curl_errno( $ch ) !== 0 ) {
144 $req['error'] = "(curl error: " . curl_errno( $ch ) . ") " . curl_error( $ch );
145 }
146 // For convenience with the list() operator
147 $req['response'][0] = $req['response']['code'];
148 $req['response'][1] = $req['response']['reason'];
149 $req['response'][2] = $req['response']['headers'];
150 $req['response'][3] = $req['response']['body'];
151 $req['response'][4] = $req['response']['error'];
152 curl_close( $ch );
153 // Close any string wrapper file handles
154 if ( isset( $req['_closeHandle'] ) ) {
155 fclose( $req['_closeHandle'] );
156 unset( $req['_closeHandle'] );
157 }
158 }
159
160 return $reqs;
161 }
162
163 /**
164 * @param array $req HTTP request map
165 * @return resource
166 */
167 protected function getCurlHandle( array &$req ) {
168 $ch = curl_init();
169
170 curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, $this->connTimeout );
171 curl_setopt( $ch, CURLOPT_TIMEOUT, $this->reqTimeout );
172 curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 );
173 curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 );
174 curl_setopt( $ch, CURLOPT_HEADER, 0 );
175 if ( !is_null( $this->caBundlePath ) ) {
176 curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
177 curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath );
178 }
179 curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
180
181 $url = $req['url'];
182 $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 );
183 if ( $query != '' ) {
184 $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query";
185 }
186 curl_setopt( $ch, CURLOPT_URL, $url );
187
188 curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] );
189 if ( $req['method'] === 'HEAD' ) {
190 curl_setopt( $ch, CURLOPT_NOBODY, 1 );
191 }
192
193 if ( $req['method'] === 'PUT' ) {
194 curl_setopt( $ch, CURLOPT_PUT, 1 );
195 if ( is_resource( $req['body'] ) ) {
196 curl_setopt( $ch, CURLOPT_INFILE, $req['body'] );
197 if ( isset( $req['headers']['content-length'] ) ) {
198 curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] );
199 } elseif ( isset( $req['headers']['transfer-encoding'] ) &&
200 $req['headers']['transfer-encoding'] === 'chunks'
201 ) {
202 curl_setopt( $ch, CURLOPT_UPLOAD, true );
203 } else {
204 throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." );
205 }
206 } elseif ( $req['body'] !== '' ) {
207 $fp = fopen( "php://temp", "wb+" );
208 fwrite( $fp, $req['body'], strlen( $req['body'] ) );
209 rewind( $fp );
210 curl_setopt( $ch, CURLOPT_INFILE, $fp );
211 curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) );
212 $req['_closeHandle'] = $fp; // remember to close this later
213 } else {
214 curl_setopt( $ch, CURLOPT_INFILESIZE, 0 );
215 }
216 curl_setopt( $ch, CURLOPT_READFUNCTION,
217 function ( $ch, $fd, $length ) {
218 $data = fread( $fd, $length );
219 $len = strlen( $data );
220 return $data;
221 }
222 );
223 } elseif ( $req['method'] === 'POST' ) {
224 curl_setopt( $ch, CURLOPT_POST, 1 );
225 curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] );
226 } else {
227 if ( is_resource( $req['body'] ) || $req['body'] !== '' ) {
228 throw new Exception( "HTTP body specified for a non PUT/POST request." );
229 }
230 $req['headers']['content-length'] = 0;
231 }
232
233 $headers = array();
234 foreach ( $req['headers'] as $name => $value ) {
235 if ( strpos( $name, ': ' ) ) {
236 throw new Exception( "Headers cannot have ':' in the name." );
237 }
238 $headers[] = $name . ': ' . trim( $value );
239 }
240 curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
241
242 curl_setopt( $ch, CURLOPT_HEADERFUNCTION,
243 function ( $ch, $header ) use ( &$req ) {
244 $length = strlen( $header );
245 $matches = array();
246 if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) {
247 $req['response']['code'] = (int)$matches[2];
248 $req['response']['reason'] = trim( $matches[3] );
249 return $length;
250 }
251 if ( strpos( $header, ":" ) === false ) {
252 return $length;
253 }
254 list( $name, $value ) = explode( ":", $header, 2 );
255 $req['response']['headers'][strtolower( $name )] = trim( $value );
256 return $length;
257 }
258 );
259
260 if ( isset( $req['stream'] ) ) {
261 // Don't just use CURLOPT_FILE as that might give:
262 // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE*
263 // The callback here handles both normal files and php://temp handles.
264 curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
265 function ( $ch, $data ) use ( &$req ) {
266 return fwrite( $req['stream'], $data );
267 }
268 );
269 } else {
270 curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
271 function ( $ch, $data ) use ( &$req ) {
272 $req['response']['body'] .= $data;
273 return strlen( $data );
274 }
275 );
276 }
277
278 return $ch;
279 }
280
281 /**
282 * @return resource
283 */
284 protected function getCurlMulti() {
285 if ( !$this->multiHandle ) {
286 $this->multiHandle = curl_multi_init();
287 }
288 return $this->multiHandle;
289 }
290
291 function __destruct() {
292 if ( $this->multiHandle ) {
293 curl_multi_close( $this->multiHandle );
294 }
295 }
296 }