├── README ├── examples └── nested-pool-requests.php ├── benchmark └── 2x2.php └── src └── Hasty ├── HeaderStore.php ├── Loader.php ├── Pool.php ├── Response.php └── Request.php /README: -------------------------------------------------------------------------------- 1 | Note: This project is a work in progress and incomplete. Check back later! -------------------------------------------------------------------------------- /examples/nested-pool-requests.php: -------------------------------------------------------------------------------- 1 | register(); 12 | 13 | $pool = new \Hasty\Pool; 14 | $r1 = new \Hasty\Request('http://pear.phpunit.de'); 15 | $r2 = new \Hasty\Request('http://pear.php.net'); 16 | 17 | // The complete event can also be referenced as \Hasty\Request::EVENT_COMPLETE 18 | 19 | $r1->on('complete', function($response, $pool) { 20 | echo $response->getContent(), "\n\n\n====================\n\n\n"; 21 | $r3 = new \Hasty\Request('http://pear.survivethedeepend.com'); 22 | $r3->on('complete', function($response, $pool) { 23 | echo $response->getContent(), "\n\n\n====================\n\n\n"; 24 | }); 25 | $pool->attach($r3); 26 | }); 27 | 28 | $r2->on('complete', function($response, $pool) { 29 | echo $response->getContent(), "\n\n\n====================\n\n\n"; 30 | $r4 = new \Hasty\Request('http://saucelabs.github.com/pear/'); 31 | $r4->on('complete', function($response, $pool) { 32 | echo $response->getContent(), "\n\n\n====================\n\n\n"; 33 | }); 34 | $pool->attach($r4); 35 | }); 36 | 37 | $responses = $pool->attach($r1)->attach($r2)->run(); -------------------------------------------------------------------------------- /benchmark/2x2.php: -------------------------------------------------------------------------------- 1 | register(true); 16 | 17 | $time_start_hasty = microtime(true); 18 | for ($i=0; $i < $iterations; $i++) { 19 | $pool = new \Hasty\Pool; 20 | $r1 = new \Hasty\Request('http://pear.phpunit.de'); 21 | $r2 = new \Hasty\Request('http://pear.php.net'); 22 | $r3 = new \Hasty\Request('http://pear.survivethedeepend.com'); 23 | $r4 = new \Hasty\Request('http://saucelabs.github.com/pear/'); 24 | 25 | $r1->on('complete', function($response, $pool) { 26 | echo '.'; 27 | }); 28 | $r2->on('complete', function($response, $pool) { 29 | echo '.'; 30 | }); 31 | $r3->on('complete', function($response, $pool) { 32 | echo '.'; 33 | }); 34 | $r4->on('complete', function($response, $pool) { 35 | echo '.'; 36 | }); 37 | 38 | $responses = $pool->attach($r1)->attach($r2)->attach($r3)->attach($r4)->run(); 39 | } 40 | 41 | $hasty_time_cumulative = microtime(true) - $time_start_hasty; 42 | echo "\nAverage Hasty (Batched): ", round($hasty_time_cumulative/$iterations, 2), "s\n\n"; 43 | sleep($sleep); 44 | 45 | $time_start_hasty2 = microtime(true); 46 | for ($j=0; $j < $iterations; $j++) { 47 | $pool = new \Hasty\Pool; 48 | $r1 = new \Hasty\Request('http://pear.phpunit.de'); 49 | $r2 = new \Hasty\Request('http://pear.php.net'); 50 | 51 | $r1->on('complete', function($response, $pool) { 52 | echo '.'; 53 | $r3 = new \Hasty\Request('http://pear.survivethedeepend.com'); 54 | $r3->on('complete', function($response, $pool) { 55 | echo '.'; 56 | }); 57 | $pool->attach($r3); 58 | }); 59 | 60 | $r2->on('complete', function($response, $pool) { 61 | echo '.'; 62 | $r4 = new \Hasty\Request('http://saucelabs.github.com/pear/'); 63 | $r4->on('complete', function($response, $pool) { 64 | echo '.'; 65 | }); 66 | $pool->attach($r4); 67 | }); 68 | 69 | $responses = $pool->attach($r1)->attach($r2)->run(); 70 | } 71 | 72 | $hasty_time_cumulative2 = microtime(true) - $time_start_hasty2; 73 | echo "\nAverage Hasty (Nested): ", round($hasty_time_cumulative2/$iterations, 2), "s\n\n"; 74 | sleep($sleep); 75 | 76 | /** 77 | * Optionally set ZF include path to start of path stack 78 | */ 79 | set_include_path(realpath('../../zf/library') . PATH_SEPARATOR . get_include_path()); 80 | function autoload($class) { 81 | include_once str_replace('_', '/', $class) . '.php'; 82 | } 83 | spl_autoload_register('autoload', true, true); 84 | 85 | $time_start_zend = microtime(true); 86 | 87 | for ($k=0; $k < $iterations; $k++) { 88 | $client = new Zend_Http_Client(null, array('httpversion'=>'1.0', 'timeout'=>30)); 89 | $client->resetParameters()->setUri('http://pear.phpunit.de')->request(); echo '.'; 90 | $client->resetParameters()->setUri('http://pear.php.net')->request(); echo '.'; 91 | $client->resetParameters()->setUri('http://pear.survivethedeepend.com')->request(); echo '.'; 92 | $client->resetParameters()->setUri('http://saucelabs.github.com/pear/')->request(); echo '.'; 93 | } 94 | 95 | $zend_time_cumulative = microtime(true) - $time_start_zend; 96 | echo "\nAverage Zend_Http_Client (1.10): ", round($zend_time_cumulative/$iterations, 2), "s\n\n"; 97 | 98 | echo 'Hasty (Batched) was ' ,round(($zend_time_cumulative/$hasty_time_cumulative), 2) , ' times faster than ZF 1.10', "\n"; 99 | echo 'Hasty (Nested) was ' ,round(($zend_time_cumulative/$hasty_time_cumulative2), 2) , ' times faster than ZF 1.10', "\n"; -------------------------------------------------------------------------------- /src/Hasty/HeaderStore.php: -------------------------------------------------------------------------------- 1 | $value) { 14 | $this->set($header, $value); 15 | } 16 | } 17 | } 18 | 19 | public function set($name, $value, $replace = true) 20 | { 21 | $name = $this->normalize($name); 22 | if ($replace === false) { 23 | if (array_key_exists($name, $this->headers)) { 24 | return; 25 | } 26 | } else { 27 | $this->headers[$name] = $value; 28 | } 29 | return $this; 30 | } 31 | 32 | public function get($name) 33 | { 34 | $name = $this->normalize($name); 35 | if (isset($this->headers[$name])) { 36 | return $this->headers[$name]; 37 | } 38 | } 39 | 40 | public function has($name) 41 | { 42 | $name = $this->normalize($name); 43 | return array_key_exists($name, $this->headers); 44 | } 45 | 46 | public function contains($name, $value) 47 | { 48 | $name = $this->normalize($name); 49 | return $this->has($name) && $this->headers[$name] == $value; 50 | } 51 | 52 | public function remove($name) 53 | { 54 | $name = $this->normalize($name); 55 | if ($this->has($name)) { 56 | unset($this->headers[$name]); 57 | } 58 | return $this; 59 | } 60 | 61 | public function keys() 62 | { 63 | return array_keys($this->headers); 64 | } 65 | 66 | public function toArray() 67 | { 68 | return $this->headers; 69 | } 70 | 71 | public function populate(array $headers) 72 | { 73 | foreach ($headers as $name => $value) { 74 | $this->set($name, $value); 75 | } 76 | } 77 | 78 | public function clear() 79 | { 80 | $this->headers = array(); 81 | } 82 | 83 | public function toString() 84 | { 85 | if (count($this->headers) == 0) { 86 | return ''; 87 | } 88 | $string = ''; 89 | foreach ($this->headers as $name => $value) { 90 | if (strpos($name, '_')) { 91 | $parts = explode('_', $name); 92 | $parts = array_map('ucfirst', $parts); 93 | $name = implode('-', $parts); 94 | } else { 95 | $name = ucfirst($name); 96 | } 97 | $string .= sprintf("%s: %s\r\n", $name, $value); 98 | } 99 | return $string; 100 | } 101 | 102 | public function __toString() 103 | { 104 | return $this->toString(); 105 | } 106 | 107 | public function fromString($string) 108 | { 109 | $current = array(); 110 | foreach (preg_split('#\r\n#', $string) as $line) { 111 | if (preg_match('/^(?P[^()><@,;:\"\\/\[\]?=}{ \t]+):.*$/', $line, $matches)) { 112 | if ($current) { 113 | list($name, $value) = preg_split('#: #', $current['line'], 2); 114 | $this->set($name, $value); 115 | } 116 | $current = array( 117 | 'name' => $matches['name'], 118 | 'line' => trim($line) 119 | ); 120 | } elseif (preg_match('/^\s+.*$/', $line, $matches)) { 121 | $current['line'] .= trim($line); 122 | } elseif (preg_match('/^\s*$/', $line)) { 123 | break; 124 | } else { 125 | throw new \RuntimeException(sprintf( 126 | 'Line "%s" does not match header format!', 127 | $line 128 | )); 129 | } 130 | } 131 | if ($current) { 132 | list($name, $value) = preg_split('#: #', $current['line'], 2); 133 | $this->set($name, $value); 134 | } 135 | } 136 | 137 | public function count() 138 | { 139 | return count($this->headers); 140 | } 141 | 142 | protected function normalize($name) 143 | { 144 | return strtolower(str_replace('-', '_', trim($name))); 145 | } 146 | 147 | } -------------------------------------------------------------------------------- /src/Hasty/Loader.php: -------------------------------------------------------------------------------- 1 | register(); 12 | * 13 | * @author Jonathan H. Wage 14 | * @author Roman S. Borschel 15 | * @author Matthew Weier O'Phinney 16 | * @author Kris Wallsmith 17 | * @author Fabien Potencier 18 | */ 19 | 20 | namespace Hasty; 21 | 22 | class Loader 23 | { 24 | private $_fileExtension = '.php'; 25 | private $_namespace; 26 | private $_includePath; 27 | private $_namespaceSeparator = '\\'; 28 | 29 | /** 30 | * Creates a new Loader that loads classes of the 31 | * specified namespace. 32 | * 33 | * @param string $ns The namespace to use. 34 | */ 35 | public function __construct($ns = 'Hasty', $includePath = null) 36 | { 37 | $this->_namespace = $ns; 38 | $this->_includePath = $includePath; 39 | } 40 | 41 | /** 42 | * Sets the namespace separator used by classes in the namespace of this class loader. 43 | * 44 | * @param string $sep The separator to use. 45 | */ 46 | public function setNamespaceSeparator($sep) 47 | { 48 | $this->_namespaceSeparator = $sep; 49 | } 50 | 51 | /** 52 | * Gets the namespace seperator used by classes in the namespace of this class loader. 53 | * 54 | * @return void 55 | */ 56 | public function getNamespaceSeparator() 57 | { 58 | return $this->_namespaceSeparator; 59 | } 60 | 61 | /** 62 | * Sets the base include path for all class files in the namespace of this class loader. 63 | * 64 | * @param string $includePath 65 | */ 66 | public function setIncludePath($includePath) 67 | { 68 | $this->_includePath = $includePath; 69 | } 70 | 71 | /** 72 | * Gets the base include path for all class files in the namespace of this class loader. 73 | * 74 | * @return string $includePath 75 | */ 76 | public function getIncludePath() 77 | { 78 | return $this->_includePath; 79 | } 80 | 81 | /** 82 | * Sets the file extension of class files in the namespace of this class loader. 83 | * 84 | * @param string $fileExtension 85 | */ 86 | public function setFileExtension($fileExtension) 87 | { 88 | $this->_fileExtension = $fileExtension; 89 | } 90 | 91 | /** 92 | * Gets the file extension of class files in the namespace of this class loader. 93 | * 94 | * @return string $fileExtension 95 | */ 96 | public function getFileExtension() 97 | { 98 | return $this->_fileExtension; 99 | } 100 | 101 | /** 102 | * Installs this class loader on the SPL autoload stack. 103 | * 104 | * @param bool $prepend If true, prepend autoloader on the autoload stack 105 | */ 106 | public function register($prepend = false) 107 | { 108 | spl_autoload_register(array($this, 'loadClass'), true, $prepend); 109 | } 110 | 111 | /** 112 | * Uninstalls this class loader from the SPL autoloader stack. 113 | */ 114 | public function unregister() 115 | { 116 | spl_autoload_unregister(array($this, 'loadClass')); 117 | } 118 | 119 | /** 120 | * Loads the given class or interface. 121 | * 122 | * @param string $className The name of the class to load. 123 | * @return void 124 | */ 125 | public function loadClass($className) 126 | { 127 | /**if ($className === 'Hasty') { 128 | require 'Hasty.php'; 129 | return; 130 | }*/ 131 | if (null === $this->_namespace 132 | || $this->_namespace.$this->_namespaceSeparator === substr($className, 0, strlen($this->_namespace.$this->_namespaceSeparator))) { 133 | $fileName = ''; 134 | $namespace = ''; 135 | if (false !== ($lastNsPos = strripos($className, $this->_namespaceSeparator))) { 136 | $namespace = substr($className, 0, $lastNsPos); 137 | $className = substr($className, $lastNsPos + 1); 138 | $fileName = str_replace($this->_namespaceSeparator, DIRECTORY_SEPARATOR, $namespace) . DIRECTORY_SEPARATOR; 139 | } 140 | $fileName .= str_replace('_', DIRECTORY_SEPARATOR, $className) . $this->_fileExtension; 141 | require ($this->_includePath !== null ? $this->_includePath . DIRECTORY_SEPARATOR : '') . $fileName; 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Hasty/Pool.php: -------------------------------------------------------------------------------- 1 | 30, 35 | 'context' => null, 36 | 'max_redirects' => 3, 37 | 'chunk_size' => 1024 38 | ); 39 | 40 | protected $requests = array(); 41 | 42 | protected $responses = array(); 43 | 44 | protected $streams = array(); 45 | 46 | protected $states = array(); 47 | 48 | protected $streamCounter = 0; 49 | 50 | protected $maxTimeout = 30; 51 | 52 | protected $writeBuffers = array(); 53 | 54 | public function __construct(array $options = null) 55 | { 56 | if (!is_null($options)) { 57 | $options = $this->processOptions($options); 58 | $this->defaultOptions = $this->defaultOptions + $options; 59 | } 60 | } 61 | 62 | public function attach($request, array $requestOptions = null) 63 | { 64 | if(!$request instanceof Request) { 65 | if(!is_null($requestOptions)) $requestOptions = $requestOptions + $this->defaultOptions; 66 | $request = new Request($request, $requestOptions); 67 | } else { 68 | $request->setOptions($request->getOptions() + $this->defaultOptions); 69 | } 70 | $pointer = null; 71 | $errorCode = null; 72 | $errorString = null; 73 | set_error_handler(function($severity, $message, $file, $line) { 74 | throw new \ErrorException($message, $severity, $severity, $file, $line); 75 | }); 76 | $pointer = stream_socket_client( 77 | $request->getSocketUri(), 78 | $errorCode, 79 | $errorString, 80 | $request->getTimeout(), 81 | STREAM_CLIENT_ASYNC_CONNECT|STREAM_CLIENT_CONNECT, 82 | $request->getContext() 83 | ); 84 | restore_error_handler(); 85 | if ($pointer === false) { 86 | throw new \RuntimeException(sprintf( 87 | 'Error encountered while attempting to open a socket with message: %s', 88 | $errorString 89 | )); 90 | } 91 | stream_set_blocking($pointer, 0); 92 | $this->stashRequest($request, $pointer); 93 | return $this; 94 | } 95 | 96 | public function run() 97 | { 98 | if (count($this->responses) == 0) { 99 | throw new \RuntimeException( 100 | 'Unable to execute request pool as there appear to be no requests pooled. ' 101 | . 'You may want to add a few!' 102 | ); 103 | } 104 | set_error_handler(function($severity, $message, $file, $line) { 105 | throw new \ErrorException($message, $severity, $severity, $file, $line); 106 | }); 107 | while (!empty($this->streams)) { 108 | $excepts = array(); 109 | $reads = $writes = $this->streams; 110 | $result = stream_select($reads, $writes, $excepts, $this->maxTimeout); 111 | if ($result === false) { 112 | throw new \RuntimeException( 113 | 'Unexpected error encountered while opening streams' 114 | ); 115 | } 116 | if ($result > 0) { 117 | foreach ($reads as $read) { 118 | $this->performRead($read); 119 | } 120 | foreach ($writes as $write) { 121 | $this->performWrite($write); 122 | } 123 | } else { 124 | break; 125 | } 126 | if (!empty($this->streams)) { 127 | usleep(30000); 128 | } 129 | } 130 | restore_error_handler(); 131 | return $this->responses; 132 | } 133 | 134 | public function reset() 135 | { 136 | $this->responses = array(); 137 | $this->requests = array(); 138 | $this->streams = array(); 139 | $this->writeBuffers = array(); 140 | $this->streamCounter = 0; 141 | $this->maxTimeout = 30; 142 | } 143 | 144 | public function getResponses() 145 | { 146 | return $this->responses; 147 | } 148 | 149 | public function getDefaultOptions() 150 | { 151 | return $this->defaultOptions; 152 | } 153 | 154 | public function getDefaultOption($key) 155 | { 156 | if (array_key_exists($key, $this->defaultOptions)) { 157 | return $this->defaultOptions[$key]; 158 | } 159 | return null; 160 | } 161 | 162 | public function getMaxTimeout() 163 | { 164 | return $this->maxTimeout; 165 | } 166 | 167 | protected function setMaxTimeout($timeout) 168 | { 169 | $this->maxTimeout = (int) $timeout; 170 | } 171 | 172 | protected function performRead($read) 173 | { 174 | $id = array_search($read, $this->streams); 175 | $response = $this->responses[$id]; 176 | $request = $this->requests[$id]; 177 | $chunk = fread($read, $response->getChunkSize()); 178 | $response->appendChunk($chunk); 179 | if (count($response->headers) > 0 && $response->isRedirect()) { 180 | $response->setRequestStatus(self::STATUS_COMPLETED); // redirect status? 181 | fclose($read); 182 | unset($this->streams[$id]); 183 | return; 184 | } elseif (count($response->headers) > 0) { 185 | $response->setChunkSize(32768); 186 | } 187 | $meta = stream_get_meta_data($read); 188 | $active = !feof($read) 189 | && !$meta['eof'] 190 | && !$meta['timed_out'] 191 | && strlen($chunk); 192 | if (!$active) { 193 | if ($response->getRequestStatus() == self::STATUS_PROGRESSING) { 194 | $response->setRequestStatus(self::STATUS_CONNECTIONFAILED); 195 | } else { 196 | $response->setRequestStatus(self::STATUS_COMPLETED); 197 | } 198 | fclose($read); 199 | unset($this->streams[$id]); 200 | $request->setResponse($response); 201 | $request->trigger(Request::EVENT_COMPLETE, $this); 202 | } else { 203 | $response->setRequestStatus(self::STATUS_READING); 204 | } 205 | } 206 | 207 | protected function performWrite($write) 208 | { 209 | $id = array_search($write, $this->streams); 210 | $response = $this->responses[$id]; 211 | $request = $this->requests[$id]; 212 | if (isset($this->streams[$id]) 213 | && $response->getRequestStatus() == self::STATUS_PROGRESSING) { 214 | if (!isset($this->writeBuffers[$id])) { 215 | $this->writeBuffers[$id] = $request->toString(); 216 | } 217 | $size = strlen($this->writeBuffers[$id]); 218 | $written = fwrite($write, $this->writeBuffers[$id], $size); 219 | if ($written >= $size) { 220 | $response->setRequestStatus(self::STATUS_WAITINGFORRESPONSE); 221 | unset($this->writeBuffers[$id]); 222 | } else { 223 | $this->writeBuffers[$id] = substr($this->writeBuffers[$id], $written); 224 | } 225 | } 226 | } 227 | 228 | protected function handleRequestErrorFromRead() 229 | { 230 | 231 | } 232 | 233 | protected function handleRequestRedirectFromRead() 234 | { 235 | 236 | } 237 | 238 | protected function handleRedirectFor() 239 | { 240 | 241 | } 242 | 243 | protected function stashRequest(Request $request, $pointer) 244 | { 245 | $this->streams[$this->streamCounter] = $pointer; 246 | $this->responses[$this->streamCounter] = new Response( 247 | array('url' => $request->getUri(), 248 | 'options' => $request->getOptions(), 249 | 'request' => $request, 250 | 'raw_request' => $request->toString(), 251 | 'status' => self::STATUS_PROGRESSING, 252 | 'data' => '', 253 | 'redirect_uri' => null, 254 | 'redirect_code' => null, 255 | 'id' => null, 256 | 'protocol' => null, 257 | 'code' => null, 258 | 'message' => null, 259 | 'error' => false) 260 | ); 261 | $this->requests[$this->streamCounter] = $request; 262 | $this->streamCounter++; 263 | } 264 | 265 | protected function processOptions(array $options) 266 | { 267 | foreach ($options as $key => $value) { 268 | switch ($key) { 269 | case 'timeout': 270 | $value = (float) $value; 271 | $value = max($value, $this->getDefaultOption('timeout')); 272 | $options[$key] = (float) $value; 273 | $this->setMaxTimeout((float) $value); 274 | break; 275 | case 'context': 276 | if (!is_resource($value) || get_resource_type($value) !== 'stream-context') { 277 | throw new \InvalidArgumentException( 278 | 'Value of \'context\' provided to Hasty\Pool must be a valid ' 279 | . 'stream-context resource created via the stream_context_create() function' 280 | ); 281 | } 282 | break; 283 | case 'max_redirects': 284 | $value = (int) $value; 285 | if ($value <= 0) { 286 | throw new \InvalidArgumentException( 287 | 'Value of \'max_redirects\' provided to Hasty\Pool must be greater ' 288 | . 'than zero' 289 | ); 290 | } 291 | $options[$key] = $value; 292 | break; 293 | } 294 | } 295 | return $options; 296 | } 297 | 298 | } -------------------------------------------------------------------------------- /src/Hasty/Response.php: -------------------------------------------------------------------------------- 1 | null, 10 | 'options' => null, 11 | 'request' => null, 12 | 'raw_request' => '', 13 | 'status' => Pool::STATUS_PROGRESSING, 14 | 'data' => '', 15 | 'redirect_uri' => null, 16 | 'redirect_code' => null, 17 | 'id' => null, 18 | 'protocol' => null, 19 | 'code' => null, 20 | 'message' => null, 21 | 'error' => false 22 | ); 23 | 24 | const HTTP_10 = '1.0'; 25 | const HTTP_11 = '1.1'; 26 | 27 | public $headers = null; 28 | 29 | protected $protocol = 'HTTP'; // won't change in this iteration 30 | 31 | protected $version = self::HTTP_10; 32 | 33 | protected $statusCode = 200; 34 | 35 | protected $reasonPhrase = ''; 36 | 37 | protected $content = ''; 38 | 39 | protected $status = Pool::STATUS_PROGRESSING; 40 | 41 | protected $chunkSize = 1024; 42 | 43 | protected $responseCodes = array( 44 | 100 => 'Continue', 45 | 101 => 'Switching Protocols', 46 | 102 => 'Processing', 47 | 200 => 'OK', 48 | 201 => 'Created', 49 | 202 => 'Accepted', 50 | 203 => 'Non-Authoritative Information', 51 | 204 => 'No Content', 52 | 205 => 'Reset Content', 53 | 206 => 'Partial Content', 54 | 207 => 'Multi-status', 55 | 208 => 'Already Reported', 56 | 300 => 'Multiple Choices', 57 | 301 => 'Moved Permanently', 58 | 302 => 'Found', 59 | 303 => 'See Other', 60 | 304 => 'Not Modified', 61 | 305 => 'Use Proxy', 62 | 306 => 'Switch Proxy', 63 | 307 => 'Temporary Redirect', 64 | 400 => 'Bad Request', 65 | 401 => 'Unauthorized', 66 | 402 => 'Payment Required', 67 | 403 => 'Forbidden', 68 | 404 => 'Not Found', 69 | 405 => 'Method Not Allowed', 70 | 406 => 'Not Acceptable', 71 | 407 => 'Proxy Authentication Required', 72 | 408 => 'Request Time-out', 73 | 409 => 'Conflict', 74 | 410 => 'Gone', 75 | 411 => 'Length Required', 76 | 412 => 'Precondition Failed', 77 | 413 => 'Request Entity Too Large', 78 | 414 => 'Request-URI Too Large', 79 | 415 => 'Unsupported Media Type', 80 | 416 => 'Requested range not satisfiable', 81 | 417 => 'Expectation Failed', 82 | 418 => 'I\'m a teapot', 83 | 422 => 'Unprocessable Entity', 84 | 423 => 'Locked', 85 | 424 => 'Failed Dependency', 86 | 425 => 'Unordered Collection', 87 | 426 => 'Upgrade Required', 88 | 428 => 'Precondition Required', 89 | 429 => 'Too Many Requests', 90 | 431 => 'Request Header Fields Too Large', 91 | 500 => 'Internal Server Error', 92 | 501 => 'Not Implemented', 93 | 502 => 'Bad Gateway', 94 | 503 => 'Service Unavailable', 95 | 504 => 'Gateway Time-out', 96 | 505 => 'HTTP Version not supported', 97 | 506 => 'Variant Also Negotiates', 98 | 507 => 'Insufficient Storage', 99 | 508 => 'Loop Detected', 100 | 511 => 'Network Authentication Required', 101 | ); 102 | 103 | public function __construct(array $data) // deprecate! 104 | { 105 | foreach ($data as $key => $value) { 106 | $this->set($key, $value); 107 | } 108 | $this->headers = new HeaderStore(); 109 | } 110 | 111 | public function get($key) //deprecate! 112 | { 113 | if (isset($this->data[$key])) { 114 | return $this->data[$key]; 115 | } 116 | } 117 | 118 | public function set($key, $value) //deprecate! 119 | { 120 | if (!array_key_exists($key, $this->data)) { 121 | throw new \InvalidArgumentException ( 122 | 'Data key does not exist: '.$key 123 | ); 124 | } 125 | $data = array($key => $value); 126 | $this->data = array_merge($this->data, $data); 127 | } 128 | 129 | // the actual API to implement 130 | 131 | public function setVersion($version) 132 | { 133 | $this->version = $version; 134 | } 135 | 136 | public function getVersion() 137 | { 138 | return $this->version; 139 | } 140 | 141 | public function setStatusCode($code) 142 | { 143 | $code = (int) $code; 144 | if (!in_array($code, array_keys($this->responseCodes))) { 145 | throw new \InvalidArgumentException( 146 | 'Invalid status code provided: ' . $code 147 | ); 148 | } 149 | $this->statusCode = $code; 150 | } 151 | 152 | public function getStatusCode() 153 | { 154 | return $this->statusCode; 155 | } 156 | 157 | public function setReasonPhrase($phrase) 158 | { 159 | $this->reasonPhrase = $phrase; 160 | } 161 | 162 | public function getReasonPhrase() 163 | { 164 | if (empty($this->reasonPhrase)) { 165 | return $this->responseCodes[$this->getStatusCode()]; 166 | } 167 | } 168 | 169 | public function getHeaders() 170 | { 171 | return $this->headers; 172 | } 173 | 174 | public function getContent() 175 | { 176 | return $this->decodeContent($this->content); 177 | } 178 | 179 | public function setContent($content) 180 | { 181 | $this->content = $content; 182 | } 183 | 184 | public function setRequestStatus($status) 185 | { 186 | $this->status = $status; 187 | } 188 | 189 | public function getRequestStatus() 190 | { 191 | return $this->status; 192 | } 193 | 194 | public function setChunkSize($size) 195 | { 196 | $this->chunkSize = (int) $size; 197 | } 198 | 199 | public function getChunkSize() 200 | { 201 | return $this->chunkSize; 202 | } 203 | 204 | public function appendContent($content) 205 | { 206 | $this->setContent( 207 | $this->getContent() . $content 208 | ); 209 | } 210 | 211 | public function isClientError() 212 | { 213 | $code = $this->getStatusCode(); 214 | return ($code < 500 && $code >= 400); 215 | } 216 | 217 | public function isInformationalError() 218 | { 219 | $code = $this->getStatusCode(); 220 | return ($code >= 100 && $code < 200); 221 | } 222 | 223 | public function isForbidden() 224 | { 225 | return (403 == $this->getStatusCode()); 226 | } 227 | 228 | public function isOk() 229 | { 230 | return (200 === $this->getStatusCode()); 231 | } 232 | 233 | public function isNotFound() 234 | { 235 | return (404 === $this->getStatusCode()); 236 | } 237 | 238 | public function isServerError() 239 | { 240 | $code = $this->getStatusCode(); 241 | return (500 <= $code && 600 > $code); 242 | } 243 | 244 | public function isRedirect() 245 | { 246 | $code = $this->getStatusCode(); 247 | return (300 <= $code && 400 > $code); 248 | } 249 | 250 | public function isSuccess() 251 | { 252 | $code = $this->getStatusCode(); 253 | return (200 <= $code && 300 > $code); 254 | } 255 | 256 | public function toString() 257 | { 258 | $string = 'HTTP/' 259 | .$this->getVersion() 260 | .' '.$this->getStatusCode() 261 | .' '.$this->getReasonPhrase() 262 | ."\r\n" 263 | . (string) $this->headers 264 | ."\r\n" 265 | .$this->getContent(); 266 | return $string; 267 | } 268 | 269 | public function __toString() 270 | { 271 | return $this->toString(); 272 | } 273 | 274 | public function appendChunk($string) 275 | { 276 | if (strlen($string) === 0) { // add test for zero-length chunk handling 277 | return; 278 | } 279 | if (count($this->headers) === 0) { // and when there are no headers in response? ;) 280 | $lines = preg_split('/\r\n/', $string); 281 | if (!is_array($lines) || count($lines) == 1) { 282 | $lines = preg_split ('/\n/',$string); 283 | } 284 | $firstLine = array_shift($lines); 285 | $matches = null; 286 | if (!preg_match('/^HTTP\/(?P1\.[01]) (?P\d{3}) (?P.*)$/', 287 | $firstLine, $matches)) { 288 | throw new \InvalidArgumentException( 289 | 'A valid response status line was not found in the provided string' 290 | ); 291 | } 292 | $this->setVersion($matches['version']); 293 | $this->setStatusCode($matches['status']); 294 | $this->setReasonPhrase($matches['reason']); 295 | if (count($lines) == 0) { 296 | return; 297 | } 298 | $isHeader = true; 299 | $headers = $content = array(); 300 | while ($lines) { 301 | $nextLine = array_shift($lines); 302 | if ($nextLine == '') { 303 | $isHeader = false; 304 | continue; 305 | } 306 | if ($isHeader) { 307 | $headers[] .= $nextLine; 308 | } else { 309 | $content[] .= $nextLine; 310 | } 311 | } 312 | if ($headers) { 313 | $this->headers->fromString(implode("\r\n", $headers)); 314 | } 315 | if ($content) { 316 | $this->appendContent(implode("\r\n", $content)); 317 | } 318 | } else { 319 | $this->appendContent($string); 320 | } 321 | } 322 | 323 | public function fromString($string) 324 | { 325 | return $this->appendChunk($string); 326 | } 327 | 328 | protected function decodeContent($content) 329 | { 330 | if ($this->headers->contains('transfer_encoding', 'chunked')) { 331 | $decBody = ''; 332 | if (function_exists('mb_internal_encoding') && 333 | ((int) ini_get('mbstring.func_overload')) & 2) { 334 | $mbIntEnc = mb_internal_encoding(); 335 | mb_internal_encoding('ASCII'); 336 | } 337 | while (trim($content)) { 338 | if (!preg_match("/^([\da-fA-F]+)[^\r\n]*\r\n/sm", $content, $m)) { 339 | throw new Exception\RuntimeException( 340 | 'Error parsing body - does not seem to be a chunked message' 341 | ); 342 | } 343 | $length = hexdec(trim($m[1])); 344 | $cut = strlen($m[0]); 345 | $decBody .= substr($content, $cut, $length); 346 | $content = substr($content, $cut + $length + 2); 347 | } 348 | if (isset($mbIntEnc)) { 349 | mb_internal_encoding($mbIntEnc); 350 | } 351 | return $decBody; 352 | } elseif ($this->headers->contains('content_encoding', 'gzip')) { 353 | return gzinflate(substr($content), 10); 354 | } elseif ($this->headers->contains('content_encoding', 'deflate')) { 355 | return gzinflate($content); 356 | } 357 | return $content; 358 | } 359 | 360 | } -------------------------------------------------------------------------------- /src/Hasty/Request.php: -------------------------------------------------------------------------------- 1 | 30, 24 | 'context' => null, 25 | 'max_redirects' => 5 26 | ); 27 | 28 | protected $method = self::GET; 29 | 30 | protected $uri = null; 31 | 32 | protected $version = self::HTTP_10; 33 | 34 | protected $parameters = array(); 35 | 36 | public $headers = null; 37 | 38 | protected $query = array(); 39 | 40 | protected $post = array(); 41 | 42 | protected $file = array(); 43 | 44 | protected $uriScheme = null; 45 | 46 | protected $uriHost = null; 47 | 48 | protected $uriPort = null; 49 | 50 | protected $uriPath = null; 51 | 52 | protected $socketUri = null; 53 | 54 | protected $context = null; // set this to fix the stupid insecure PHP defaults 55 | 56 | protected $timeout = 30.0; 57 | 58 | protected $response = null; 59 | 60 | protected $callbacks = array(); 61 | 62 | public function __construct($url, array $options = null) 63 | { 64 | if (!filter_var($url, FILTER_VALIDATE_URL)) { 65 | throw new \InvalidArgumentException( 66 | 'Unable to create a new request due to an invalid URL: '.$url 67 | ); 68 | } 69 | $this->headers = new HeaderStore; 70 | if (!is_null($options)) { 71 | $this->setOptions($options); 72 | } 73 | $this->processUri($url); 74 | } 75 | 76 | public function setOptions(array $options) 77 | { 78 | $options = $this->processOptions($options); 79 | $this->options = $this->options + $options; 80 | } 81 | 82 | public function getOptions() 83 | { 84 | return $this->options; 85 | } 86 | 87 | public function setMethod($method) 88 | { 89 | if (!in_array($method, array(self::GET, self::POST, self::HEAD, 90 | self::PUT, self::DELETE, self::OPTIONS, self::TRACE, self::CONNECT))) { 91 | throw new \InvalidArgumentException(sprintf( 92 | 'Invalid method type given: %s', $method 93 | )); 94 | } 95 | $this->method = $method; 96 | } 97 | 98 | public function getMethod() 99 | { 100 | return $this->method; 101 | } 102 | 103 | public function setUri($uri) 104 | { 105 | if (!filter_var($uri, FILTER_VALIDATE_URL)) { 106 | throw new \InvalidArgumentException(sprintf( 107 | 'Invalid HTTP URI provided: %s', $uri 108 | )); 109 | } 110 | $this->processUri($uri); 111 | } 112 | 113 | public function getUri() 114 | { 115 | return $this->uri; 116 | } 117 | 118 | public function getUrl() 119 | { 120 | return $this->getUri(); 121 | } 122 | 123 | public function setVersion($version) 124 | { 125 | if (!in_array($version, array(self::HTTP_10, self::HTTP_11))) { 126 | throw new \InvalidArgumentException(sprintf( 127 | 'Invalid protocol version string: %s', $version 128 | )); 129 | } 130 | $this->version = $version; 131 | } 132 | 133 | public function getVersion() 134 | { 135 | return $this->version; 136 | } 137 | 138 | public function setQuery() 139 | { 140 | 141 | } 142 | 143 | public function setPost() 144 | { 145 | 146 | } 147 | 148 | public function setFile() 149 | { 150 | 151 | } 152 | 153 | public function setContext($context) 154 | { 155 | if (!is_null($context) && (!is_resource($context) 156 | || get_resource_type($context) !== 'stream-context')) { 157 | throw new \InvalidArgumentException( 158 | 'Value of \'context\' provided to Hasty\Request must be a valid ' 159 | . 'stream-context resource created via the stream_context_create() function' 160 | ); 161 | } 162 | $this->context = $context; 163 | } 164 | 165 | public function getContext() 166 | { 167 | if (!is_null($this->context)) { 168 | return $this->context; 169 | } 170 | $this->context = stream_context_create(); 171 | return $this->context; 172 | } 173 | 174 | public function setTimeout($timeout) 175 | { 176 | $this->timeout = (float) $timeout; 177 | } 178 | 179 | public function getTimeout() 180 | { 181 | return $this->timeout; 182 | } 183 | 184 | // parseable URI info 185 | 186 | public function setUriScheme($scheme) 187 | { 188 | $this->uriScheme = $scheme; 189 | } 190 | 191 | public function setUriHost($host) 192 | { 193 | $this->uriHost = $host; 194 | } 195 | 196 | public function setUriPort($port) 197 | { 198 | $this->uriPort = $port; 199 | } 200 | 201 | public function setUriPath($path) 202 | { 203 | $this->uriPath = $path; 204 | } 205 | 206 | public function setSocketUri($uri) 207 | { 208 | $this->socketUri = $uri; 209 | } 210 | 211 | public function getUriScheme() 212 | { 213 | return $this->uriScheme; 214 | } 215 | 216 | public function getUriHost() 217 | { 218 | return $this->uriHost; 219 | } 220 | 221 | public function getUriPort() 222 | { 223 | return $this->uriPort; 224 | } 225 | 226 | public function getUriPath() 227 | { 228 | return $this->uriPath; 229 | } 230 | 231 | public function getSocketUri() 232 | { 233 | return $this->socketUri; 234 | } 235 | 236 | public function getHeaders() 237 | { 238 | return $this->headers; 239 | } 240 | 241 | public function setResponse(Response $response) 242 | { 243 | $this->response = $response; 244 | } 245 | 246 | public function getResponse() 247 | { 248 | return $this->response; 249 | } 250 | 251 | public function on($event, $callback) 252 | { 253 | if (!in_array($event, array(self::EVENT_COMPLETE))) { 254 | throw new \InvalidArgumentException(sprintf( 255 | 'Invalid event name passed to Request: %s', $event 256 | )); 257 | } 258 | if (!$this->isCallable($callback)) { 259 | throw new \InvalidArgumentException(sprintf( 260 | 'Invalid callback passed to Request: %s', (string) $callback 261 | )); 262 | } 263 | $this->callbacks[$event] = $callback; 264 | } 265 | 266 | public function hasCallback($event) 267 | { 268 | if (isset($this->callbacks[$event])) { 269 | return true; 270 | } 271 | return false; 272 | } 273 | 274 | public function getCallback($event) 275 | { 276 | if (isset($this->callbacks[$event])) { 277 | return $this->callbacks[$event]; 278 | } 279 | throw new \RuntimeException(sprintf( 280 | 'No callback exists for event: %s. Check for callback existence using hasCallback()', $event 281 | )); 282 | } 283 | 284 | public function trigger($event, Pool $pool) 285 | { 286 | if (!in_array($event, array(self::EVENT_COMPLETE))) { 287 | throw new \InvalidArgumentException(sprintf( 288 | 'Invalid event name triggered on Request: %s', $event 289 | )); 290 | } 291 | if (isset($this->callbacks[$event])) { 292 | $callback = $this->callbacks[$event]; 293 | $callback($this->getResponse(), $pool); // fix later for class|method arrays 294 | } 295 | } 296 | 297 | public function isGet() 298 | { 299 | return $this->getMethod() === self::GET; 300 | } 301 | 302 | public function isPost() 303 | { 304 | return $this->getMethod() === self::POST; 305 | } 306 | 307 | public function isHead() 308 | { 309 | return $this->getMethod() === self::HEAD; 310 | } 311 | 312 | public function isPut() 313 | { 314 | return $this->getMethod() === self::PUT; 315 | } 316 | 317 | public function isDelete() 318 | { 319 | return $this->getMethod() === self::DELETE; 320 | } 321 | 322 | public function isOptions() 323 | { 324 | return $this->getMethod() === self::OPTIONS; 325 | } 326 | 327 | public function isTrace() 328 | { 329 | return $this->getMethod() === self::TRACE; 330 | } 331 | 332 | public function isConnect() 333 | { 334 | return $this->getMethod() === self::CONNECT; 335 | } 336 | 337 | public function isSecure() 338 | { 339 | return $this->getUriScheme() === 'https'; 340 | } 341 | 342 | public function toString() 343 | { 344 | $this->headers->set('host', $this->getUriHost()); 345 | $this->headers->set('connection', 'close'); 346 | $request = $this->getMethod() 347 | . " " 348 | . $this->getUriPath() 349 | . " HTTP/" 350 | . $this->getVersion() 351 | . "\r\n" 352 | . $this->headers->toString() 353 | . "\r\n"; 354 | return $request; 355 | } 356 | 357 | public function __toString() 358 | { 359 | return $this->toString(); 360 | } 361 | 362 | public function fromString($string) 363 | { 364 | 365 | } 366 | 367 | protected function processUri($uri) 368 | { 369 | $parts = parse_url($uri); 370 | $port = ''; 371 | $socket = ''; 372 | $host = $parts['host']; 373 | $path = ''; 374 | $request = ''; 375 | switch ($parts['scheme']) { 376 | case 'http': 377 | if (isset($parts['port'])) { 378 | $port = $parts['port']; 379 | $host = $host.':'.$port; 380 | } else { 381 | $port = '80'; 382 | } 383 | $socket = 'tcp://'.$host.':'.$port; 384 | break; 385 | case 'https': 386 | if (isset($parts['port'])) { 387 | $port = $parts['port']; 388 | $host = $host.':'.$port; 389 | } else { 390 | $port = '443'; 391 | } 392 | $socket = 'ssl://'.$host.':'.$port; 393 | break; 394 | default: 395 | throw new \InvalidArgumentException(sprintf( 396 | 'Unable to add a new request due to an unsupported URL schema in: %s', $uri 397 | )); 398 | break; 399 | } 400 | if (isset($parts['path'])) { 401 | $path = $parts['path']; 402 | } else { 403 | $path = '/'; 404 | } 405 | $this->uri = $uri; 406 | $this->setUriScheme($parts['scheme']); 407 | $this->setUriHost($host); 408 | $this->setUriPort($port); 409 | $this->setUriPath($path); 410 | $this->setSocketUri($socket); 411 | } 412 | 413 | protected function isCallable($callback) 414 | { 415 | if (is_array($callback) && !is_object($callback[0])) { 416 | if (!class_exists($callback[0], true) 417 | || !method_exists($callback[0], $callback[1])) { 418 | return false; // class or method don't exist 419 | } 420 | $method = new ReflectionMethod($callback[0], $callback[1]); 421 | if (!$method->isStatic()) { 422 | return false; // method on non-instanced class must be static 423 | } 424 | return true; // we can call statically 425 | } 426 | return is_callable($callback); // catches everything else 427 | } 428 | 429 | protected function processOptions(array $options) 430 | { 431 | foreach ($options as $key => $value) { 432 | switch ($key) { 433 | case 'timeout': 434 | $value = (float) $value; 435 | $value = max($value, $this->options[$key]); 436 | $options[$key] = (float) $value; 437 | break; 438 | case 'max_redirects': 439 | $value = (int) $value; 440 | if ($value <= 0) { 441 | throw new \InvalidArgumentException( 442 | 'Value of \'max_redirects\' provided to Hasty\\Request must be greater ' 443 | . 'than zero' 444 | ); 445 | } 446 | $options[$key] = $value; 447 | break; 448 | case 'headers': 449 | if (!is_array($value)) { // TODO - accept HeaderStore ;) 450 | throw new \InvalidArgumentException( 451 | 'Value of \'headers\' provided to Hasty\\Request must be an ' 452 | . 'associative array of header names and values.' 453 | ); 454 | } 455 | $this->headers->populate($value); 456 | unset($options['headers']); 457 | break; 458 | case 'method': 459 | $this->setMethod($value); 460 | unset($options['method']); 461 | break; 462 | } 463 | } 464 | return $options; 465 | } 466 | 467 | } --------------------------------------------------------------------------------