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