├── .editorconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.rst ├── composer.json ├── docs ├── Makefile ├── client_handlers.rst ├── client_middleware.rst ├── conf.py ├── futures.rst ├── index.rst ├── requirements.txt ├── spec.rst └── testing.rst ├── phpunit.xml.dist ├── src ├── Client │ ├── ClientUtils.php │ ├── CurlFactory.php │ ├── CurlHandler.php │ ├── CurlMultiHandler.php │ ├── Middleware.php │ ├── MockHandler.php │ └── StreamHandler.php ├── Core.php ├── Exception │ ├── CancelledException.php │ ├── CancelledFutureAccessException.php │ ├── ConnectException.php │ └── RingException.php └── Future │ ├── BaseFutureTrait.php │ ├── CompletedFutureArray.php │ ├── CompletedFutureValue.php │ ├── FutureArray.php │ ├── FutureArrayInterface.php │ ├── FutureInterface.php │ ├── FutureValue.php │ └── MagicFutureTrait.php └── tests ├── Client ├── CurlFactoryTest.php ├── CurlHandlerTest.php ├── CurlMultiHandlerTest.php ├── MiddlewareTest.php ├── MockHandlerTest.php ├── Server.php ├── StreamHandlerTest.php └── server.js ├── CoreTest.php ├── Future ├── CompletedFutureArrayTest.php ├── CompletedFutureValueTest.php ├── FutureArrayTest.php └── FutureValueTest.php └── bootstrap.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{Makefile,*.mk}] 12 | indent_style = tab 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | build/artifacts/ 3 | composer.lock 4 | docs/_build/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | cache: 4 | directories: 5 | - $HOME/.composer/cache/files 6 | 7 | php: 8 | - 5.4 9 | - 5.5 10 | - 5.6 11 | - 7.0 12 | - 7.1 13 | - 7.2 14 | - hhvm 15 | - nightly 16 | 17 | env: 18 | global: 19 | - TEST_COMMAND="composer test" 20 | 21 | matrix: 22 | allow_failures: 23 | - php: hhvm 24 | - php: nightly 25 | fast_finish: true 26 | include: 27 | - php: 5.4 28 | env: COMPOSER_FLAGS="--prefer-stable --prefer-lowest" 29 | 30 | before_install: 31 | - if [[ $COVERAGE != true ]]; then phpenv config-rm xdebug.ini || true; fi 32 | 33 | install: 34 | # To be removed when this issue will be resolved: https://github.com/composer/composer/issues/5355 35 | - if [[ "$COMPOSER_FLAGS" == *"--prefer-lowest"* ]]; then travis_retry composer update --prefer-dist --no-interaction --prefer-stable --quiet; fi 36 | - travis_retry composer update ${COMPOSER_FLAGS} --prefer-dist --no-interaction 37 | 38 | before_script: 39 | - ~/.nvm/nvm.sh install v0.6.14 40 | - ~/.nvm/nvm.sh run v0.6.14 41 | 42 | script: 43 | - $TEST_COMMAND 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 7 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 8 | 9 | 10 | ## [Unreleased] 11 | 12 | 13 | ## [1.1.1] - 2018-07-31 14 | 15 | ### Fixed 16 | 17 | - `continue` keyword usage on PHP 7.3 18 | 19 | 20 | ## [1.1.0] - 2015-05-19 21 | 22 | ### Added 23 | 24 | - Added `CURL_HTTP_VERSION_2_0` 25 | 26 | ### Changed 27 | 28 | - The PHP stream wrapper handler now sets `allow_self_signed` to `false` to 29 | match the cURL handler when `verify` is set to `true` or a certificate file. 30 | - Ensuring that a directory exists before using the `save_to` option. 31 | - Response protocol version is now correctly extracted from a response. 32 | 33 | ### Fixed 34 | 35 | - Fixed a bug in which the result of `CurlFactory::retryFailedRewind` did not 36 | return an array. 37 | 38 | 39 | ## [1.0.7] - 2015-03-29 40 | 41 | ### Fixed 42 | 43 | - PHP 7 fixes. 44 | 45 | 46 | ## [1.0.6] - 2015-02-26 47 | 48 | ### Changed 49 | 50 | - The multi handle of the CurlMultiHandler is now created lazily. 51 | 52 | ### Fixed 53 | 54 | - Bug fix: futures now extend from React's PromiseInterface to ensure that they 55 | are properly forwarded down the promise chain. 56 | 57 | 58 | ## [1.0.5] - 2014-12-10 59 | 60 | ### Added 61 | 62 | - Adding more error information to PHP stream wrapper exceptions. 63 | - Added digest auth integration test support to test server. 64 | 65 | 66 | ## [1.0.4] - 2014-12-01 67 | 68 | ### Added 69 | 70 | - Added support for older versions of cURL that do not have CURLOPT_TIMEOUT_MS. 71 | - Added a fix to the StreamHandler to return a `FutureArrayInterface` when an 72 | 73 | ### Changed 74 | 75 | - Setting debug to `false` does not enable debug output. error occurs. 76 | 77 | 78 | ## [1.0.3] - 2014-11-03 79 | 80 | ### Fixed 81 | 82 | - Setting the `header` stream option as a string to be compatible with GAE. 83 | - Header parsing now ensures that header order is maintained in the parsed 84 | message. 85 | 86 | 87 | ## [1.0.2] - 2014-10-28 88 | 89 | ### Fixed 90 | 91 | - Now correctly honoring a `version` option is supplied in a request. 92 | See https://github.com/guzzle/RingPHP/pull/8 93 | 94 | 95 | ## [1.0.1] - 2014-10-26 96 | 97 | ### Fixed 98 | 99 | - Fixed a header parsing issue with the `CurlHandler` and `CurlMultiHandler` 100 | that caused cURL requests with multiple responses to merge repsonses together 101 | (e.g., requests with digest authentication). 102 | 103 | 104 | ## 1.0.0 - 2014-10-12 105 | 106 | - Initial release 107 | 108 | 109 | [Unreleased]: https://github.com/guzzle/RingPHP/compare/1.1.1...HEAD 110 | [1.1.1]: https://github.com/guzzle/RingPHP/compare/1.1.0...1.1.1 111 | [1.1.0]: https://github.com/guzzle/RingPHP/compare/1.0.7...1.1.0 112 | [1.0.7]: https://github.com/guzzle/RingPHP/compare/1.0.6...1.0.7 113 | [1.0.6]: https://github.com/guzzle/RingPHP/compare/1.0.5...1.0.6 114 | [1.0.5]: https://github.com/guzzle/RingPHP/compare/1.0.4...1.0.5 115 | [1.0.4]: https://github.com/guzzle/RingPHP/compare/1.0.3...1.0.4 116 | [1.0.3]: https://github.com/guzzle/RingPHP/compare/1.0.2...1.0.3 117 | [1.0.2]: https://github.com/guzzle/RingPHP/compare/1.0.1...1.0.2 118 | [1.0.1]: https://github.com/guzzle/RingPHP/compare/1.0.0...1.0.1 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Michael Dowling, https://github.com/mtdowling 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: clean coverage docs 2 | 3 | docs: 4 | cd docs && make html 5 | 6 | view-docs: 7 | open docs/_build/html/index.html 8 | 9 | start-server: stop-server 10 | node tests/Client/server.js &> /dev/null & 11 | 12 | stop-server: 13 | @PID=$(shell ps axo pid,command \ 14 | | grep 'tests/Client/server.js' \ 15 | | grep -v grep \ 16 | | cut -f 1 -d " "\ 17 | ) && [ -n "$$PID" ] && kill $$PID || true 18 | 19 | test: start-server 20 | vendor/bin/phpunit $(TEST) 21 | $(MAKE) stop-server 22 | 23 | coverage: start-server 24 | vendor/bin/phpunit --coverage-html=build/artifacts/coverage $(TEST) 25 | $(MAKE) stop-server 26 | 27 | view-coverage: 28 | open build/artifacts/coverage/index.html 29 | 30 | clean: 31 | rm -rf build/artifacts/* 32 | cd docs && make clean 33 | 34 | tag: 35 | $(if $(TAG),,$(error TAG is not defined. Pass via "make tag TAG=4.2.1")) 36 | @echo Tagging $(TAG) 37 | chag update -m '$(TAG) ()' 38 | git add -A 39 | git commit -m '$(TAG) release' 40 | chag tag 41 | 42 | perf: start-server 43 | php tests/perf.php 44 | $(MAKE) stop-server 45 | 46 | .PHONY: docs 47 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | RingPHP 3 | ======= 4 | 5 | Provides a simple API and specification that abstracts away the details of HTTP 6 | into a single PHP function. RingPHP be used to power HTTP clients and servers 7 | through a PHP function that accepts a request hash and returns a response hash 8 | that is fulfilled using a `promise `_, 9 | allowing RingPHP to support both synchronous and asynchronous workflows. 10 | 11 | By abstracting the implementation details of different HTTP clients and 12 | servers, RingPHP allows you to utilize pluggable HTTP clients and servers 13 | without tying your application to a specific implementation. 14 | 15 | .. code-block:: php 16 | 17 | 'GET', 25 | 'uri' => '/', 26 | 'headers' => [ 27 | 'host' => ['www.google.com'], 28 | 'x-foo' => ['baz'] 29 | ] 30 | ]); 31 | 32 | $response->then(function (array $response) { 33 | echo $response['status']; 34 | }); 35 | 36 | $response->wait(); 37 | 38 | RingPHP is inspired by Clojure's `Ring `_, 39 | which, in turn, was inspired by Python's WSGI and Ruby's Rack. RingPHP is 40 | utilized as the handler layer in `Guzzle `_ 5.0+ to send 41 | HTTP requests. 42 | 43 | Documentation 44 | ------------- 45 | 46 | See http://ringphp.readthedocs.org/ for the full online documentation. 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guzzlehttp/ringphp", 3 | "description": "Provides a simple API and specification that abstracts away the details of HTTP into a single PHP function.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Michael Dowling", 8 | "email": "mtdowling@gmail.com", 9 | "homepage": "https://github.com/mtdowling" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=5.4.0", 14 | "guzzlehttp/streams": "~3.0", 15 | "react/promise": "~2.0" 16 | }, 17 | "require-dev": { 18 | "ext-curl": "*", 19 | "phpunit/phpunit": "~4.0" 20 | }, 21 | "suggest": { 22 | "ext-curl": "Guzzle will use specific adapters if cURL is present" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "GuzzleHttp\\Ring\\": "src/" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "GuzzleHttp\\Tests\\Ring\\": "tests/" 32 | } 33 | }, 34 | "scripts": { 35 | "test": "make test", 36 | "test-ci": "make coverage" 37 | }, 38 | "extra": { 39 | "branch-alias": { 40 | "dev-master": "1.1-dev" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/GuzzleRing.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/GuzzleRing.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/GuzzleRing" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/GuzzleRing" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/client_handlers.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Client Handlers 3 | =============== 4 | 5 | Client handlers accept a request array and return a future response array that 6 | can be used synchronously as an array or asynchronously using a promise. 7 | 8 | Built-In Handlers 9 | ----------------- 10 | 11 | RingPHP comes with three built-in client handlers. 12 | 13 | Stream Handler 14 | ~~~~~~~~~~~~~~ 15 | 16 | The ``GuzzleHttp\Ring\Client\StreamHandler`` uses PHP's 17 | `http stream wrapper `_ to send 18 | requests. 19 | 20 | .. note:: 21 | 22 | This handler cannot send requests concurrently. 23 | 24 | You can provide an associative array of custom stream context options to the 25 | StreamHandler using the ``stream_context`` key of the ``client`` request 26 | option. 27 | 28 | .. code-block:: php 29 | 30 | use GuzzleHttp\Ring\Client\StreamHandler; 31 | 32 | $response = $handler([ 33 | 'http_method' => 'GET', 34 | 'uri' => '/', 35 | 'headers' => ['host' => ['httpbin.org']], 36 | 'client' => [ 37 | 'stream_context' => [ 38 | 'http' => [ 39 | 'request_fulluri' => true, 40 | 'method' => 'HEAD' 41 | ], 42 | 'socket' => [ 43 | 'bindto' => '127.0.0.1:0' 44 | ], 45 | 'ssl' => [ 46 | 'verify_peer' => false 47 | ] 48 | ] 49 | ] 50 | ]); 51 | 52 | // Even though it's already completed, you can still use a promise 53 | $response->then(function ($response) { 54 | echo $response['status']; // 200 55 | }); 56 | 57 | // Or access the response using the future interface 58 | echo $response['status']; // 200 59 | 60 | cURL Handler 61 | ~~~~~~~~~~~~ 62 | 63 | The ``GuzzleHttp\Ring\Client\CurlHandler`` can be used with PHP 5.5+ to send 64 | requests using cURL easy handles. This handler is great for sending requests 65 | one at a time because the execute and select loop is implemented in C code 66 | which executes faster and consumes less memory than using PHP's 67 | ``curl_multi_*`` interface. 68 | 69 | .. note:: 70 | 71 | This handler cannot send requests concurrently. 72 | 73 | When using the CurlHandler, custom curl options can be specified as an 74 | associative array of `cURL option constants `_ 75 | mapping to values in the ``client`` option of a requst using the **curl** key. 76 | 77 | .. code-block:: php 78 | 79 | use GuzzleHttp\Ring\Client\CurlHandler; 80 | 81 | $handler = new CurlHandler(); 82 | 83 | $request = [ 84 | 'http_method' => 'GET', 85 | 'headers' => ['host' => [Server::$host]], 86 | 'client' => ['curl' => [CURLOPT_LOW_SPEED_LIMIT => 10]] 87 | ]; 88 | 89 | $response = $handler($request); 90 | 91 | // The response can be used directly as an array. 92 | echo $response['status']; // 200 93 | 94 | // Or, it can be used as a promise (that has already fulfilled). 95 | $response->then(function ($response) { 96 | echo $response['status']; // 200 97 | }); 98 | 99 | cURL Multi Handler 100 | ~~~~~~~~~~~~~~~~~~ 101 | 102 | The ``GuzzleHttp\Ring\Client\CurlMultiHandler`` transfers requests using 103 | cURL's `multi API `_. The 104 | ``CurlMultiHandler`` is great for sending requests concurrently. 105 | 106 | .. code-block:: php 107 | 108 | use GuzzleHttp\Ring\Client\CurlMultiHandler; 109 | 110 | $handler = new CurlMultiHandler(); 111 | 112 | $request = [ 113 | 'http_method' => 'GET', 114 | 'headers' => ['host' => [Server::$host]] 115 | ]; 116 | 117 | // this call returns a future array immediately. 118 | $response = $handler($request); 119 | 120 | // Ideally, you should use the promise API to not block. 121 | $response 122 | ->then(function ($response) { 123 | // Got the response at some point in the future 124 | echo $response['status']; // 200 125 | // Don't break the chain 126 | return $response; 127 | })->then(function ($response) { 128 | // ... 129 | }); 130 | 131 | // If you really need to block, then you can use the response as an 132 | // associative array. This will block until it has completed. 133 | echo $response['status']; // 200 134 | 135 | Just like the ``CurlHandler``, the ``CurlMultiHandler`` accepts custom curl 136 | option in the ``curl`` key of the ``client`` request option. 137 | 138 | Mock Handler 139 | ~~~~~~~~~~~~ 140 | 141 | The ``GuzzleHttp\Ring\Client\MockHandler`` is used to return mock responses. 142 | When constructed, the handler can be configured to return the same response 143 | array over and over, a future response, or a the evaluation of a callback 144 | function. 145 | 146 | .. code-block:: php 147 | 148 | use GuzzleHttp\Ring\Client\MockHandler; 149 | 150 | // Return a canned response. 151 | $mock = new MockHandler(['status' => 200]); 152 | $response = $mock([]); 153 | assert(200 == $response['status']); 154 | assert([] == $response['headers']); 155 | 156 | Implementing Handlers 157 | --------------------- 158 | 159 | Client handlers are just PHP callables (functions or classes that have the 160 | ``__invoke`` magic method). The callable accepts a request array and MUST 161 | return an instance of ``GuzzleHttp\Ring\Future\FutureArrayInterface`` so that 162 | the response can be used by both blocking and non-blocking consumers. 163 | 164 | Handlers need to follow a few simple rules: 165 | 166 | 1. Do not throw exceptions. If an error is encountered, return an array that 167 | contains the ``error`` key that maps to an ``\Exception`` value. 168 | 2. If the request has a ``delay`` client option, then the handler should only 169 | send the request after the specified delay time in seconds. Blocking 170 | handlers may find it convenient to just let the 171 | ``GuzzleHttp\Ring\Core::doSleep($request)`` function handle this for them. 172 | 3. Always return an instance of ``GuzzleHttp\Ring\Future\FutureArrayInterface``. 173 | 4. Complete any outstanding requests when the handler is destructed. 174 | -------------------------------------------------------------------------------- /docs/client_middleware.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Client Middleware 3 | ================= 4 | 5 | Middleware intercepts requests before they are sent over the wire and can be 6 | used to add functionality to handlers. 7 | 8 | Modifying Requests 9 | ------------------ 10 | 11 | Let's say you wanted to modify requests before they are sent over the wire 12 | so that they always add specific headers. This can be accomplished by creating 13 | a function that accepts a handler and returns a new function that adds the 14 | composed behavior. 15 | 16 | .. code-block:: php 17 | 18 | use GuzzleHttp\Ring\Client\CurlHandler; 19 | 20 | $handler = new CurlHandler(); 21 | 22 | $addHeaderHandler = function (callable $handler, array $headers = []) { 23 | return function (array $request) use ($handler, $headers) { 24 | // Add our custom headers 25 | foreach ($headers as $key => $value) { 26 | $request['headers'][$key] = $value; 27 | } 28 | 29 | // Send the request using the handler and return the response. 30 | return $handler($request); 31 | } 32 | }; 33 | 34 | // Create a new handler that adds headers to each request. 35 | $handler = $addHeaderHandler($handler, [ 36 | 'X-AddMe' => 'hello', 37 | 'Authorization' => 'Basic xyz' 38 | ]); 39 | 40 | $response = $handler([ 41 | 'http_method' => 'GET', 42 | 'headers' => ['Host' => ['httpbin.org']] 43 | ]); 44 | 45 | Modifying Responses 46 | ------------------- 47 | 48 | You can change a response as it's returned from a middleware. Remember that 49 | responses returned from an handler (including middleware) must implement 50 | ``GuzzleHttp\Ring\Future\FutureArrayInterface``. In order to be a good citizen, 51 | you should not expect that the responses returned through your middleware will 52 | be completed synchronously. Instead, you should use the 53 | ``GuzzleHttp\Ring\Core::proxy()`` function to modify the response when the 54 | underlying promise is resolved. This function is a helper function that makes it 55 | easy to create a new instance of ``FutureArrayInterface`` that wraps an existing 56 | ``FutureArrayInterface`` object. 57 | 58 | Let's say you wanted to add headers to a response as they are returned from 59 | your middleware, but you want to make sure you aren't causing future 60 | responses to be dereferenced right away. You can achieve this by modifying the 61 | incoming request and using the ``Core::proxy`` function. 62 | 63 | .. code-block:: php 64 | 65 | use GuzzleHttp\Ring\Core; 66 | use GuzzleHttp\Ring\Client\CurlHandler; 67 | 68 | $handler = new CurlHandler(); 69 | 70 | $responseHeaderHandler = function (callable $handler, array $headers) { 71 | return function (array $request) use ($handler, $headers) { 72 | // Send the request using the wrapped handler. 73 | return Core::proxy($handler($request), function ($response) use ($headers) { 74 | // Add the headers to the response when it is available. 75 | foreach ($headers as $key => $value) { 76 | $response['headers'][$key] = (array) $value; 77 | } 78 | // Note that you can return a regular response array when using 79 | // the proxy method. 80 | return $response; 81 | }); 82 | } 83 | }; 84 | 85 | // Create a new handler that adds headers to each response. 86 | $handler = $responseHeaderHandler($handler, ['X-Header' => 'hello!']); 87 | 88 | $response = $handler([ 89 | 'http_method' => 'GET', 90 | 'headers' => ['Host' => ['httpbin.org']] 91 | ]); 92 | 93 | assert($response['headers']['X-Header'] == 'hello!'); 94 | 95 | Built-In Middleware 96 | ------------------- 97 | 98 | RingPHP comes with a few basic client middlewares that modify requests 99 | and responses. 100 | 101 | Streaming Middleware 102 | ~~~~~~~~~~~~~~~~~~~~ 103 | 104 | If you want to send all requests with the ``streaming`` option to a specific 105 | handler but other requests to a different handler, then use the streaming 106 | middleware. 107 | 108 | .. code-block:: php 109 | 110 | use GuzzleHttp\Ring\Client\CurlHandler; 111 | use GuzzleHttp\Ring\Client\StreamHandler; 112 | use GuzzleHttp\Ring\Client\Middleware; 113 | 114 | $defaultHandler = new CurlHandler(); 115 | $streamingHandler = new StreamHandler(); 116 | $streamingHandler = Middleware::wrapStreaming( 117 | $defaultHandler, 118 | $streamingHandler 119 | ); 120 | 121 | // Send the request using the streaming handler. 122 | $response = $streamingHandler([ 123 | 'http_method' => 'GET', 124 | 'headers' => ['Host' => ['www.google.com']], 125 | 'stream' => true 126 | ]); 127 | 128 | // Send the request using the default handler. 129 | $response = $streamingHandler([ 130 | 'http_method' => 'GET', 131 | 'headers' => ['Host' => ['www.google.com']] 132 | ]); 133 | 134 | Future Middleware 135 | ~~~~~~~~~~~~~~~~~ 136 | 137 | If you want to send all requests with the ``future`` option to a specific 138 | handler but other requests to a different handler, then use the future 139 | middleware. 140 | 141 | .. code-block:: php 142 | 143 | use GuzzleHttp\Ring\Client\CurlHandler; 144 | use GuzzleHttp\Ring\Client\CurlMultiHandler; 145 | use GuzzleHttp\Ring\Client\Middleware; 146 | 147 | $defaultHandler = new CurlHandler(); 148 | $futureHandler = new CurlMultiHandler(); 149 | $futureHandler = Middleware::wrapFuture( 150 | $defaultHandler, 151 | $futureHandler 152 | ); 153 | 154 | // Send the request using the blocking CurlHandler. 155 | $response = $futureHandler([ 156 | 'http_method' => 'GET', 157 | 'headers' => ['Host' => ['www.google.com']] 158 | ]); 159 | 160 | // Send the request using the non-blocking CurlMultiHandler. 161 | $response = $futureHandler([ 162 | 'http_method' => 'GET', 163 | 'headers' => ['Host' => ['www.google.com']], 164 | 'future' => true 165 | ]); 166 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | import sphinx_rtd_theme 3 | from sphinx.highlighting import lexers 4 | from pygments.lexers.web import PhpLexer 5 | 6 | 7 | lexers['php'] = PhpLexer(startinline=True, linenos=1) 8 | lexers['php-annotations'] = PhpLexer(startinline=True, linenos=1) 9 | primary_domain = 'php' 10 | 11 | extensions = [] 12 | templates_path = ['_templates'] 13 | source_suffix = '.rst' 14 | master_doc = 'index' 15 | project = u'RingPHP' 16 | copyright = u'2014, Michael Dowling' 17 | version = '1.0.0-alpha' 18 | exclude_patterns = ['_build'] 19 | 20 | html_title = "RingPHP" 21 | html_short_title = "RingPHP" 22 | html_theme = "sphinx_rtd_theme" 23 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 24 | -------------------------------------------------------------------------------- /docs/futures.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Futures 3 | ======= 4 | 5 | Futures represent a computation that may have not yet completed. RingPHP 6 | uses hybrid of futures and promises to provide a consistent API that can be 7 | used for both blocking and non-blocking consumers. 8 | 9 | Promises 10 | -------- 11 | 12 | You can get the result of a future when it is ready using the promise interface 13 | of a future. Futures expose a promise API via a ``then()`` method that utilizes 14 | `React's promise library `_. You should 15 | use this API when you do not wish to block. 16 | 17 | .. code-block:: php 18 | 19 | use GuzzleHttp\Ring\Client\CurlMultiHandler; 20 | 21 | $request = [ 22 | 'http_method' => 'GET', 23 | 'uri' => '/', 24 | 'headers' => ['host' => ['httpbin.org']] 25 | ]; 26 | 27 | $response = $handler($request); 28 | 29 | // Use the then() method to use the promise API of the future. 30 | $response->then(function ($response) { 31 | echo $response['status']; 32 | }); 33 | 34 | You can get the promise used by a future, an instance of 35 | ``React\Promise\PromiseInterface``, by calling the ``promise()`` method. 36 | 37 | .. code-block:: php 38 | 39 | $response = $handler($request); 40 | $promise = $response->promise(); 41 | $promise->then(function ($response) { 42 | echo $response['status']; 43 | }); 44 | 45 | This promise value can be used with React's 46 | `aggregate promise functions `_. 47 | 48 | Waiting 49 | ------- 50 | 51 | You can wait on a future to complete and retrieve the value, or *dereference* 52 | the future, using the ``wait()`` method. Calling the ``wait()`` method of a 53 | future will block until the result is available. The result is then returned or 54 | an exception is thrown if and exception was encountered while waiting on the 55 | the result. Subsequent calls to dereference a future will return the previously 56 | completed result or throw the previously encountered exception. Futures can be 57 | cancelled, which stops the computation if possible. 58 | 59 | .. code-block:: php 60 | 61 | use GuzzleHttp\Ring\Client\CurlMultiHandler; 62 | 63 | $response = $handler([ 64 | 'http_method' => 'GET', 65 | 'uri' => '/', 66 | 'headers' => ['host' => ['httpbin.org']] 67 | ]); 68 | 69 | // You can explicitly call block to wait on a result. 70 | $realizedResponse = $response->wait(); 71 | 72 | // Future responses can be used like a regular PHP array. 73 | echo $response['status']; 74 | 75 | In addition to explicitly calling the ``wait()`` function, using a future like 76 | a normal value will implicitly trigger the ``wait()`` function. 77 | 78 | Future Responses 79 | ---------------- 80 | 81 | RingPHP uses futures to return asynchronous responses immediately. Client 82 | handlers always return future responses that implement 83 | ``GuzzleHttp\Ring\Future\ArrayFutureInterface``. These future responses act 84 | just like normal PHP associative arrays for blocking access and provide a 85 | promise interface for non-blocking access. 86 | 87 | .. code-block:: php 88 | 89 | use GuzzleHttp\Ring\Client\CurlMultiHandler; 90 | 91 | $handler = new CurlMultiHandler(); 92 | 93 | $request = [ 94 | 'http_method' => 'GET', 95 | 'uri' => '/', 96 | 'headers' => ['Host' => ['www.google.com']] 97 | ]; 98 | 99 | $response = $handler($request); 100 | 101 | // Use the promise API for non-blocking access to the response. The actual 102 | // response value will be delivered to the promise. 103 | $response->then(function ($response) { 104 | echo $response['status']; 105 | }); 106 | 107 | // You can wait (block) until the future is completed. 108 | $response->wait(); 109 | 110 | // This will implicitly call wait(), and will block too! 111 | $response['status']; 112 | 113 | .. important:: 114 | 115 | Futures that are not completed by the time the underlying handler is 116 | destructed will be completed when the handler is shutting down. 117 | 118 | Cancelling 119 | ---------- 120 | 121 | Futures can be cancelled if they have not already been dereferenced. 122 | 123 | RingPHP futures are typically implemented with the 124 | ``GuzzleHttp\Ring\Future\BaseFutureTrait``. This trait provides the cancellation 125 | functionality that should be common to most implementations. Cancelling a 126 | future response will try to prevent the request from sending over the wire. 127 | 128 | When a future is cancelled, the cancellation function is invoked and performs 129 | the actual work needed to cancel the request from sending if possible 130 | (e.g., telling an event loop to stop sending a request or to close a socket). 131 | If no cancellation function is provided, then a request cannot be cancelled. If 132 | a cancel function is provided, then it should accept the future as an argument 133 | and return true if the future was successfully cancelled or false if it could 134 | not be cancelled. 135 | 136 | Wrapping an existing Promise 137 | ---------------------------- 138 | 139 | You can easily create a future from any existing promise using the 140 | ``GuzzleHttp\Ring\Future\FutureValue`` class. This class's constructor 141 | accepts a promise as the first argument, a wait function as the second 142 | argument, and a cancellation function as the third argument. The dereference 143 | function is used to force the promise to resolve (for example, manually ticking 144 | an event loop). The cancel function is optional and is used to tell the thing 145 | that created the promise that it can stop computing the result (for example, 146 | telling an event loop to stop transferring a request). 147 | 148 | .. code-block:: php 149 | 150 | use GuzzleHttp\Ring\Future\FutureValue; 151 | use React\Promise\Deferred; 152 | 153 | $deferred = new Deferred(); 154 | $promise = $deferred->promise(); 155 | 156 | $f = new FutureValue( 157 | $promise, 158 | function () use ($deferred) { 159 | // This function is responsible for blocking and resolving the 160 | // promise. Here we pass in a reference to the deferred so that 161 | // it can be resolved or rejected. 162 | $deferred->resolve('foo'); 163 | } 164 | ); 165 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | RingPHP 3 | ======= 4 | 5 | Provides a simple API and specification that abstracts away the details of HTTP 6 | into a single PHP function. RingPHP be used to power HTTP clients and servers 7 | through a PHP function that accepts a request hash and returns a response hash 8 | that is fulfilled using a `promise `_, 9 | allowing RingPHP to support both synchronous and asynchronous workflows. 10 | 11 | By abstracting the implementation details of different HTTP clients and 12 | servers, RingPHP allows you to utilize pluggable HTTP clients and servers 13 | without tying your application to a specific implementation. 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | spec 19 | futures 20 | client_middleware 21 | client_handlers 22 | testing 23 | 24 | .. code-block:: php 25 | 26 | 'GET', 34 | 'uri' => '/', 35 | 'headers' => [ 36 | 'host' => ['www.google.com'], 37 | 'x-foo' => ['baz'] 38 | ] 39 | ]); 40 | 41 | $response->then(function (array $response) { 42 | echo $response['status']; 43 | }); 44 | 45 | $response->wait(); 46 | 47 | RingPHP is inspired by Clojure's `Ring `_, 48 | which, in turn, was inspired by Python's WSGI and Ruby's Rack. RingPHP is 49 | utilized as the handler layer in `Guzzle `_ 5.0+ to send 50 | HTTP requests. 51 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme 2 | -------------------------------------------------------------------------------- /docs/spec.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Specification 3 | ============= 4 | 5 | RingPHP applications consist of handlers, requests, responses, and 6 | middleware. 7 | 8 | Handlers 9 | -------- 10 | 11 | Handlers are implemented as a PHP ``callable`` that accept a request array 12 | and return a response array (``GuzzleHttp\Ring\Future\FutureArrayInterface``). 13 | 14 | For example: 15 | 16 | .. code-block:: php 17 | 18 | use GuzzleHttp\Ring\Future\CompletedFutureArray; 19 | 20 | $mockHandler = function (array $request) { 21 | return new CompletedFutureArray([ 22 | 'status' => 200, 23 | 'headers' => ['X-Foo' => ['Bar']], 24 | 'body' => 'Hello!' 25 | ]); 26 | }; 27 | 28 | This handler returns the same response each time it is invoked. All RingPHP 29 | handlers must return a ``GuzzleHttp\Ring\Future\FutureArrayInterface``. Use 30 | ``GuzzleHttp\Ring\Future\CompletedFutureArray`` when returning a response that 31 | has already completed. 32 | 33 | Requests 34 | -------- 35 | 36 | A request array is a PHP associative array that contains the configuration 37 | settings need to send a request. 38 | 39 | .. code-block:: php 40 | 41 | $request = [ 42 | 'http_method' => 'GET', 43 | 'scheme' => 'http', 44 | 'uri' => '/', 45 | 'body' => 'hello!', 46 | 'client' => ['timeout' => 1.0], 47 | 'headers' => [ 48 | 'host' => ['httpbin.org'], 49 | 'X-Foo' => ['baz', 'bar'] 50 | ] 51 | ]; 52 | 53 | The request array contains the following key value pairs: 54 | 55 | request_method 56 | (string, required) The HTTP request method, must be all caps corresponding 57 | to a HTTP request method, such as ``GET`` or ``POST``. 58 | 59 | scheme 60 | (string) The transport protocol, must be one of ``http`` or ``https``. 61 | Defaults to ``http``. 62 | 63 | uri 64 | (string, required) The request URI excluding the query string. Must 65 | start with "/". 66 | 67 | query_string 68 | (string) The query string, if present (e.g., ``foo=bar``). 69 | 70 | version 71 | (string) HTTP protocol version. Defaults to ``1.1``. 72 | 73 | headers 74 | (required, array) Associative array of headers. Each key represents the 75 | header name. Each value contains an array of strings where each entry of 76 | the array SHOULD be sent over the wire on a separate header line. 77 | 78 | body 79 | (string, fopen resource, ``Iterator``, ``GuzzleHttp\Stream\StreamInterface``) 80 | The body of the request, if present. Can be a string, resource returned 81 | from fopen, an ``Iterator`` that yields chunks of data, an object that 82 | implemented ``__toString``, or a ``GuzzleHttp\Stream\StreamInterface``. 83 | 84 | future 85 | (bool, string) Controls the asynchronous behavior of a response. 86 | 87 | Set to ``true`` or omit the ``future`` option to *request* that a request 88 | will be completed asynchronously. Keep in mind that your request might not 89 | necessarily be completed asynchronously based on the handler you are using. 90 | Set the ``future`` option to ``false`` to request that a synchronous 91 | response be provided. 92 | 93 | You can provide a string value to specify fine-tuned future behaviors that 94 | may be specific to the underlying handlers you are using. There are, 95 | however, some common future options that handlers should implement if 96 | possible. 97 | 98 | lazy 99 | Requests that the handler does not open and send the request 100 | immediately, but rather only opens and sends the request once the 101 | future is dereferenced. This option is often useful for sending a large 102 | number of requests concurrently to allow handlers to take better 103 | advantage of non-blocking transfers by first building up a pool of 104 | requests. 105 | 106 | If an handler does not implement or understand a provided string value, 107 | then the request MUST be treated as if the user provided ``true`` rather 108 | than the string value. 109 | 110 | Future responses created by asynchronous handlers MUST attempt to complete 111 | any outstanding future responses when they are destructed. Asynchronous 112 | handlers MAY choose to automatically complete responses when the number 113 | of outstanding requests reaches an handler-specific threshold. 114 | 115 | Client Specific Options 116 | ~~~~~~~~~~~~~~~~~~~~~~~ 117 | 118 | The following options are only used in ring client handlers. 119 | 120 | .. _client-options: 121 | 122 | client 123 | (array) Associative array of client specific transfer options. The 124 | ``client`` request key value pair can contain the following keys: 125 | 126 | cert 127 | (string, array) Set to a string to specify the path to a file 128 | containing a PEM formatted SSL client side certificate. If a password 129 | is required, then set ``cert`` to an array containing the path to the 130 | PEM file in the first array element followed by the certificate 131 | password in the second array element. 132 | 133 | connect_timeout 134 | (float) Float describing the number of seconds to wait while trying to 135 | connect to a server. Use ``0`` to wait indefinitely (the default 136 | behavior). 137 | 138 | debug 139 | (bool, fopen() resource) Set to true or set to a PHP stream returned by 140 | fopen() to enable debug output with the handler used to send a request. 141 | If set to ``true``, the output is written to PHP's STDOUT. If a PHP 142 | ``fopen`` resource handle is provided, the output is written to the 143 | stream. 144 | 145 | "Debug output" is handler specific: different handlers will yield 146 | different output and various various level of detail. For example, when 147 | using cURL to transfer requests, cURL's `CURLOPT_VERBOSE `_ 148 | will be used. When using the PHP stream wrapper, `stream notifications `_ 149 | will be emitted. 150 | 151 | decode_content 152 | (bool) Specify whether or not ``Content-Encoding`` responses 153 | (gzip, deflate, etc.) are automatically decoded. Set to ``true`` to 154 | automatically decode encoded responses. Set to ``false`` to not decode 155 | responses. By default, content is *not* decoded automatically. 156 | 157 | delay 158 | (int) The number of milliseconds to delay before sending the request. 159 | This is often used for delaying before retrying a request. Handlers 160 | SHOULD implement this if possible, but it is not a strict requirement. 161 | 162 | progress 163 | (function) Defines a function to invoke when transfer progress is made. 164 | The function accepts the following arguments: 165 | 166 | 1. The total number of bytes expected to be downloaded 167 | 2. The number of bytes downloaded so far 168 | 3. The number of bytes expected to be uploaded 169 | 4. The number of bytes uploaded so far 170 | 171 | proxy 172 | (string, array) Pass a string to specify an HTTP proxy, or an 173 | associative array to specify different proxies for different protocols 174 | where the scheme is the key and the value is the proxy address. 175 | 176 | .. code-block:: php 177 | 178 | $request = [ 179 | 'http_method' => 'GET', 180 | 'headers' => ['host' => ['httpbin.org']], 181 | 'client' => [ 182 | // Use different proxies for different URI schemes. 183 | 'proxy' => [ 184 | 'http' => 'http://proxy.example.com:5100', 185 | 'https' => 'https://proxy.example.com:6100' 186 | ] 187 | ] 188 | ]; 189 | 190 | ssl_key 191 | (string, array) Specify the path to a file containing a private SSL key 192 | in PEM format. If a password is required, then set to an array 193 | containing the path to the SSL key in the first array element followed 194 | by the password required for the certificate in the second element. 195 | 196 | save_to 197 | (string, fopen resource, ``GuzzleHttp\Stream\StreamInterface``) 198 | Specifies where the body of the response is downloaded. Pass a string to 199 | open a local file on disk and save the output to the file. Pass an fopen 200 | resource to save the output to a PHP stream resource. Pass a 201 | ``GuzzleHttp\Stream\StreamInterface`` to save the output to a Guzzle 202 | StreamInterface. Omitting this option will typically save the body of a 203 | response to a PHP temp stream. 204 | 205 | stream 206 | (bool) Set to true to stream a response rather than download it all 207 | up-front. This option will only be utilized when the corresponding 208 | handler supports it. 209 | 210 | timeout 211 | (float) Float describing the timeout of the request in seconds. Use 0 to 212 | wait indefinitely (the default behavior). 213 | 214 | verify 215 | (bool, string) Describes the SSL certificate verification behavior of a 216 | request. Set to true to enable SSL certificate verification using the 217 | system CA bundle when available (the default). Set to false to disable 218 | certificate verification (this is insecure!). Set to a string to provide 219 | the path to a CA bundle on disk to enable verification using a custom 220 | certificate. 221 | 222 | version 223 | (string) HTTP protocol version to use with the request. 224 | 225 | Server Specific Options 226 | ~~~~~~~~~~~~~~~~~~~~~~~ 227 | 228 | The following options are only used in ring server handlers. 229 | 230 | server_port 231 | (integer) The port on which the request is being handled. This is only 232 | used with ring servers, and is required. 233 | 234 | server_name 235 | (string) The resolved server name, or the server IP address. Required when 236 | using a Ring server. 237 | 238 | remote_addr 239 | (string) The IP address of the client or the last proxy that sent the 240 | request. Required when using a Ring server. 241 | 242 | Responses 243 | --------- 244 | 245 | A response is an array-like object that implements 246 | ``GuzzleHttp\Ring\Future\FutureArrayInterface``. Responses contain the 247 | following key value pairs: 248 | 249 | body 250 | (string, fopen resource, ``Iterator``, ``GuzzleHttp\Stream\StreamInterface``) 251 | The body of the response, if present. Can be a string, resource returned 252 | from fopen, an ``Iterator`` that yields chunks of data, an object that 253 | implemented ``__toString``, or a ``GuzzleHttp\Stream\StreamInterface``. 254 | 255 | effective_url 256 | (string) The URL that returned the resulting response. 257 | 258 | error 259 | (``\Exception``) Contains an exception describing any errors that were 260 | encountered during the transfer. 261 | 262 | headers 263 | (Required, array) Associative array of headers. Each key represents the 264 | header name. Each value contains an array of strings where each entry of 265 | the array is a header line. The headers array MAY be an empty array in the 266 | event an error occurred before a response was received. 267 | 268 | reason 269 | (string) Optional reason phrase. This option should be provided when the 270 | reason phrase does not match the typical reason phrase associated with the 271 | ``status`` code. See `RFC 7231 `_ 272 | for a list of HTTP reason phrases mapped to status codes. 273 | 274 | status 275 | (Required, integer) The HTTP status code. The status code MAY be set to 276 | ``null`` in the event an error occurred before a response was received 277 | (e.g., a networking error). 278 | 279 | transfer_stats 280 | (array) Provides an associative array of arbitrary transfer statistics if 281 | provided by the underlying handler. 282 | 283 | version 284 | (string) HTTP protocol version. Defaults to ``1.1``. 285 | 286 | Middleware 287 | ---------- 288 | 289 | Ring middleware augments the functionality of handlers by invoking them in the 290 | process of generating responses. Middleware is typically implemented as a 291 | higher-order function that takes one or more handlers as arguments followed by 292 | an optional associative array of options as the last argument, returning a new 293 | handler with the desired compound behavior. 294 | 295 | Here's an example of a middleware that adds a Content-Type header to each 296 | request. 297 | 298 | .. code-block:: php 299 | 300 | use GuzzleHttp\Ring\Client\CurlHandler; 301 | use GuzzleHttp\Ring\Core; 302 | 303 | $contentTypeHandler = function(callable $handler, $contentType) { 304 | return function (array $request) use ($handler, $contentType) { 305 | return $handler(Core::setHeader('Content-Type', $contentType)); 306 | }; 307 | }; 308 | 309 | $baseHandler = new CurlHandler(); 310 | $wrappedHandler = $contentTypeHandler($baseHandler, 'text/html'); 311 | $response = $wrappedHandler([/** request hash **/]); 312 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Testing 3 | ======= 4 | 5 | RingPHP tests client handlers using `PHPUnit `_ and a 6 | built-in node.js web server. 7 | 8 | Running Tests 9 | ------------- 10 | 11 | First, install the dependencies using `Composer `_. 12 | 13 | composer.phar install 14 | 15 | Next, run the unit tests using ``Make``. 16 | 17 | make test 18 | 19 | The tests are also run on Travis-CI on each commit: https://travis-ci.org/guzzle/guzzle-ring 20 | 21 | Test Server 22 | ----------- 23 | 24 | Testing client handlers usually involves actually sending HTTP requests. 25 | RingPHP provides a node.js web server that returns canned responses and 26 | keep a list of the requests that have been received. The server can then 27 | be queried to get a list of the requests that were sent by the client so that 28 | you can ensure that the client serialized and transferred requests as intended. 29 | 30 | The server keeps a list of queued responses and returns responses that are 31 | popped off of the queue as HTTP requests are received. When there are not 32 | more responses to serve, the server returns a 500 error response. 33 | 34 | The test server uses the ``GuzzleHttp\Tests\Ring\Client\Server`` class to 35 | control the server. 36 | 37 | .. code-block:: php 38 | 39 | use GuzzleHttp\Ring\Client\StreamHandler; 40 | use GuzzleHttp\Tests\Ring\Client\Server; 41 | 42 | // First return a 200 followed by a 404 response. 43 | Server::enqueue([ 44 | ['status' => 200], 45 | ['status' => 404] 46 | ]); 47 | 48 | $handler = new StreamHandler(); 49 | 50 | $response = $handler([ 51 | 'http_method' => 'GET', 52 | 'headers' => ['host' => [Server::$host]], 53 | 'uri' => '/' 54 | ]); 55 | 56 | assert(200 == $response['status']); 57 | 58 | $response = $handler([ 59 | 'http_method' => 'HEAD', 60 | 'headers' => ['host' => [Server::$host]], 61 | 'uri' => '/' 62 | ]); 63 | 64 | assert(404 == $response['status']); 65 | 66 | After requests have been sent, you can get a list of the requests as they 67 | were sent over the wire to ensure they were sent correctly. 68 | 69 | .. code-block:: php 70 | 71 | $received = Server::received(); 72 | 73 | assert('GET' == $received[0]['http_method']); 74 | assert('HEAD' == $received[1]['http_method']); 75 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | tests 7 | 8 | 9 | 10 | 11 | src 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Client/ClientUtils.php: -------------------------------------------------------------------------------- 1 | getDefaultOptions($request, $headers); 30 | $this->applyMethod($request, $options); 31 | 32 | if (isset($request['client'])) { 33 | $this->applyHandlerOptions($request, $options); 34 | } 35 | 36 | $this->applyHeaders($request, $options); 37 | unset($options['_headers']); 38 | 39 | // Add handler options from the request's configuration options 40 | if (isset($request['client']['curl'])) { 41 | $options = $this->applyCustomCurlOptions( 42 | $request['client']['curl'], 43 | $options 44 | ); 45 | } 46 | 47 | if (!$handle) { 48 | $handle = curl_init(); 49 | } 50 | 51 | $body = $this->getOutputBody($request, $options); 52 | curl_setopt_array($handle, $options); 53 | 54 | return [$handle, &$headers, $body]; 55 | } 56 | 57 | /** 58 | * Creates a response hash from a cURL result. 59 | * 60 | * @param callable $handler Handler that was used. 61 | * @param array $request Request that sent. 62 | * @param array $response Response hash to update. 63 | * @param array $headers Headers received during transfer. 64 | * @param resource $body Body fopen response. 65 | * 66 | * @return array 67 | */ 68 | public static function createResponse( 69 | callable $handler, 70 | array $request, 71 | array $response, 72 | array $headers, 73 | $body 74 | ) { 75 | if (isset($response['transfer_stats']['url'])) { 76 | $response['effective_url'] = $response['transfer_stats']['url']; 77 | } 78 | 79 | if (!empty($headers)) { 80 | $startLine = explode(' ', array_shift($headers), 3); 81 | $headerList = Core::headersFromLines($headers); 82 | $response['headers'] = $headerList; 83 | $response['version'] = isset($startLine[0]) ? substr($startLine[0], 5) : null; 84 | $response['status'] = isset($startLine[1]) ? (int) $startLine[1] : null; 85 | $response['reason'] = isset($startLine[2]) ? $startLine[2] : null; 86 | $response['body'] = $body; 87 | Core::rewindBody($response); 88 | } 89 | 90 | return !empty($response['curl']['errno']) || !isset($response['status']) 91 | ? self::createErrorResponse($handler, $request, $response) 92 | : $response; 93 | } 94 | 95 | private static function createErrorResponse( 96 | callable $handler, 97 | array $request, 98 | array $response 99 | ) { 100 | static $connectionErrors = [ 101 | CURLE_OPERATION_TIMEOUTED => true, 102 | CURLE_COULDNT_RESOLVE_HOST => true, 103 | CURLE_COULDNT_CONNECT => true, 104 | CURLE_SSL_CONNECT_ERROR => true, 105 | CURLE_GOT_NOTHING => true, 106 | ]; 107 | 108 | // Retry when nothing is present or when curl failed to rewind. 109 | if (!isset($response['err_message']) 110 | && (empty($response['curl']['errno']) 111 | || $response['curl']['errno'] == 65) 112 | ) { 113 | return self::retryFailedRewind($handler, $request, $response); 114 | } 115 | 116 | $message = isset($response['err_message']) 117 | ? $response['err_message'] 118 | : sprintf('cURL error %s: %s', 119 | $response['curl']['errno'], 120 | isset($response['curl']['error']) 121 | ? $response['curl']['error'] 122 | : 'See http://curl.haxx.se/libcurl/c/libcurl-errors.html'); 123 | 124 | $error = isset($response['curl']['errno']) 125 | && isset($connectionErrors[$response['curl']['errno']]) 126 | ? new ConnectException($message) 127 | : new RingException($message); 128 | 129 | return $response + [ 130 | 'status' => null, 131 | 'reason' => null, 132 | 'body' => null, 133 | 'headers' => [], 134 | 'error' => $error, 135 | ]; 136 | } 137 | 138 | private function getOutputBody(array $request, array &$options) 139 | { 140 | // Determine where the body of the response (if any) will be streamed. 141 | if (isset($options[CURLOPT_WRITEFUNCTION])) { 142 | return $request['client']['save_to']; 143 | } 144 | 145 | if (isset($options[CURLOPT_FILE])) { 146 | return $options[CURLOPT_FILE]; 147 | } 148 | 149 | if ($request['http_method'] != 'HEAD') { 150 | // Create a default body if one was not provided 151 | return $options[CURLOPT_FILE] = fopen('php://temp', 'w+'); 152 | } 153 | 154 | return null; 155 | } 156 | 157 | private function getDefaultOptions(array $request, array &$headers) 158 | { 159 | $url = Core::url($request); 160 | $startingResponse = false; 161 | 162 | $options = [ 163 | '_headers' => $request['headers'], 164 | CURLOPT_CUSTOMREQUEST => $request['http_method'], 165 | CURLOPT_URL => $url, 166 | CURLOPT_RETURNTRANSFER => false, 167 | CURLOPT_HEADER => false, 168 | CURLOPT_CONNECTTIMEOUT => 150, 169 | CURLOPT_HEADERFUNCTION => function ($ch, $h) use (&$headers, &$startingResponse) { 170 | $value = trim($h); 171 | if ($value === '') { 172 | $startingResponse = true; 173 | } elseif ($startingResponse) { 174 | $startingResponse = false; 175 | $headers = [$value]; 176 | } else { 177 | $headers[] = $value; 178 | } 179 | return strlen($h); 180 | }, 181 | ]; 182 | 183 | if (isset($request['version'])) { 184 | if ($request['version'] == 2.0) { 185 | $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0; 186 | } else if ($request['version'] == 1.1) { 187 | $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1; 188 | } else { 189 | $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0; 190 | } 191 | } 192 | 193 | if (defined('CURLOPT_PROTOCOLS')) { 194 | $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS; 195 | } 196 | 197 | return $options; 198 | } 199 | 200 | private function applyMethod(array $request, array &$options) 201 | { 202 | if (isset($request['body'])) { 203 | $this->applyBody($request, $options); 204 | return; 205 | } 206 | 207 | switch ($request['http_method']) { 208 | case 'PUT': 209 | case 'POST': 210 | // See http://tools.ietf.org/html/rfc7230#section-3.3.2 211 | if (!Core::hasHeader($request, 'Content-Length')) { 212 | $options[CURLOPT_HTTPHEADER][] = 'Content-Length: 0'; 213 | } 214 | break; 215 | case 'HEAD': 216 | $options[CURLOPT_NOBODY] = true; 217 | unset( 218 | $options[CURLOPT_WRITEFUNCTION], 219 | $options[CURLOPT_READFUNCTION], 220 | $options[CURLOPT_FILE], 221 | $options[CURLOPT_INFILE] 222 | ); 223 | } 224 | } 225 | 226 | private function applyBody(array $request, array &$options) 227 | { 228 | $contentLength = Core::firstHeader($request, 'Content-Length'); 229 | $size = $contentLength !== null ? (int) $contentLength : null; 230 | 231 | // Send the body as a string if the size is less than 1MB OR if the 232 | // [client][curl][body_as_string] request value is set. 233 | if (($size !== null && $size < 1000000) || 234 | isset($request['client']['curl']['body_as_string']) || 235 | is_string($request['body']) 236 | ) { 237 | $options[CURLOPT_POSTFIELDS] = Core::body($request); 238 | // Don't duplicate the Content-Length header 239 | $this->removeHeader('Content-Length', $options); 240 | $this->removeHeader('Transfer-Encoding', $options); 241 | } else { 242 | $options[CURLOPT_UPLOAD] = true; 243 | if ($size !== null) { 244 | // Let cURL handle setting the Content-Length header 245 | $options[CURLOPT_INFILESIZE] = $size; 246 | $this->removeHeader('Content-Length', $options); 247 | } 248 | $this->addStreamingBody($request, $options); 249 | } 250 | 251 | // If the Expect header is not present, prevent curl from adding it 252 | if (!Core::hasHeader($request, 'Expect')) { 253 | $options[CURLOPT_HTTPHEADER][] = 'Expect:'; 254 | } 255 | 256 | // cURL sometimes adds a content-type by default. Prevent this. 257 | if (!Core::hasHeader($request, 'Content-Type')) { 258 | $options[CURLOPT_HTTPHEADER][] = 'Content-Type:'; 259 | } 260 | } 261 | 262 | private function addStreamingBody(array $request, array &$options) 263 | { 264 | $body = $request['body']; 265 | 266 | if ($body instanceof StreamInterface) { 267 | $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) { 268 | return (string) $body->read($length); 269 | }; 270 | if (!isset($options[CURLOPT_INFILESIZE])) { 271 | if ($size = $body->getSize()) { 272 | $options[CURLOPT_INFILESIZE] = $size; 273 | } 274 | } 275 | } elseif (is_resource($body)) { 276 | $options[CURLOPT_INFILE] = $body; 277 | } elseif ($body instanceof \Iterator) { 278 | $buf = ''; 279 | $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body, &$buf) { 280 | if ($body->valid()) { 281 | $buf .= $body->current(); 282 | $body->next(); 283 | } 284 | $result = (string) substr($buf, 0, $length); 285 | $buf = substr($buf, $length); 286 | return $result; 287 | }; 288 | } else { 289 | throw new \InvalidArgumentException('Invalid request body provided'); 290 | } 291 | } 292 | 293 | private function applyHeaders(array $request, array &$options) 294 | { 295 | foreach ($options['_headers'] as $name => $values) { 296 | foreach ($values as $value) { 297 | $options[CURLOPT_HTTPHEADER][] = "$name: $value"; 298 | } 299 | } 300 | 301 | // Remove the Accept header if one was not set 302 | if (!Core::hasHeader($request, 'Accept')) { 303 | $options[CURLOPT_HTTPHEADER][] = 'Accept:'; 304 | } 305 | } 306 | 307 | /** 308 | * Takes an array of curl options specified in the 'curl' option of a 309 | * request's configuration array and maps them to CURLOPT_* options. 310 | * 311 | * This method is only called when a request has a 'curl' config setting. 312 | * 313 | * @param array $config Configuration array of custom curl option 314 | * @param array $options Array of existing curl options 315 | * 316 | * @return array Returns a new array of curl options 317 | */ 318 | private function applyCustomCurlOptions(array $config, array $options) 319 | { 320 | $curlOptions = []; 321 | foreach ($config as $key => $value) { 322 | if (is_int($key)) { 323 | $curlOptions[$key] = $value; 324 | } 325 | } 326 | 327 | return $curlOptions + $options; 328 | } 329 | 330 | /** 331 | * Remove a header from the options array. 332 | * 333 | * @param string $name Case-insensitive header to remove 334 | * @param array $options Array of options to modify 335 | */ 336 | private function removeHeader($name, array &$options) 337 | { 338 | foreach (array_keys($options['_headers']) as $key) { 339 | if (!strcasecmp($key, $name)) { 340 | unset($options['_headers'][$key]); 341 | return; 342 | } 343 | } 344 | } 345 | 346 | /** 347 | * Applies an array of request client options to a the options array. 348 | * 349 | * This method uses a large switch rather than double-dispatch to save on 350 | * high overhead of calling functions in PHP. 351 | */ 352 | private function applyHandlerOptions(array $request, array &$options) 353 | { 354 | foreach ($request['client'] as $key => $value) { 355 | switch ($key) { 356 | // Violating PSR-4 to provide more room. 357 | case 'verify': 358 | 359 | if ($value === false) { 360 | unset($options[CURLOPT_CAINFO]); 361 | $options[CURLOPT_SSL_VERIFYHOST] = 0; 362 | $options[CURLOPT_SSL_VERIFYPEER] = false; 363 | continue 2; 364 | } 365 | 366 | $options[CURLOPT_SSL_VERIFYHOST] = 2; 367 | $options[CURLOPT_SSL_VERIFYPEER] = true; 368 | 369 | if (is_string($value)) { 370 | $options[CURLOPT_CAINFO] = $value; 371 | if (!file_exists($value)) { 372 | throw new \InvalidArgumentException( 373 | "SSL CA bundle not found: $value" 374 | ); 375 | } 376 | } 377 | break; 378 | 379 | case 'decode_content': 380 | 381 | if ($value === false) { 382 | continue 2; 383 | } 384 | 385 | $accept = Core::firstHeader($request, 'Accept-Encoding'); 386 | if ($accept) { 387 | $options[CURLOPT_ENCODING] = $accept; 388 | } else { 389 | $options[CURLOPT_ENCODING] = ''; 390 | // Don't let curl send the header over the wire 391 | $options[CURLOPT_HTTPHEADER][] = 'Accept-Encoding:'; 392 | } 393 | break; 394 | 395 | case 'save_to': 396 | 397 | if (is_string($value)) { 398 | if (!is_dir(dirname($value))) { 399 | throw new \RuntimeException(sprintf( 400 | 'Directory %s does not exist for save_to value of %s', 401 | dirname($value), 402 | $value 403 | )); 404 | } 405 | $value = new LazyOpenStream($value, 'w+'); 406 | } 407 | 408 | if ($value instanceof StreamInterface) { 409 | $options[CURLOPT_WRITEFUNCTION] = 410 | function ($ch, $write) use ($value) { 411 | return $value->write($write); 412 | }; 413 | } elseif (is_resource($value)) { 414 | $options[CURLOPT_FILE] = $value; 415 | } else { 416 | throw new \InvalidArgumentException('save_to must be a ' 417 | . 'GuzzleHttp\Stream\StreamInterface or resource'); 418 | } 419 | break; 420 | 421 | case 'timeout': 422 | 423 | if (defined('CURLOPT_TIMEOUT_MS')) { 424 | $options[CURLOPT_TIMEOUT_MS] = $value * 1000; 425 | } else { 426 | $options[CURLOPT_TIMEOUT] = $value; 427 | } 428 | break; 429 | 430 | case 'connect_timeout': 431 | 432 | if (defined('CURLOPT_CONNECTTIMEOUT_MS')) { 433 | $options[CURLOPT_CONNECTTIMEOUT_MS] = $value * 1000; 434 | } else { 435 | $options[CURLOPT_CONNECTTIMEOUT] = $value; 436 | } 437 | break; 438 | 439 | case 'proxy': 440 | 441 | if (!is_array($value)) { 442 | $options[CURLOPT_PROXY] = $value; 443 | } elseif (isset($request['scheme'])) { 444 | $scheme = $request['scheme']; 445 | if (isset($value[$scheme])) { 446 | $options[CURLOPT_PROXY] = $value[$scheme]; 447 | } 448 | } 449 | break; 450 | 451 | case 'cert': 452 | 453 | if (is_array($value)) { 454 | $options[CURLOPT_SSLCERTPASSWD] = $value[1]; 455 | $value = $value[0]; 456 | } 457 | 458 | if (!file_exists($value)) { 459 | throw new \InvalidArgumentException( 460 | "SSL certificate not found: {$value}" 461 | ); 462 | } 463 | 464 | $options[CURLOPT_SSLCERT] = $value; 465 | break; 466 | 467 | case 'ssl_key': 468 | 469 | if (is_array($value)) { 470 | $options[CURLOPT_SSLKEYPASSWD] = $value[1]; 471 | $value = $value[0]; 472 | } 473 | 474 | if (!file_exists($value)) { 475 | throw new \InvalidArgumentException( 476 | "SSL private key not found: {$value}" 477 | ); 478 | } 479 | 480 | $options[CURLOPT_SSLKEY] = $value; 481 | break; 482 | 483 | case 'progress': 484 | 485 | if (!is_callable($value)) { 486 | throw new \InvalidArgumentException( 487 | 'progress client option must be callable' 488 | ); 489 | } 490 | 491 | $options[CURLOPT_NOPROGRESS] = false; 492 | $options[CURLOPT_PROGRESSFUNCTION] = 493 | function () use ($value) { 494 | $args = func_get_args(); 495 | // PHP 5.5 pushed the handle onto the start of the args 496 | if (is_resource($args[0])) { 497 | array_shift($args); 498 | } 499 | call_user_func_array($value, $args); 500 | }; 501 | break; 502 | 503 | case 'debug': 504 | 505 | if ($value) { 506 | $options[CURLOPT_STDERR] = Core::getDebugResource($value); 507 | $options[CURLOPT_VERBOSE] = true; 508 | } 509 | break; 510 | } 511 | } 512 | } 513 | 514 | /** 515 | * This function ensures that a response was set on a transaction. If one 516 | * was not set, then the request is retried if possible. This error 517 | * typically means you are sending a payload, curl encountered a 518 | * "Connection died, retrying a fresh connect" error, tried to rewind the 519 | * stream, and then encountered a "necessary data rewind wasn't possible" 520 | * error, causing the request to be sent through curl_multi_info_read() 521 | * without an error status. 522 | */ 523 | private static function retryFailedRewind( 524 | callable $handler, 525 | array $request, 526 | array $response 527 | ) { 528 | // If there is no body, then there is some other kind of issue. This 529 | // is weird and should probably never happen. 530 | if (!isset($request['body'])) { 531 | $response['err_message'] = 'No response was received for a request ' 532 | . 'with no body. This could mean that you are saturating your ' 533 | . 'network.'; 534 | return self::createErrorResponse($handler, $request, $response); 535 | } 536 | 537 | if (!Core::rewindBody($request)) { 538 | $response['err_message'] = 'The connection unexpectedly failed ' 539 | . 'without providing an error. The request would have been ' 540 | . 'retried, but attempting to rewind the request body failed.'; 541 | return self::createErrorResponse($handler, $request, $response); 542 | } 543 | 544 | // Retry no more than 3 times before giving up. 545 | if (!isset($request['curl']['retries'])) { 546 | $request['curl']['retries'] = 1; 547 | } elseif ($request['curl']['retries'] == 2) { 548 | $response['err_message'] = 'The cURL request was retried 3 times ' 549 | . 'and did no succeed. cURL was unable to rewind the body of ' 550 | . 'the request and subsequent retries resulted in the same ' 551 | . 'error. Turn on the debug option to see what went wrong. ' 552 | . 'See https://bugs.php.net/bug.php?id=47204 for more information.'; 553 | return self::createErrorResponse($handler, $request, $response); 554 | } else { 555 | $request['curl']['retries']++; 556 | } 557 | 558 | return $handler($request); 559 | } 560 | } 561 | -------------------------------------------------------------------------------- /src/Client/CurlHandler.php: -------------------------------------------------------------------------------- 1 | handles = $this->ownedHandles = []; 43 | $this->factory = isset($options['handle_factory']) 44 | ? $options['handle_factory'] 45 | : new CurlFactory(); 46 | $this->maxHandles = isset($options['max_handles']) 47 | ? $options['max_handles'] 48 | : 5; 49 | } 50 | 51 | public function __destruct() 52 | { 53 | foreach ($this->handles as $handle) { 54 | if (is_resource($handle)) { 55 | curl_close($handle); 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * @param array $request 62 | * 63 | * @return CompletedFutureArray 64 | */ 65 | public function __invoke(array $request) 66 | { 67 | return new CompletedFutureArray( 68 | $this->_invokeAsArray($request) 69 | ); 70 | } 71 | 72 | /** 73 | * @internal 74 | * 75 | * @param array $request 76 | * 77 | * @return array 78 | */ 79 | public function _invokeAsArray(array $request) 80 | { 81 | $factory = $this->factory; 82 | 83 | // Ensure headers are by reference. They're updated elsewhere. 84 | $result = $factory($request, $this->checkoutEasyHandle()); 85 | $h = $result[0]; 86 | $hd =& $result[1]; 87 | $bd = $result[2]; 88 | Core::doSleep($request); 89 | curl_exec($h); 90 | $response = ['transfer_stats' => curl_getinfo($h)]; 91 | $response['curl']['error'] = curl_error($h); 92 | $response['curl']['errno'] = curl_errno($h); 93 | $response['transfer_stats'] = array_merge($response['transfer_stats'], $response['curl']); 94 | $this->releaseEasyHandle($h); 95 | 96 | return CurlFactory::createResponse([$this, '_invokeAsArray'], $request, $response, $hd, $bd); 97 | } 98 | 99 | private function checkoutEasyHandle() 100 | { 101 | // Find an unused handle in the cache 102 | if (false !== ($key = array_search(false, $this->ownedHandles, true))) { 103 | $this->ownedHandles[$key] = true; 104 | return $this->handles[$key]; 105 | } 106 | 107 | // Add a new handle 108 | $handle = curl_init(); 109 | $id = (int) $handle; 110 | $this->handles[$id] = $handle; 111 | $this->ownedHandles[$id] = true; 112 | 113 | return $handle; 114 | } 115 | 116 | private function releaseEasyHandle($handle) 117 | { 118 | $id = (int) $handle; 119 | if (count($this->ownedHandles) > $this->maxHandles) { 120 | curl_close($this->handles[$id]); 121 | unset($this->handles[$id], $this->ownedHandles[$id]); 122 | } else { 123 | // curl_reset doesn't clear these out for some reason 124 | static $unsetValues = [ 125 | CURLOPT_HEADERFUNCTION => null, 126 | CURLOPT_WRITEFUNCTION => null, 127 | CURLOPT_READFUNCTION => null, 128 | CURLOPT_PROGRESSFUNCTION => null, 129 | ]; 130 | curl_setopt_array($handle, $unsetValues); 131 | curl_reset($handle); 132 | $this->ownedHandles[$id] = false; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Client/CurlMultiHandler.php: -------------------------------------------------------------------------------- 1 | _mh = $options['mh']; 48 | } 49 | $this->factory = isset($options['handle_factory']) 50 | ? $options['handle_factory'] : new CurlFactory(); 51 | $this->selectTimeout = isset($options['select_timeout']) 52 | ? $options['select_timeout'] : 1; 53 | $this->maxHandles = isset($options['max_handles']) 54 | ? $options['max_handles'] : 100; 55 | } 56 | 57 | public function __get($name) 58 | { 59 | if ($name === '_mh') { 60 | return $this->_mh = curl_multi_init(); 61 | } 62 | 63 | throw new \BadMethodCallException(); 64 | } 65 | 66 | public function __destruct() 67 | { 68 | // Finish any open connections before terminating the script. 69 | if ($this->handles) { 70 | $this->execute(); 71 | } 72 | 73 | if (isset($this->_mh)) { 74 | curl_multi_close($this->_mh); 75 | unset($this->_mh); 76 | } 77 | } 78 | 79 | public function __invoke(array $request) 80 | { 81 | $factory = $this->factory; 82 | $result = $factory($request); 83 | $entry = [ 84 | 'request' => $request, 85 | 'response' => [], 86 | 'handle' => $result[0], 87 | 'headers' => &$result[1], 88 | 'body' => $result[2], 89 | 'deferred' => new Deferred(), 90 | ]; 91 | 92 | $id = (int) $result[0]; 93 | 94 | $future = new FutureArray( 95 | $entry['deferred']->promise(), 96 | [$this, 'execute'], 97 | function () use ($id) { 98 | return $this->cancel($id); 99 | } 100 | ); 101 | 102 | $this->addRequest($entry); 103 | 104 | // Transfer outstanding requests if there are too many open handles. 105 | if (count($this->handles) >= $this->maxHandles) { 106 | $this->execute(); 107 | } 108 | 109 | return $future; 110 | } 111 | 112 | /** 113 | * Runs until all outstanding connections have completed. 114 | */ 115 | public function execute() 116 | { 117 | do { 118 | 119 | if ($this->active && 120 | curl_multi_select($this->_mh, $this->selectTimeout) === -1 121 | ) { 122 | // Perform a usleep if a select returns -1. 123 | // See: https://bugs.php.net/bug.php?id=61141 124 | usleep(250); 125 | } 126 | 127 | // Add any delayed futures if needed. 128 | if ($this->delays) { 129 | $this->addDelays(); 130 | } 131 | 132 | do { 133 | $mrc = curl_multi_exec($this->_mh, $this->active); 134 | } while ($mrc === CURLM_CALL_MULTI_PERFORM); 135 | 136 | $this->processMessages(); 137 | 138 | // If there are delays but no transfers, then sleep for a bit. 139 | if (!$this->active && $this->delays) { 140 | usleep(500); 141 | } 142 | 143 | } while ($this->active || $this->handles); 144 | } 145 | 146 | private function addRequest(array &$entry) 147 | { 148 | $id = (int) $entry['handle']; 149 | $this->handles[$id] = $entry; 150 | 151 | // If the request is a delay, then add the reques to the curl multi 152 | // pool only after the specified delay. 153 | if (isset($entry['request']['client']['delay'])) { 154 | $this->delays[$id] = microtime(true) + ($entry['request']['client']['delay'] / 1000); 155 | } elseif (empty($entry['request']['future'])) { 156 | curl_multi_add_handle($this->_mh, $entry['handle']); 157 | } else { 158 | curl_multi_add_handle($this->_mh, $entry['handle']); 159 | // "lazy" futures are only sent once the pool has many requests. 160 | if ($entry['request']['future'] !== 'lazy') { 161 | do { 162 | $mrc = curl_multi_exec($this->_mh, $this->active); 163 | } while ($mrc === CURLM_CALL_MULTI_PERFORM); 164 | $this->processMessages(); 165 | } 166 | } 167 | } 168 | 169 | private function removeProcessed($id) 170 | { 171 | if (isset($this->handles[$id])) { 172 | curl_multi_remove_handle( 173 | $this->_mh, 174 | $this->handles[$id]['handle'] 175 | ); 176 | curl_close($this->handles[$id]['handle']); 177 | unset($this->handles[$id], $this->delays[$id]); 178 | } 179 | } 180 | 181 | /** 182 | * Cancels a handle from sending and removes references to it. 183 | * 184 | * @param int $id Handle ID to cancel and remove. 185 | * 186 | * @return bool True on success, false on failure. 187 | */ 188 | private function cancel($id) 189 | { 190 | // Cannot cancel if it has been processed. 191 | if (!isset($this->handles[$id])) { 192 | return false; 193 | } 194 | 195 | $handle = $this->handles[$id]['handle']; 196 | unset($this->delays[$id], $this->handles[$id]); 197 | curl_multi_remove_handle($this->_mh, $handle); 198 | curl_close($handle); 199 | 200 | return true; 201 | } 202 | 203 | private function addDelays() 204 | { 205 | $currentTime = microtime(true); 206 | 207 | foreach ($this->delays as $id => $delay) { 208 | if ($currentTime >= $delay) { 209 | unset($this->delays[$id]); 210 | curl_multi_add_handle( 211 | $this->_mh, 212 | $this->handles[$id]['handle'] 213 | ); 214 | } 215 | } 216 | } 217 | 218 | private function processMessages() 219 | { 220 | while ($done = curl_multi_info_read($this->_mh)) { 221 | $id = (int) $done['handle']; 222 | 223 | if (!isset($this->handles[$id])) { 224 | // Probably was cancelled. 225 | continue; 226 | } 227 | 228 | $entry = $this->handles[$id]; 229 | $entry['response']['transfer_stats'] = curl_getinfo($done['handle']); 230 | 231 | if ($done['result'] !== CURLM_OK) { 232 | $entry['response']['curl']['errno'] = $done['result']; 233 | $entry['response']['curl']['error'] = curl_error($done['handle']); 234 | } 235 | 236 | $result = CurlFactory::createResponse( 237 | $this, 238 | $entry['request'], 239 | $entry['response'], 240 | $entry['headers'], 241 | $entry['body'] 242 | ); 243 | 244 | $this->removeProcessed($id); 245 | $entry['deferred']->resolve($result); 246 | } 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/Client/Middleware.php: -------------------------------------------------------------------------------- 1 | result = $result; 26 | } 27 | 28 | public function __invoke(array $request) 29 | { 30 | Core::doSleep($request); 31 | $response = is_callable($this->result) 32 | ? call_user_func($this->result, $request) 33 | : $this->result; 34 | 35 | if (is_array($response)) { 36 | $response = new CompletedFutureArray($response + [ 37 | 'status' => null, 38 | 'body' => null, 39 | 'headers' => [], 40 | 'reason' => null, 41 | 'effective_url' => null, 42 | ]); 43 | } elseif (!$response instanceof FutureArrayInterface) { 44 | throw new \InvalidArgumentException( 45 | 'Response must be an array or FutureArrayInterface. Found ' 46 | . Core::describeType($request) 47 | ); 48 | } 49 | 50 | return $response; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Client/StreamHandler.php: -------------------------------------------------------------------------------- 1 | options = $options; 24 | } 25 | 26 | public function __invoke(array $request) 27 | { 28 | $url = Core::url($request); 29 | Core::doSleep($request); 30 | 31 | try { 32 | // Does not support the expect header. 33 | $request = Core::removeHeader($request, 'Expect'); 34 | $stream = $this->createStream($url, $request); 35 | return $this->createResponse($request, $url, $stream); 36 | } catch (RingException $e) { 37 | return $this->createErrorResponse($url, $e); 38 | } 39 | } 40 | 41 | private function createResponse(array $request, $url, $stream) 42 | { 43 | $hdrs = $this->lastHeaders; 44 | $this->lastHeaders = null; 45 | $parts = explode(' ', array_shift($hdrs), 3); 46 | $response = [ 47 | 'version' => substr($parts[0], 5), 48 | 'status' => $parts[1], 49 | 'reason' => isset($parts[2]) ? $parts[2] : null, 50 | 'headers' => Core::headersFromLines($hdrs), 51 | 'effective_url' => $url, 52 | ]; 53 | 54 | $stream = $this->checkDecode($request, $response, $stream); 55 | 56 | // If not streaming, then drain the response into a stream. 57 | if (empty($request['client']['stream'])) { 58 | $dest = isset($request['client']['save_to']) 59 | ? $request['client']['save_to'] 60 | : fopen('php://temp', 'r+'); 61 | $stream = $this->drain($stream, $dest); 62 | } 63 | 64 | $response['body'] = $stream; 65 | 66 | return new CompletedFutureArray($response); 67 | } 68 | 69 | private function checkDecode(array $request, array $response, $stream) 70 | { 71 | // Automatically decode responses when instructed. 72 | if (!empty($request['client']['decode_content'])) { 73 | switch (Core::firstHeader($response, 'Content-Encoding', true)) { 74 | case 'gzip': 75 | case 'deflate': 76 | $stream = new InflateStream(Stream::factory($stream)); 77 | break; 78 | } 79 | } 80 | 81 | return $stream; 82 | } 83 | 84 | /** 85 | * Drains the stream into the "save_to" client option. 86 | * 87 | * @param resource $stream 88 | * @param string|resource|StreamInterface $dest 89 | * 90 | * @return Stream 91 | * @throws \RuntimeException when the save_to option is invalid. 92 | */ 93 | private function drain($stream, $dest) 94 | { 95 | if (is_resource($stream)) { 96 | if (!is_resource($dest)) { 97 | $stream = Stream::factory($stream); 98 | } else { 99 | stream_copy_to_stream($stream, $dest); 100 | fclose($stream); 101 | rewind($dest); 102 | return $dest; 103 | } 104 | } 105 | 106 | // Stream the response into the destination stream 107 | $dest = is_string($dest) 108 | ? new Stream(Utils::open($dest, 'r+')) 109 | : Stream::factory($dest); 110 | 111 | Utils::copyToStream($stream, $dest); 112 | $dest->seek(0); 113 | $stream->close(); 114 | 115 | return $dest; 116 | } 117 | 118 | /** 119 | * Creates an error response for the given stream. 120 | * 121 | * @param string $url 122 | * @param RingException $e 123 | * 124 | * @return array 125 | */ 126 | private function createErrorResponse($url, RingException $e) 127 | { 128 | // Determine if the error was a networking error. 129 | $message = $e->getMessage(); 130 | 131 | // This list can probably get more comprehensive. 132 | if (strpos($message, 'getaddrinfo') // DNS lookup failed 133 | || strpos($message, 'Connection refused') 134 | ) { 135 | $e = new ConnectException($e->getMessage(), 0, $e); 136 | } 137 | 138 | return new CompletedFutureArray([ 139 | 'status' => null, 140 | 'body' => null, 141 | 'headers' => [], 142 | 'effective_url' => $url, 143 | 'error' => $e 144 | ]); 145 | } 146 | 147 | /** 148 | * Create a resource and check to ensure it was created successfully 149 | * 150 | * @param callable $callback Callable that returns stream resource 151 | * 152 | * @return resource 153 | * @throws \RuntimeException on error 154 | */ 155 | private function createResource(callable $callback) 156 | { 157 | $errors = null; 158 | set_error_handler(function ($_, $msg, $file, $line) use (&$errors) { 159 | $errors[] = [ 160 | 'message' => $msg, 161 | 'file' => $file, 162 | 'line' => $line 163 | ]; 164 | return true; 165 | }); 166 | 167 | $resource = $callback(); 168 | restore_error_handler(); 169 | 170 | if (!$resource) { 171 | $message = 'Error creating resource: '; 172 | foreach ($errors as $err) { 173 | foreach ($err as $key => $value) { 174 | $message .= "[$key] $value" . PHP_EOL; 175 | } 176 | } 177 | throw new RingException(trim($message)); 178 | } 179 | 180 | return $resource; 181 | } 182 | 183 | private function createStream($url, array $request) 184 | { 185 | static $methods; 186 | if (!$methods) { 187 | $methods = array_flip(get_class_methods(__CLASS__)); 188 | } 189 | 190 | // HTTP/1.1 streams using the PHP stream wrapper require a 191 | // Connection: close header 192 | if ((!isset($request['version']) || $request['version'] == '1.1') 193 | && !Core::hasHeader($request, 'Connection') 194 | ) { 195 | $request['headers']['Connection'] = ['close']; 196 | } 197 | 198 | // Ensure SSL is verified by default 199 | if (!isset($request['client']['verify'])) { 200 | $request['client']['verify'] = true; 201 | } 202 | 203 | $params = []; 204 | $options = $this->getDefaultOptions($request); 205 | 206 | if (isset($request['client'])) { 207 | foreach ($request['client'] as $key => $value) { 208 | $method = "add_{$key}"; 209 | if (isset($methods[$method])) { 210 | $this->{$method}($request, $options, $value, $params); 211 | } 212 | } 213 | } 214 | 215 | return $this->createStreamResource( 216 | $url, 217 | $request, 218 | $options, 219 | $this->createContext($request, $options, $params) 220 | ); 221 | } 222 | 223 | private function getDefaultOptions(array $request) 224 | { 225 | $headers = ""; 226 | foreach ($request['headers'] as $name => $value) { 227 | foreach ((array) $value as $val) { 228 | $headers .= "$name: $val\r\n"; 229 | } 230 | } 231 | 232 | $context = [ 233 | 'http' => [ 234 | 'method' => $request['http_method'], 235 | 'header' => $headers, 236 | 'protocol_version' => isset($request['version']) ? $request['version'] : 1.1, 237 | 'ignore_errors' => true, 238 | 'follow_location' => 0, 239 | ], 240 | ]; 241 | 242 | $body = Core::body($request); 243 | if (isset($body)) { 244 | $context['http']['content'] = $body; 245 | // Prevent the HTTP handler from adding a Content-Type header. 246 | if (!Core::hasHeader($request, 'Content-Type')) { 247 | $context['http']['header'] .= "Content-Type:\r\n"; 248 | } 249 | } 250 | 251 | $context['http']['header'] = rtrim($context['http']['header']); 252 | 253 | return $context; 254 | } 255 | 256 | private function add_proxy(array $request, &$options, $value, &$params) 257 | { 258 | if (!is_array($value)) { 259 | $options['http']['proxy'] = $value; 260 | } else { 261 | $scheme = isset($request['scheme']) ? $request['scheme'] : 'http'; 262 | if (isset($value[$scheme])) { 263 | $options['http']['proxy'] = $value[$scheme]; 264 | } 265 | } 266 | } 267 | 268 | private function add_timeout(array $request, &$options, $value, &$params) 269 | { 270 | $options['http']['timeout'] = $value; 271 | } 272 | 273 | private function add_verify(array $request, &$options, $value, &$params) 274 | { 275 | if ($value === true) { 276 | // PHP 5.6 or greater will find the system cert by default. When 277 | // < 5.6, use the Guzzle bundled cacert. 278 | if (PHP_VERSION_ID < 50600) { 279 | $options['ssl']['cafile'] = ClientUtils::getDefaultCaBundle(); 280 | } 281 | } elseif (is_string($value)) { 282 | $options['ssl']['cafile'] = $value; 283 | if (!file_exists($value)) { 284 | throw new RingException("SSL CA bundle not found: $value"); 285 | } 286 | } elseif ($value === false) { 287 | $options['ssl']['verify_peer'] = false; 288 | $options['ssl']['allow_self_signed'] = true; 289 | return; 290 | } else { 291 | throw new RingException('Invalid verify request option'); 292 | } 293 | 294 | $options['ssl']['verify_peer'] = true; 295 | $options['ssl']['allow_self_signed'] = false; 296 | } 297 | 298 | private function add_cert(array $request, &$options, $value, &$params) 299 | { 300 | if (is_array($value)) { 301 | $options['ssl']['passphrase'] = $value[1]; 302 | $value = $value[0]; 303 | } 304 | 305 | if (!file_exists($value)) { 306 | throw new RingException("SSL certificate not found: {$value}"); 307 | } 308 | 309 | $options['ssl']['local_cert'] = $value; 310 | } 311 | 312 | private function add_progress(array $request, &$options, $value, &$params) 313 | { 314 | $fn = function ($code, $_1, $_2, $_3, $transferred, $total) use ($value) { 315 | if ($code == STREAM_NOTIFY_PROGRESS) { 316 | $value($total, $transferred, null, null); 317 | } 318 | }; 319 | 320 | // Wrap the existing function if needed. 321 | $params['notification'] = isset($params['notification']) 322 | ? Core::callArray([$params['notification'], $fn]) 323 | : $fn; 324 | } 325 | 326 | private function add_debug(array $request, &$options, $value, &$params) 327 | { 328 | if ($value === false) { 329 | return; 330 | } 331 | 332 | static $map = [ 333 | STREAM_NOTIFY_CONNECT => 'CONNECT', 334 | STREAM_NOTIFY_AUTH_REQUIRED => 'AUTH_REQUIRED', 335 | STREAM_NOTIFY_AUTH_RESULT => 'AUTH_RESULT', 336 | STREAM_NOTIFY_MIME_TYPE_IS => 'MIME_TYPE_IS', 337 | STREAM_NOTIFY_FILE_SIZE_IS => 'FILE_SIZE_IS', 338 | STREAM_NOTIFY_REDIRECTED => 'REDIRECTED', 339 | STREAM_NOTIFY_PROGRESS => 'PROGRESS', 340 | STREAM_NOTIFY_FAILURE => 'FAILURE', 341 | STREAM_NOTIFY_COMPLETED => 'COMPLETED', 342 | STREAM_NOTIFY_RESOLVE => 'RESOLVE', 343 | ]; 344 | 345 | static $args = ['severity', 'message', 'message_code', 346 | 'bytes_transferred', 'bytes_max']; 347 | 348 | $value = Core::getDebugResource($value); 349 | $ident = $request['http_method'] . ' ' . Core::url($request); 350 | $fn = function () use ($ident, $value, $map, $args) { 351 | $passed = func_get_args(); 352 | $code = array_shift($passed); 353 | fprintf($value, '<%s> [%s] ', $ident, $map[$code]); 354 | foreach (array_filter($passed) as $i => $v) { 355 | fwrite($value, $args[$i] . ': "' . $v . '" '); 356 | } 357 | fwrite($value, "\n"); 358 | }; 359 | 360 | // Wrap the existing function if needed. 361 | $params['notification'] = isset($params['notification']) 362 | ? Core::callArray([$params['notification'], $fn]) 363 | : $fn; 364 | } 365 | 366 | private function applyCustomOptions(array $request, array &$options) 367 | { 368 | if (!isset($request['client']['stream_context'])) { 369 | return; 370 | } 371 | 372 | if (!is_array($request['client']['stream_context'])) { 373 | throw new RingException('stream_context must be an array'); 374 | } 375 | 376 | $options = array_replace_recursive( 377 | $options, 378 | $request['client']['stream_context'] 379 | ); 380 | } 381 | 382 | private function createContext(array $request, array $options, array $params) 383 | { 384 | $this->applyCustomOptions($request, $options); 385 | return $this->createResource( 386 | function () use ($request, $options, $params) { 387 | return stream_context_create($options, $params); 388 | }, 389 | $request, 390 | $options 391 | ); 392 | } 393 | 394 | private function createStreamResource( 395 | $url, 396 | array $request, 397 | array $options, 398 | $context 399 | ) { 400 | return $this->createResource( 401 | function () use ($url, $context) { 402 | if (false === strpos($url, 'http')) { 403 | trigger_error("URL is invalid: {$url}", E_USER_WARNING); 404 | return null; 405 | } 406 | $resource = fopen($url, 'r', null, $context); 407 | $this->lastHeaders = $http_response_header; 408 | return $resource; 409 | }, 410 | $request, 411 | $options 412 | ); 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /src/Core.php: -------------------------------------------------------------------------------- 1 | $value) { 48 | if (!strcasecmp($name, $header)) { 49 | $result = array_merge($result, $value); 50 | } 51 | } 52 | } 53 | 54 | return $result; 55 | } 56 | 57 | /** 58 | * Gets a header value from a message as a string or null 59 | * 60 | * This method searches through the "headers" key of a message for a header 61 | * using a case-insensitive search. The lines of the header are imploded 62 | * using commas into a single string return value. 63 | * 64 | * @param array $message Request or response hash. 65 | * @param string $header Header to retrieve 66 | * 67 | * @return string|null Returns the header string if found, or null if not. 68 | */ 69 | public static function header($message, $header) 70 | { 71 | $match = self::headerLines($message, $header); 72 | return $match ? implode(', ', $match) : null; 73 | } 74 | 75 | /** 76 | * Returns the first header value from a message as a string or null. If 77 | * a header line contains multiple values separated by a comma, then this 78 | * function will return the first value in the list. 79 | * 80 | * @param array $message Request or response hash. 81 | * @param string $header Header to retrieve 82 | * 83 | * @return string|null Returns the value as a string if found. 84 | */ 85 | public static function firstHeader($message, $header) 86 | { 87 | if (!empty($message['headers'])) { 88 | foreach ($message['headers'] as $name => $value) { 89 | if (!strcasecmp($name, $header)) { 90 | // Return the match itself if it is a single value. 91 | $pos = strpos($value[0], ','); 92 | return $pos ? substr($value[0], 0, $pos) : $value[0]; 93 | } 94 | } 95 | } 96 | 97 | return null; 98 | } 99 | 100 | /** 101 | * Returns true if a message has the provided case-insensitive header. 102 | * 103 | * @param array $message Request or response hash. 104 | * @param string $header Header to check 105 | * 106 | * @return bool 107 | */ 108 | public static function hasHeader($message, $header) 109 | { 110 | if (!empty($message['headers'])) { 111 | foreach ($message['headers'] as $name => $value) { 112 | if (!strcasecmp($name, $header)) { 113 | return true; 114 | } 115 | } 116 | } 117 | 118 | return false; 119 | } 120 | 121 | /** 122 | * Parses an array of header lines into an associative array of headers. 123 | * 124 | * @param array $lines Header lines array of strings in the following 125 | * format: "Name: Value" 126 | * @return array 127 | */ 128 | public static function headersFromLines($lines) 129 | { 130 | $headers = []; 131 | 132 | foreach ($lines as $line) { 133 | $parts = explode(':', $line, 2); 134 | $headers[trim($parts[0])][] = isset($parts[1]) 135 | ? trim($parts[1]) 136 | : null; 137 | } 138 | 139 | return $headers; 140 | } 141 | 142 | /** 143 | * Removes a header from a message using a case-insensitive comparison. 144 | * 145 | * @param array $message Message that contains 'headers' 146 | * @param string $header Header to remove 147 | * 148 | * @return array 149 | */ 150 | public static function removeHeader(array $message, $header) 151 | { 152 | if (isset($message['headers'])) { 153 | foreach (array_keys($message['headers']) as $key) { 154 | if (!strcasecmp($header, $key)) { 155 | unset($message['headers'][$key]); 156 | } 157 | } 158 | } 159 | 160 | return $message; 161 | } 162 | 163 | /** 164 | * Replaces any existing case insensitive headers with the given value. 165 | * 166 | * @param array $message Message that contains 'headers' 167 | * @param string $header Header to set. 168 | * @param array $value Value to set. 169 | * 170 | * @return array 171 | */ 172 | public static function setHeader(array $message, $header, array $value) 173 | { 174 | $message = self::removeHeader($message, $header); 175 | $message['headers'][$header] = $value; 176 | 177 | return $message; 178 | } 179 | 180 | /** 181 | * Creates a URL string from a request. 182 | * 183 | * If the "url" key is present on the request, it is returned, otherwise 184 | * the url is built up based on the scheme, host, uri, and query_string 185 | * request values. 186 | * 187 | * @param array $request Request to get the URL from 188 | * 189 | * @return string Returns the request URL as a string. 190 | * @throws \InvalidArgumentException if no Host header is present. 191 | */ 192 | public static function url(array $request) 193 | { 194 | if (isset($request['url'])) { 195 | return $request['url']; 196 | } 197 | 198 | $uri = (isset($request['scheme']) 199 | ? $request['scheme'] : 'http') . '://'; 200 | 201 | if ($host = self::header($request, 'host')) { 202 | $uri .= $host; 203 | } else { 204 | throw new \InvalidArgumentException('No Host header was provided'); 205 | } 206 | 207 | if (isset($request['uri'])) { 208 | $uri .= $request['uri']; 209 | } 210 | 211 | if (isset($request['query_string'])) { 212 | $uri .= '?' . $request['query_string']; 213 | } 214 | 215 | return $uri; 216 | } 217 | 218 | /** 219 | * Reads the body of a message into a string. 220 | * 221 | * @param array|FutureArrayInterface $message Array containing a "body" key 222 | * 223 | * @return null|string Returns the body as a string or null if not set. 224 | * @throws \InvalidArgumentException if a request body is invalid. 225 | */ 226 | public static function body($message) 227 | { 228 | if (!isset($message['body'])) { 229 | return null; 230 | } 231 | 232 | if ($message['body'] instanceof StreamInterface) { 233 | return (string) $message['body']; 234 | } 235 | 236 | switch (gettype($message['body'])) { 237 | case 'string': 238 | return $message['body']; 239 | case 'resource': 240 | return stream_get_contents($message['body']); 241 | case 'object': 242 | if ($message['body'] instanceof \Iterator) { 243 | return implode('', iterator_to_array($message['body'])); 244 | } elseif (method_exists($message['body'], '__toString')) { 245 | return (string) $message['body']; 246 | } 247 | default: 248 | throw new \InvalidArgumentException('Invalid request body: ' 249 | . self::describeType($message['body'])); 250 | } 251 | } 252 | 253 | /** 254 | * Rewind the body of the provided message if possible. 255 | * 256 | * @param array $message Message that contains a 'body' field. 257 | * 258 | * @return bool Returns true on success, false on failure 259 | */ 260 | public static function rewindBody($message) 261 | { 262 | if ($message['body'] instanceof StreamInterface) { 263 | return $message['body']->seek(0); 264 | } 265 | 266 | if ($message['body'] instanceof \Generator) { 267 | return false; 268 | } 269 | 270 | if ($message['body'] instanceof \Iterator) { 271 | $message['body']->rewind(); 272 | return true; 273 | } 274 | 275 | if (is_resource($message['body'])) { 276 | return rewind($message['body']); 277 | } 278 | 279 | return is_string($message['body']) 280 | || (is_object($message['body']) 281 | && method_exists($message['body'], '__toString')); 282 | } 283 | 284 | /** 285 | * Debug function used to describe the provided value type and class. 286 | * 287 | * @param mixed $input 288 | * 289 | * @return string Returns a string containing the type of the variable and 290 | * if a class is provided, the class name. 291 | */ 292 | public static function describeType($input) 293 | { 294 | switch (gettype($input)) { 295 | case 'object': 296 | return 'object(' . get_class($input) . ')'; 297 | case 'array': 298 | return 'array(' . count($input) . ')'; 299 | default: 300 | ob_start(); 301 | var_dump($input); 302 | // normalize float vs double 303 | return str_replace('double(', 'float(', rtrim(ob_get_clean())); 304 | } 305 | } 306 | 307 | /** 308 | * Sleep for the specified amount of time specified in the request's 309 | * ['client']['delay'] option if present. 310 | * 311 | * This function should only be used when a non-blocking sleep is not 312 | * possible. 313 | * 314 | * @param array $request Request to sleep 315 | */ 316 | public static function doSleep(array $request) 317 | { 318 | if (isset($request['client']['delay'])) { 319 | usleep($request['client']['delay'] * 1000); 320 | } 321 | } 322 | 323 | /** 324 | * Returns a proxied future that modifies the dereferenced value of another 325 | * future using a promise. 326 | * 327 | * @param FutureArrayInterface $future Future to wrap with a new future 328 | * @param callable $onFulfilled Invoked when the future fulfilled 329 | * @param callable $onRejected Invoked when the future rejected 330 | * @param callable $onProgress Invoked when the future progresses 331 | * 332 | * @return FutureArray 333 | */ 334 | public static function proxy( 335 | FutureArrayInterface $future, 336 | callable $onFulfilled = null, 337 | callable $onRejected = null, 338 | callable $onProgress = null 339 | ) { 340 | return new FutureArray( 341 | $future->then($onFulfilled, $onRejected, $onProgress), 342 | [$future, 'wait'], 343 | [$future, 'cancel'] 344 | ); 345 | } 346 | 347 | /** 348 | * Returns a debug stream based on the provided variable. 349 | * 350 | * @param mixed $value Optional value 351 | * 352 | * @return resource 353 | */ 354 | public static function getDebugResource($value = null) 355 | { 356 | if (is_resource($value)) { 357 | return $value; 358 | } elseif (defined('STDOUT')) { 359 | return STDOUT; 360 | } else { 361 | return fopen('php://output', 'w'); 362 | } 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/Exception/CancelledException.php: -------------------------------------------------------------------------------- 1 | wrappedPromise = $promise; 46 | $this->waitfn = $wait; 47 | $this->cancelfn = $cancel; 48 | } 49 | 50 | public function wait() 51 | { 52 | if (!$this->isRealized) { 53 | $this->addShadow(); 54 | if (!$this->isRealized && $this->waitfn) { 55 | $this->invokeWait(); 56 | } 57 | if (!$this->isRealized) { 58 | $this->error = new RingException('Waiting did not resolve future'); 59 | } 60 | } 61 | 62 | if ($this->error) { 63 | throw $this->error; 64 | } 65 | 66 | return $this->result; 67 | } 68 | 69 | public function promise() 70 | { 71 | return $this->wrappedPromise; 72 | } 73 | 74 | public function then( 75 | callable $onFulfilled = null, 76 | callable $onRejected = null, 77 | callable $onProgress = null 78 | ) { 79 | return $this->wrappedPromise->then($onFulfilled, $onRejected, $onProgress); 80 | } 81 | 82 | public function cancel() 83 | { 84 | if (!$this->isRealized) { 85 | $cancelfn = $this->cancelfn; 86 | $this->waitfn = $this->cancelfn = null; 87 | $this->isRealized = true; 88 | $this->error = new CancelledFutureAccessException(); 89 | if ($cancelfn) { 90 | $cancelfn($this); 91 | } 92 | } 93 | } 94 | 95 | private function addShadow() 96 | { 97 | // Get the result and error when the promise is resolved. Note that 98 | // calling this function might trigger the resolution immediately. 99 | $this->wrappedPromise->then( 100 | function ($value) { 101 | $this->isRealized = true; 102 | $this->result = $value; 103 | $this->waitfn = $this->cancelfn = null; 104 | }, 105 | function ($error) { 106 | $this->isRealized = true; 107 | $this->error = $error; 108 | $this->waitfn = $this->cancelfn = null; 109 | } 110 | ); 111 | } 112 | 113 | private function invokeWait() 114 | { 115 | try { 116 | $wait = $this->waitfn; 117 | $this->waitfn = null; 118 | $wait(); 119 | } catch (\Exception $e) { 120 | // Defer can throw to reject. 121 | $this->error = $e; 122 | $this->isRealized = true; 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Future/CompletedFutureArray.php: -------------------------------------------------------------------------------- 1 | result[$offset]); 17 | } 18 | 19 | public function offsetGet($offset) 20 | { 21 | return $this->result[$offset]; 22 | } 23 | 24 | public function offsetSet($offset, $value) 25 | { 26 | $this->result[$offset] = $value; 27 | } 28 | 29 | public function offsetUnset($offset) 30 | { 31 | unset($this->result[$offset]); 32 | } 33 | 34 | public function count() 35 | { 36 | return count($this->result); 37 | } 38 | 39 | public function getIterator() 40 | { 41 | return new \ArrayIterator($this->result); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Future/CompletedFutureValue.php: -------------------------------------------------------------------------------- 1 | result = $result; 25 | $this->error = $e; 26 | } 27 | 28 | public function wait() 29 | { 30 | if ($this->error) { 31 | throw $this->error; 32 | } 33 | 34 | return $this->result; 35 | } 36 | 37 | public function cancel() {} 38 | 39 | public function promise() 40 | { 41 | if (!$this->cachedPromise) { 42 | $this->cachedPromise = $this->error 43 | ? new RejectedPromise($this->error) 44 | : new FulfilledPromise($this->result); 45 | } 46 | 47 | return $this->cachedPromise; 48 | } 49 | 50 | public function then( 51 | callable $onFulfilled = null, 52 | callable $onRejected = null, 53 | callable $onProgress = null 54 | ) { 55 | return $this->promise()->then($onFulfilled, $onRejected, $onProgress); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Future/FutureArray.php: -------------------------------------------------------------------------------- 1 | _value[$offset]); 14 | } 15 | 16 | public function offsetGet($offset) 17 | { 18 | return $this->_value[$offset]; 19 | } 20 | 21 | public function offsetSet($offset, $value) 22 | { 23 | $this->_value[$offset] = $value; 24 | } 25 | 26 | public function offsetUnset($offset) 27 | { 28 | unset($this->_value[$offset]); 29 | } 30 | 31 | public function count() 32 | { 33 | return count($this->_value); 34 | } 35 | 36 | public function getIterator() 37 | { 38 | return new \ArrayIterator($this->_value); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Future/FutureArrayInterface.php: -------------------------------------------------------------------------------- 1 | _value = $this->wait(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Client/CurlHandlerTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('curl_reset() is not available'); 12 | } 13 | } 14 | 15 | protected function getHandler($factory = null, $options = []) 16 | { 17 | return new CurlHandler($options); 18 | } 19 | 20 | public function testCanSetMaxHandles() 21 | { 22 | $a = new CurlHandler(['max_handles' => 10]); 23 | $this->assertEquals(10, $this->readAttribute($a, 'maxHandles')); 24 | } 25 | 26 | public function testCreatesCurlErrors() 27 | { 28 | $handler = new CurlHandler(); 29 | $response = $handler([ 30 | 'http_method' => 'GET', 31 | 'uri' => '/', 32 | 'headers' => ['host' => ['localhost:123']], 33 | 'client' => ['timeout' => 0.001, 'connect_timeout' => 0.001], 34 | ]); 35 | $this->assertNull($response['status']); 36 | $this->assertNull($response['reason']); 37 | $this->assertEquals([], $response['headers']); 38 | $this->assertInstanceOf( 39 | 'GuzzleHttp\Ring\Exception\RingException', 40 | $response['error'] 41 | ); 42 | 43 | $this->assertEquals( 44 | 1, 45 | preg_match('/^cURL error \d+: .*$/', $response['error']->getMessage()) 46 | ); 47 | } 48 | 49 | public function testReleasesAdditionalEasyHandles() 50 | { 51 | Server::flush(); 52 | $response = [ 53 | 'status' => 200, 54 | 'headers' => ['Content-Length' => [4]], 55 | 'body' => 'test', 56 | ]; 57 | 58 | Server::enqueue([$response, $response, $response, $response]); 59 | $a = new CurlHandler(['max_handles' => 2]); 60 | 61 | $fn = function () use (&$calls, $a, &$fn) { 62 | if (++$calls < 4) { 63 | $a([ 64 | 'http_method' => 'GET', 65 | 'headers' => ['host' => [Server::$host]], 66 | 'client' => ['progress' => $fn], 67 | ]); 68 | } 69 | }; 70 | 71 | $request = [ 72 | 'http_method' => 'GET', 73 | 'headers' => ['host' => [Server::$host]], 74 | 'client' => [ 75 | 'progress' => $fn, 76 | ], 77 | ]; 78 | 79 | $a($request); 80 | $this->assertCount(2, $this->readAttribute($a, 'handles')); 81 | } 82 | 83 | public function testReusesHandles() 84 | { 85 | Server::flush(); 86 | $response = ['status' => 200]; 87 | Server::enqueue([$response, $response]); 88 | $a = new CurlHandler(); 89 | $request = [ 90 | 'http_method' => 'GET', 91 | 'headers' => ['host' => [Server::$host]], 92 | ]; 93 | $a($request); 94 | $a($request); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Client/CurlMultiHandlerTest.php: -------------------------------------------------------------------------------- 1 | 200]]); 11 | $a = new CurlMultiHandler(); 12 | $response = $a([ 13 | 'http_method' => 'GET', 14 | 'headers' => ['host' => [Server::$host]], 15 | ]); 16 | $this->assertInstanceOf('GuzzleHttp\Ring\Future\FutureArray', $response); 17 | $this->assertEquals(200, $response['status']); 18 | $this->assertArrayHasKey('transfer_stats', $response); 19 | $realUrl = trim($response['transfer_stats']['url'], '/'); 20 | $this->assertEquals(trim(Server::$url, '/'), $realUrl); 21 | $this->assertArrayHasKey('effective_url', $response); 22 | $this->assertEquals( 23 | trim(Server::$url, '/'), 24 | trim($response['effective_url'], '/') 25 | ); 26 | } 27 | 28 | public function testCreatesErrorResponses() 29 | { 30 | $url = 'http://localhost:123/'; 31 | $a = new CurlMultiHandler(); 32 | $response = $a([ 33 | 'http_method' => 'GET', 34 | 'headers' => ['host' => ['localhost:123']], 35 | ]); 36 | $this->assertInstanceOf('GuzzleHttp\Ring\Future\FutureArray', $response); 37 | $this->assertNull($response['status']); 38 | $this->assertNull($response['reason']); 39 | $this->assertEquals([], $response['headers']); 40 | $this->assertArrayHasKey('error', $response); 41 | $this->assertContains('cURL error ', $response['error']->getMessage()); 42 | $this->assertArrayHasKey('transfer_stats', $response); 43 | $this->assertEquals( 44 | trim($url, '/'), 45 | trim($response['transfer_stats']['url'], '/') 46 | ); 47 | $this->assertArrayHasKey('effective_url', $response); 48 | $this->assertEquals( 49 | trim($url, '/'), 50 | trim($response['effective_url'], '/') 51 | ); 52 | } 53 | 54 | public function testSendsFuturesWhenDestructed() 55 | { 56 | Server::enqueue([['status' => 200]]); 57 | $a = new CurlMultiHandler(); 58 | $response = $a([ 59 | 'http_method' => 'GET', 60 | 'headers' => ['host' => [Server::$host]], 61 | ]); 62 | $this->assertInstanceOf('GuzzleHttp\Ring\Future\FutureArray', $response); 63 | $a->__destruct(); 64 | $this->assertEquals(200, $response['status']); 65 | } 66 | 67 | public function testCanSetMaxHandles() 68 | { 69 | $a = new CurlMultiHandler(['max_handles' => 2]); 70 | $this->assertEquals(2, $this->readAttribute($a, 'maxHandles')); 71 | } 72 | 73 | public function testCanSetSelectTimeout() 74 | { 75 | $a = new CurlMultiHandler(['select_timeout' => 2]); 76 | $this->assertEquals(2, $this->readAttribute($a, 'selectTimeout')); 77 | } 78 | 79 | public function testSendsFuturesWhenMaxHandlesIsReached() 80 | { 81 | $request = [ 82 | 'http_method' => 'PUT', 83 | 'headers' => ['host' => [Server::$host]], 84 | 'future' => 'lazy', // passing this to control the test 85 | ]; 86 | $response = ['status' => 200]; 87 | Server::flush(); 88 | Server::enqueue([$response, $response, $response]); 89 | $a = new CurlMultiHandler(['max_handles' => 3]); 90 | for ($i = 0; $i < 5; $i++) { 91 | $responses[] = $a($request); 92 | } 93 | $this->assertCount(3, Server::received()); 94 | $responses[3]->cancel(); 95 | $responses[4]->cancel(); 96 | } 97 | 98 | public function testCanCancel() 99 | { 100 | Server::flush(); 101 | $response = ['status' => 200]; 102 | Server::enqueue(array_fill_keys(range(0, 10), $response)); 103 | $a = new CurlMultiHandler(); 104 | $responses = []; 105 | 106 | for ($i = 0; $i < 10; $i++) { 107 | $response = $a([ 108 | 'http_method' => 'GET', 109 | 'headers' => ['host' => [Server::$host]], 110 | 'future' => 'lazy', 111 | ]); 112 | $response->cancel(); 113 | $responses[] = $response; 114 | } 115 | 116 | $this->assertCount(0, Server::received()); 117 | 118 | foreach ($responses as $response) { 119 | $this->assertTrue($this->readAttribute($response, 'isRealized')); 120 | } 121 | } 122 | 123 | public function testCannotCancelFinished() 124 | { 125 | Server::flush(); 126 | Server::enqueue([['status' => 200]]); 127 | $a = new CurlMultiHandler(); 128 | $response = $a([ 129 | 'http_method' => 'GET', 130 | 'headers' => ['host' => [Server::$host]], 131 | ]); 132 | $response->wait(); 133 | $response->cancel(); 134 | } 135 | 136 | public function testDelaysInParallel() 137 | { 138 | Server::flush(); 139 | Server::enqueue([['status' => 200]]); 140 | $a = new CurlMultiHandler(); 141 | $expected = microtime(true) + (100 / 1000); 142 | $response = $a([ 143 | 'http_method' => 'GET', 144 | 'headers' => ['host' => [Server::$host]], 145 | 'client' => ['delay' => 100], 146 | ]); 147 | $response->wait(); 148 | $this->assertGreaterThanOrEqual($expected, microtime(true)); 149 | } 150 | 151 | public function testSendsNonLazyFutures() 152 | { 153 | $request = [ 154 | 'http_method' => 'GET', 155 | 'headers' => ['host' => [Server::$host]], 156 | 'future' => true, 157 | ]; 158 | Server::flush(); 159 | Server::enqueue([['status' => 202]]); 160 | $a = new CurlMultiHandler(); 161 | $response = $a($request); 162 | $this->assertInstanceOf('GuzzleHttp\Ring\Future\FutureArray', $response); 163 | $this->assertEquals(202, $response['status']); 164 | } 165 | 166 | public function testExtractsErrors() 167 | { 168 | $request = [ 169 | 'http_method' => 'GET', 170 | 'headers' => ['host' => ['127.0.0.1:123']], 171 | 'future' => true, 172 | ]; 173 | Server::flush(); 174 | Server::enqueue([['status' => 202]]); 175 | $a = new CurlMultiHandler(); 176 | $response = $a($request); 177 | $this->assertInstanceOf('GuzzleHttp\Ring\Future\FutureArray', $response); 178 | $this->assertEquals(CURLE_COULDNT_CONNECT, $response['curl']['errno']); 179 | $this->assertNotEmpty($response['curl']['error']); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /tests/Client/MiddlewareTest.php: -------------------------------------------------------------------------------- 1 | 200]); 12 | $calledA = false; 13 | $a = function (array $req) use (&$calledA, $future) { 14 | $calledA = true; 15 | return $future; 16 | }; 17 | $calledB = false; 18 | $b = function (array $req) use (&$calledB) { $calledB = true; }; 19 | $s = Middleware::wrapFuture($a, $b); 20 | $s([]); 21 | $this->assertTrue($calledA); 22 | $this->assertFalse($calledB); 23 | } 24 | 25 | public function testFutureCallsStreamingHandler() 26 | { 27 | $future = new CompletedFutureArray(['status' => 200]); 28 | $calledA = false; 29 | $a = function (array $req) use (&$calledA) { $calledA = true; }; 30 | $calledB = false; 31 | $b = function (array $req) use (&$calledB, $future) { 32 | $calledB = true; 33 | return $future; 34 | }; 35 | $s = Middleware::wrapFuture($a, $b); 36 | $result = $s(['client' => ['future' => true]]); 37 | $this->assertFalse($calledA); 38 | $this->assertTrue($calledB); 39 | $this->assertSame($future, $result); 40 | } 41 | 42 | public function testStreamingCallsDefaultHandler() 43 | { 44 | $calledA = false; 45 | $a = function (array $req) use (&$calledA) { $calledA = true; }; 46 | $calledB = false; 47 | $b = function (array $req) use (&$calledB) { $calledB = true; }; 48 | $s = Middleware::wrapStreaming($a, $b); 49 | $s([]); 50 | $this->assertTrue($calledA); 51 | $this->assertFalse($calledB); 52 | } 53 | 54 | public function testStreamingCallsStreamingHandler() 55 | { 56 | $calledA = false; 57 | $a = function (array $req) use (&$calledA) { $calledA = true; }; 58 | $calledB = false; 59 | $b = function (array $req) use (&$calledB) { $calledB = true; }; 60 | $s = Middleware::wrapStreaming($a, $b); 61 | $s(['client' => ['stream' => true]]); 62 | $this->assertFalse($calledA); 63 | $this->assertTrue($calledB); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Client/MockHandlerTest.php: -------------------------------------------------------------------------------- 1 | 200]); 13 | $response = $mock([]); 14 | $this->assertEquals(200, $response['status']); 15 | $this->assertEquals([], $response['headers']); 16 | $this->assertNull($response['body']); 17 | $this->assertNull($response['reason']); 18 | $this->assertNull($response['effective_url']); 19 | } 20 | 21 | public function testReturnsFutures() 22 | { 23 | $deferred = new Deferred(); 24 | $future = new FutureArray( 25 | $deferred->promise(), 26 | function () use ($deferred) { 27 | $deferred->resolve(['status' => 200]); 28 | } 29 | ); 30 | $mock = new MockHandler($future); 31 | $response = $mock([]); 32 | $this->assertInstanceOf('GuzzleHttp\Ring\Future\FutureArray', $response); 33 | $this->assertEquals(200, $response['status']); 34 | } 35 | 36 | public function testReturnsFuturesWithThenCall() 37 | { 38 | $deferred = new Deferred(); 39 | $future = new FutureArray( 40 | $deferred->promise(), 41 | function () use ($deferred) { 42 | $deferred->resolve(['status' => 200]); 43 | } 44 | ); 45 | $mock = new MockHandler($future); 46 | $response = $mock([]); 47 | $this->assertInstanceOf('GuzzleHttp\Ring\Future\FutureArray', $response); 48 | $this->assertEquals(200, $response['status']); 49 | $req = null; 50 | $promise = $response->then(function ($value) use (&$req) { 51 | $req = $value; 52 | $this->assertEquals(200, $req['status']); 53 | }); 54 | $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); 55 | $this->assertEquals(200, $req['status']); 56 | } 57 | 58 | public function testReturnsFuturesAndProxiesCancel() 59 | { 60 | $c = null; 61 | $deferred = new Deferred(); 62 | $future = new FutureArray( 63 | $deferred->promise(), 64 | function () {}, 65 | function () use (&$c) { 66 | $c = true; 67 | return true; 68 | } 69 | ); 70 | $mock = new MockHandler($future); 71 | $response = $mock([]); 72 | $this->assertInstanceOf('GuzzleHttp\Ring\Future\FutureArray', $response); 73 | $response->cancel(); 74 | $this->assertTrue($c); 75 | } 76 | 77 | /** 78 | * @expectedException \InvalidArgumentException 79 | * @expectedExceptionMessage Response must be an array or FutureArrayInterface. Found 80 | */ 81 | public function testEnsuresMockIsValid() 82 | { 83 | $mock = new MockHandler('foo'); 84 | $mock([]); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Client/Server.php: -------------------------------------------------------------------------------- 1 | [], 'reason' => '', 'body' => '']; 60 | $data[] = $response; 61 | } 62 | 63 | self::send('PUT', '/guzzle-server/responses', json_encode($data)); 64 | } 65 | 66 | /** 67 | * Get all of the received requests as a RingPHP request structure. 68 | * 69 | * @return array 70 | * @throws \RuntimeException 71 | */ 72 | public static function received() 73 | { 74 | if (!self::$started) { 75 | return []; 76 | } 77 | 78 | $response = self::send('GET', '/guzzle-server/requests'); 79 | $body = Core::body($response); 80 | $result = json_decode($body, true); 81 | if ($result === false) { 82 | throw new \RuntimeException('Error decoding response: ' 83 | . json_last_error()); 84 | } 85 | 86 | foreach ($result as &$res) { 87 | if (isset($res['uri'])) { 88 | $res['resource'] = $res['uri']; 89 | } 90 | if (isset($res['query_string'])) { 91 | $res['resource'] .= '?' . $res['query_string']; 92 | } 93 | if (!isset($res['resource'])) { 94 | $res['resource'] = ''; 95 | } 96 | // Ensure that headers are all arrays 97 | if (isset($res['headers'])) { 98 | foreach ($res['headers'] as &$h) { 99 | $h = (array) $h; 100 | } 101 | unset($h); 102 | } 103 | } 104 | 105 | unset($res); 106 | return $result; 107 | } 108 | 109 | /** 110 | * Stop running the node.js server 111 | */ 112 | public static function stop() 113 | { 114 | if (self::$started) { 115 | self::send('DELETE', '/guzzle-server'); 116 | } 117 | 118 | self::$started = false; 119 | } 120 | 121 | public static function wait($maxTries = 20) 122 | { 123 | $tries = 0; 124 | while (!self::isListening() && ++$tries < $maxTries) { 125 | usleep(100000); 126 | } 127 | 128 | if (!self::isListening()) { 129 | throw new \RuntimeException('Unable to contact node.js server'); 130 | } 131 | } 132 | 133 | public static function start() 134 | { 135 | if (self::$started) { 136 | return; 137 | } 138 | 139 | try { 140 | self::wait(); 141 | } catch (\Exception $e) { 142 | exec('node ' . __DIR__ . \DIRECTORY_SEPARATOR . 'server.js ' 143 | . self::$port . ' >> /tmp/server.log 2>&1 &'); 144 | self::wait(); 145 | } 146 | 147 | self::$started = true; 148 | } 149 | 150 | private static function isListening() 151 | { 152 | $response = self::send('GET', '/guzzle-server/perf', null, [ 153 | 'connect_timeout' => 1, 154 | 'timeout' => 1 155 | ]); 156 | 157 | return !isset($response['error']); 158 | } 159 | 160 | private static function send( 161 | $method, 162 | $path, 163 | $body = null, 164 | array $client = [] 165 | ) { 166 | $handler = new StreamHandler(); 167 | 168 | $request = [ 169 | 'http_method' => $method, 170 | 'uri' => $path, 171 | 'request_port' => 8125, 172 | 'headers' => ['host' => ['127.0.0.1:8125']], 173 | 'body' => $body, 174 | 'client' => $client, 175 | ]; 176 | 177 | if ($body) { 178 | $request['headers']['content-length'] = [strlen($body)]; 179 | } 180 | 181 | return $handler($request); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /tests/Client/StreamHandlerTest.php: -------------------------------------------------------------------------------- 1 | queueRes(); 13 | $handler = new StreamHandler(); 14 | $response = $handler([ 15 | 'http_method' => 'GET', 16 | 'uri' => '/', 17 | 'headers' => [ 18 | 'host' => [Server::$host], 19 | 'Foo' => ['Bar'], 20 | ], 21 | ]); 22 | 23 | $this->assertEquals('1.1', $response['version']); 24 | $this->assertEquals(200, $response['status']); 25 | $this->assertEquals('OK', $response['reason']); 26 | $this->assertEquals(['Bar'], $response['headers']['Foo']); 27 | $this->assertEquals(['8'], $response['headers']['Content-Length']); 28 | $this->assertEquals('hi there', Core::body($response)); 29 | 30 | $sent = Server::received()[0]; 31 | $this->assertEquals('GET', $sent['http_method']); 32 | $this->assertEquals('/', $sent['resource']); 33 | $this->assertEquals(['127.0.0.1:8125'], $sent['headers']['host']); 34 | $this->assertEquals('Bar', Core::header($sent, 'foo')); 35 | } 36 | 37 | public function testAddsErrorToResponse() 38 | { 39 | $handler = new StreamHandler(); 40 | $result = $handler([ 41 | 'http_method' => 'GET', 42 | 'headers' => ['host' => ['localhost:123']], 43 | 'client' => ['timeout' => 0.01], 44 | ]); 45 | $this->assertInstanceOf( 46 | 'GuzzleHttp\Ring\Future\CompletedFutureArray', 47 | $result 48 | ); 49 | $this->assertNull($result['status']); 50 | $this->assertNull($result['body']); 51 | $this->assertEquals([], $result['headers']); 52 | $this->assertInstanceOf( 53 | 'GuzzleHttp\Ring\Exception\RingException', 54 | $result['error'] 55 | ); 56 | } 57 | 58 | public function testEnsuresTheHttpProtocol() 59 | { 60 | $handler = new StreamHandler(); 61 | $result = $handler([ 62 | 'http_method' => 'GET', 63 | 'url' => 'ftp://localhost:123', 64 | ]); 65 | $this->assertArrayHasKey('error', $result); 66 | $this->assertContains( 67 | 'URL is invalid: ftp://localhost:123', 68 | $result['error']->getMessage() 69 | ); 70 | } 71 | 72 | public function testStreamAttributeKeepsStreamOpen() 73 | { 74 | $this->queueRes(); 75 | $handler = new StreamHandler(); 76 | $response = $handler([ 77 | 'http_method' => 'PUT', 78 | 'uri' => '/foo', 79 | 'query_string' => 'baz=bar', 80 | 'headers' => [ 81 | 'host' => [Server::$host], 82 | 'Foo' => ['Bar'], 83 | ], 84 | 'body' => 'test', 85 | 'client' => ['stream' => true], 86 | ]); 87 | 88 | $this->assertEquals(200, $response['status']); 89 | $this->assertEquals('OK', $response['reason']); 90 | $this->assertEquals('8', Core::header($response, 'Content-Length')); 91 | $body = $response['body']; 92 | $this->assertTrue(is_resource($body)); 93 | $this->assertEquals('http', stream_get_meta_data($body)['wrapper_type']); 94 | $this->assertEquals('hi there', stream_get_contents($body)); 95 | fclose($body); 96 | $sent = Server::received()[0]; 97 | $this->assertEquals('PUT', $sent['http_method']); 98 | $this->assertEquals('/foo', $sent['uri']); 99 | $this->assertEquals('baz=bar', $sent['query_string']); 100 | $this->assertEquals('/foo?baz=bar', $sent['resource']); 101 | $this->assertEquals('127.0.0.1:8125', Core::header($sent, 'host')); 102 | $this->assertEquals('Bar', Core::header($sent, 'foo')); 103 | } 104 | 105 | public function testDrainsResponseIntoTempStream() 106 | { 107 | $this->queueRes(); 108 | $handler = new StreamHandler(); 109 | $response = $handler([ 110 | 'http_method' => 'GET', 111 | 'uri' => '/', 112 | 'headers' => ['host' => [Server::$host]], 113 | ]); 114 | $body = $response['body']; 115 | $this->assertEquals('php://temp', stream_get_meta_data($body)['uri']); 116 | $this->assertEquals('hi', fread($body, 2)); 117 | fclose($body); 118 | } 119 | 120 | public function testDrainsResponseIntoSaveToBody() 121 | { 122 | $r = fopen('php://temp', 'r+'); 123 | $this->queueRes(); 124 | $handler = new StreamHandler(); 125 | $response = $handler([ 126 | 'http_method' => 'GET', 127 | 'uri' => '/', 128 | 'headers' => ['host' => [Server::$host]], 129 | 'client' => ['save_to' => $r], 130 | ]); 131 | $body = $response['body']; 132 | $this->assertEquals('php://temp', stream_get_meta_data($body)['uri']); 133 | $this->assertEquals('hi', fread($body, 2)); 134 | $this->assertEquals(' there', stream_get_contents($r)); 135 | fclose($r); 136 | } 137 | 138 | public function testDrainsResponseIntoSaveToBodyAtPath() 139 | { 140 | $tmpfname = tempnam('/tmp', 'save_to_path'); 141 | $this->queueRes(); 142 | $handler = new StreamHandler(); 143 | $response = $handler([ 144 | 'http_method' => 'GET', 145 | 'uri' => '/', 146 | 'headers' => ['host' => [Server::$host]], 147 | 'client' => ['save_to' => $tmpfname], 148 | ]); 149 | $body = $response['body']; 150 | $this->assertInstanceOf('GuzzleHttp\Stream\StreamInterface', $body); 151 | $this->assertEquals($tmpfname, $body->getMetadata('uri')); 152 | $this->assertEquals('hi', $body->read(2)); 153 | $body->close(); 154 | unlink($tmpfname); 155 | } 156 | 157 | public function testAutomaticallyDecompressGzip() 158 | { 159 | Server::flush(); 160 | $content = gzencode('test'); 161 | Server::enqueue([ 162 | [ 163 | 'status' => 200, 164 | 'reason' => 'OK', 165 | 'headers' => [ 166 | 'Content-Encoding' => ['gzip'], 167 | 'Content-Length' => [strlen($content)], 168 | ], 169 | 'body' => $content, 170 | ], 171 | ]); 172 | 173 | $handler = new StreamHandler(); 174 | $response = $handler([ 175 | 'http_method' => 'GET', 176 | 'headers' => ['host' => [Server::$host]], 177 | 'uri' => '/', 178 | 'client' => ['decode_content' => true], 179 | ]); 180 | $this->assertEquals('test', Core::body($response)); 181 | } 182 | 183 | public function testDoesNotForceGzipDecode() 184 | { 185 | Server::flush(); 186 | $content = gzencode('test'); 187 | Server::enqueue([ 188 | [ 189 | 'status' => 200, 190 | 'reason' => 'OK', 191 | 'headers' => [ 192 | 'Content-Encoding' => ['gzip'], 193 | 'Content-Length' => [strlen($content)], 194 | ], 195 | 'body' => $content, 196 | ], 197 | ]); 198 | 199 | $handler = new StreamHandler(); 200 | $response = $handler([ 201 | 'http_method' => 'GET', 202 | 'headers' => ['host' => [Server::$host]], 203 | 'uri' => '/', 204 | 'client' => ['stream' => true, 'decode_content' => false], 205 | ]); 206 | $this->assertSame($content, Core::body($response)); 207 | } 208 | 209 | public function testProtocolVersion() 210 | { 211 | $this->queueRes(); 212 | $handler = new StreamHandler(); 213 | $handler([ 214 | 'http_method' => 'GET', 215 | 'uri' => '/', 216 | 'headers' => ['host' => [Server::$host]], 217 | 'version' => 1.0, 218 | ]); 219 | 220 | $this->assertEquals(1.0, Server::received()[0]['version']); 221 | } 222 | 223 | protected function getSendResult(array $opts) 224 | { 225 | $this->queueRes(); 226 | $handler = new StreamHandler(); 227 | $opts['stream'] = true; 228 | return $handler([ 229 | 'http_method' => 'GET', 230 | 'uri' => '/', 231 | 'headers' => ['host' => [Server::$host]], 232 | 'client' => $opts, 233 | ]); 234 | } 235 | 236 | public function testAddsProxy() 237 | { 238 | $res = $this->getSendResult(['stream' => true, 'proxy' => '127.0.0.1:8125']); 239 | $opts = stream_context_get_options($res['body']); 240 | $this->assertEquals('127.0.0.1:8125', $opts['http']['proxy']); 241 | } 242 | 243 | public function testAddsTimeout() 244 | { 245 | $res = $this->getSendResult(['stream' => true, 'timeout' => 200]); 246 | $opts = stream_context_get_options($res['body']); 247 | $this->assertEquals(200, $opts['http']['timeout']); 248 | } 249 | 250 | public function testVerifiesVerifyIsValidIfPath() 251 | { 252 | $res = $this->getSendResult(['verify' => '/does/not/exist']); 253 | $this->assertContains( 254 | 'SSL CA bundle not found: /does/not/exist', 255 | (string) $res['error'] 256 | ); 257 | } 258 | 259 | public function testVerifyCanBeDisabled() 260 | { 261 | $res = $this->getSendResult(['verify' => false]); 262 | $this->assertArrayNotHasKey('error', $res); 263 | } 264 | 265 | public function testVerifiesCertIfValidPath() 266 | { 267 | $res = $this->getSendResult(['cert' => '/does/not/exist']); 268 | $this->assertContains( 269 | 'SSL certificate not found: /does/not/exist', 270 | (string) $res['error'] 271 | ); 272 | } 273 | 274 | public function testVerifyCanBeSetToPath() 275 | { 276 | $path = $path = ClientUtils::getDefaultCaBundle(); 277 | $res = $this->getSendResult(['verify' => $path]); 278 | $this->assertArrayNotHasKey('error', $res); 279 | $opts = stream_context_get_options($res['body']); 280 | $this->assertEquals(true, $opts['ssl']['verify_peer']); 281 | $this->assertEquals($path, $opts['ssl']['cafile']); 282 | $this->assertTrue(file_exists($opts['ssl']['cafile'])); 283 | } 284 | 285 | public function testUsesSystemDefaultBundle() 286 | { 287 | $path = $path = ClientUtils::getDefaultCaBundle(); 288 | $res = $this->getSendResult(['verify' => true]); 289 | $this->assertArrayNotHasKey('error', $res); 290 | $opts = stream_context_get_options($res['body']); 291 | if (PHP_VERSION_ID < 50600) { 292 | $this->assertEquals($path, $opts['ssl']['cafile']); 293 | } 294 | } 295 | 296 | public function testEnsuresVerifyOptionIsValid() 297 | { 298 | $res = $this->getSendResult(['verify' => 10]); 299 | $this->assertContains( 300 | 'Invalid verify request option', 301 | (string) $res['error'] 302 | ); 303 | } 304 | 305 | public function testCanSetPasswordWhenSettingCert() 306 | { 307 | $path = __FILE__; 308 | $res = $this->getSendResult(['cert' => [$path, 'foo']]); 309 | $opts = stream_context_get_options($res['body']); 310 | $this->assertEquals($path, $opts['ssl']['local_cert']); 311 | $this->assertEquals('foo', $opts['ssl']['passphrase']); 312 | } 313 | 314 | public function testDebugAttributeWritesToStream() 315 | { 316 | $this->queueRes(); 317 | $f = fopen('php://temp', 'w+'); 318 | $this->getSendResult(['debug' => $f]); 319 | fseek($f, 0); 320 | $contents = stream_get_contents($f); 321 | $this->assertContains(' [CONNECT]', $contents); 322 | $this->assertContains(' [FILE_SIZE_IS]', $contents); 323 | $this->assertContains(' [PROGRESS]', $contents); 324 | } 325 | 326 | public function testDebugAttributeWritesStreamInfoToBuffer() 327 | { 328 | $called = false; 329 | $this->queueRes(); 330 | $buffer = fopen('php://temp', 'r+'); 331 | $this->getSendResult([ 332 | 'progress' => function () use (&$called) { $called = true; }, 333 | 'debug' => $buffer, 334 | ]); 335 | fseek($buffer, 0); 336 | $contents = stream_get_contents($buffer); 337 | $this->assertContains(' [CONNECT]', $contents); 338 | $this->assertContains(' [FILE_SIZE_IS] message: "Content-Length: 8"', $contents); 339 | $this->assertContains(' [PROGRESS] bytes_max: "8"', $contents); 340 | $this->assertTrue($called); 341 | } 342 | 343 | public function testEmitsProgressInformation() 344 | { 345 | $called = []; 346 | $this->queueRes(); 347 | $this->getSendResult([ 348 | 'progress' => function () use (&$called) { 349 | $called[] = func_get_args(); 350 | }, 351 | ]); 352 | $this->assertNotEmpty($called); 353 | $this->assertEquals(8, $called[0][0]); 354 | $this->assertEquals(0, $called[0][1]); 355 | } 356 | 357 | public function testEmitsProgressInformationAndDebugInformation() 358 | { 359 | $called = []; 360 | $this->queueRes(); 361 | $buffer = fopen('php://memory', 'w+'); 362 | $this->getSendResult([ 363 | 'debug' => $buffer, 364 | 'progress' => function () use (&$called) { 365 | $called[] = func_get_args(); 366 | }, 367 | ]); 368 | $this->assertNotEmpty($called); 369 | $this->assertEquals(8, $called[0][0]); 370 | $this->assertEquals(0, $called[0][1]); 371 | rewind($buffer); 372 | $this->assertNotEmpty(stream_get_contents($buffer)); 373 | fclose($buffer); 374 | } 375 | 376 | public function testAddsProxyByProtocol() 377 | { 378 | $url = str_replace('http', 'tcp', Server::$url); 379 | $res = $this->getSendResult(['proxy' => ['http' => $url]]); 380 | $opts = stream_context_get_options($res['body']); 381 | $this->assertEquals($url, $opts['http']['proxy']); 382 | } 383 | 384 | public function testPerformsShallowMergeOfCustomContextOptions() 385 | { 386 | $res = $this->getSendResult([ 387 | 'stream_context' => [ 388 | 'http' => [ 389 | 'request_fulluri' => true, 390 | 'method' => 'HEAD', 391 | ], 392 | 'socket' => [ 393 | 'bindto' => '127.0.0.1:0', 394 | ], 395 | 'ssl' => [ 396 | 'verify_peer' => false, 397 | ], 398 | ], 399 | ]); 400 | 401 | $opts = stream_context_get_options($res['body']); 402 | $this->assertEquals('HEAD', $opts['http']['method']); 403 | $this->assertTrue($opts['http']['request_fulluri']); 404 | $this->assertFalse($opts['ssl']['verify_peer']); 405 | $this->assertEquals('127.0.0.1:0', $opts['socket']['bindto']); 406 | } 407 | 408 | public function testEnsuresThatStreamContextIsAnArray() 409 | { 410 | $res = $this->getSendResult(['stream_context' => 'foo']); 411 | $this->assertContains( 412 | 'stream_context must be an array', 413 | (string) $res['error'] 414 | ); 415 | } 416 | 417 | public function testDoesNotAddContentTypeByDefault() 418 | { 419 | $this->queueRes(); 420 | $handler = new StreamHandler(); 421 | $handler([ 422 | 'http_method' => 'PUT', 423 | 'uri' => '/', 424 | 'headers' => ['host' => [Server::$host], 'content-length' => [3]], 425 | 'body' => 'foo', 426 | ]); 427 | $req = Server::received()[0]; 428 | $this->assertEquals('', Core::header($req, 'Content-Type')); 429 | $this->assertEquals(3, Core::header($req, 'Content-Length')); 430 | } 431 | 432 | private function queueRes() 433 | { 434 | Server::flush(); 435 | Server::enqueue([ 436 | [ 437 | 'status' => 200, 438 | 'reason' => 'OK', 439 | 'headers' => [ 440 | 'Foo' => ['Bar'], 441 | 'Content-Length' => [8], 442 | ], 443 | 'body' => 'hi there', 444 | ], 445 | ]); 446 | } 447 | 448 | public function testSupports100Continue() 449 | { 450 | Server::flush(); 451 | Server::enqueue([ 452 | [ 453 | 'status' => '200', 454 | 'reason' => 'OK', 455 | 'headers' => [ 456 | 'Test' => ['Hello'], 457 | 'Content-Length' => ['4'], 458 | ], 459 | 'body' => 'test', 460 | ], 461 | ]); 462 | 463 | $request = [ 464 | 'http_method' => 'PUT', 465 | 'headers' => [ 466 | 'Host' => [Server::$host], 467 | 'Expect' => ['100-Continue'], 468 | ], 469 | 'body' => 'test', 470 | ]; 471 | 472 | $handler = new StreamHandler(); 473 | $response = $handler($request); 474 | $this->assertEquals(200, $response['status']); 475 | $this->assertEquals('OK', $response['reason']); 476 | $this->assertEquals(['Hello'], $response['headers']['Test']); 477 | $this->assertEquals(['4'], $response['headers']['Content-Length']); 478 | $this->assertEquals('test', Core::body($response)); 479 | } 480 | } 481 | -------------------------------------------------------------------------------- /tests/Client/server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Guzzle node.js test server to return queued responses to HTTP requests and 3 | * expose a RESTful API for enqueueing responses and retrieving the requests 4 | * that have been received. 5 | * 6 | * - Delete all requests that have been received: 7 | * > DELETE /guzzle-server/requests 8 | * > Host: 127.0.0.1:8125 9 | * 10 | * - Enqueue responses 11 | * > PUT /guzzle-server/responses 12 | * > Host: 127.0.0.1:8125 13 | * > 14 | * > [{'status': 200, 'reason': 'OK', 'headers': {}, 'body': '' }] 15 | * 16 | * - Get the received requests 17 | * > GET /guzzle-server/requests 18 | * > Host: 127.0.0.1:8125 19 | * 20 | * < HTTP/1.1 200 OK 21 | * < 22 | * < [{'http_method': 'GET', 'uri': '/', 'headers': {}, 'body': 'string'}] 23 | * 24 | * - Attempt access to the secure area 25 | * > GET /secure/by-digest/qop-auth/guzzle-server/requests 26 | * > Host: 127.0.0.1:8125 27 | * 28 | * < HTTP/1.1 401 Unauthorized 29 | * < WWW-Authenticate: Digest realm="Digest Test", qop="auth", nonce="0796e98e1aeef43141fab2a66bf4521a", algorithm="MD5", stale="false" 30 | * < 31 | * < 401 Unauthorized 32 | * 33 | * - Shutdown the server 34 | * > DELETE /guzzle-server 35 | * > Host: 127.0.0.1:8125 36 | * 37 | * @package Guzzle PHP 38 | * @license See the LICENSE file that was distributed with this source code. 39 | */ 40 | 41 | var http = require('http'); 42 | var url = require('url'); 43 | 44 | /** 45 | * Guzzle node.js server 46 | * @class 47 | */ 48 | var GuzzleServer = function(port, log) { 49 | 50 | this.port = port; 51 | this.log = log; 52 | this.responses = []; 53 | this.requests = []; 54 | var that = this; 55 | 56 | var md5 = function(input) { 57 | var crypto = require('crypto'); 58 | var hasher = crypto.createHash('md5'); 59 | hasher.update(input); 60 | return hasher.digest('hex'); 61 | } 62 | 63 | /** 64 | * Node.js HTTP server authentication module. 65 | * 66 | * It is only initialized on demand (by loadAuthentifier). This avoids 67 | * requiring the dependency to http-auth on standard operations, and the 68 | * performance hit at startup. 69 | */ 70 | var auth; 71 | 72 | /** 73 | * Provides authentication handlers (Basic, Digest). 74 | */ 75 | var loadAuthentifier = function(type, options) { 76 | var typeId = type; 77 | if (type == 'digest') { 78 | typeId += '.'+(options && options.qop ? options.qop : 'none'); 79 | } 80 | if (!loadAuthentifier[typeId]) { 81 | if (!auth) { 82 | try { 83 | auth = require('http-auth'); 84 | } catch (e) { 85 | if (e.code == 'MODULE_NOT_FOUND') { 86 | return; 87 | } 88 | } 89 | } 90 | switch (type) { 91 | case 'digest': 92 | var digestParams = { 93 | realm: 'Digest Test', 94 | login: 'me', 95 | password: 'test' 96 | }; 97 | if (options && options.qop) { 98 | digestParams.qop = options.qop; 99 | } 100 | loadAuthentifier[typeId] = auth.digest(digestParams, function(username, callback) { 101 | callback(md5(digestParams.login + ':' + digestParams.realm + ':' + digestParams.password)); 102 | }); 103 | break 104 | } 105 | } 106 | return loadAuthentifier[typeId]; 107 | }; 108 | 109 | var firewallRequest = function(request, req, res, requestHandlerCallback) { 110 | var securedAreaUriParts = request.uri.match(/^\/secure\/by-(digest)(\/qop-([^\/]*))?(\/.*)$/); 111 | if (securedAreaUriParts) { 112 | var authentifier = loadAuthentifier(securedAreaUriParts[1], { qop: securedAreaUriParts[2] }); 113 | if (!authentifier) { 114 | res.writeHead(501, 'HTTP authentication not implemented', { 'Content-Length': 0 }); 115 | res.end(); 116 | return; 117 | } 118 | authentifier.check(req, res, function(req, res) { 119 | req.url = securedAreaUriParts[4]; 120 | requestHandlerCallback(request, req, res); 121 | }); 122 | } else { 123 | requestHandlerCallback(request, req, res); 124 | } 125 | }; 126 | 127 | var controlRequest = function(request, req, res) { 128 | if (req.url == '/guzzle-server/perf') { 129 | res.writeHead(200, 'OK', {'Content-Length': 16}); 130 | res.end('Body of response'); 131 | } else if (req.method == 'DELETE') { 132 | if (req.url == '/guzzle-server/requests') { 133 | // Clear the received requests 134 | that.requests = []; 135 | res.writeHead(200, 'OK', { 'Content-Length': 0 }); 136 | res.end(); 137 | if (that.log) { 138 | console.log('Flushing requests'); 139 | } 140 | } else if (req.url == '/guzzle-server') { 141 | // Shutdown the server 142 | res.writeHead(200, 'OK', { 'Content-Length': 0, 'Connection': 'close' }); 143 | res.end(); 144 | if (that.log) { 145 | console.log('Shutting down'); 146 | } 147 | that.server.close(); 148 | } 149 | } else if (req.method == 'GET') { 150 | if (req.url === '/guzzle-server/requests') { 151 | if (that.log) { 152 | console.log('Sending received requests'); 153 | } 154 | // Get received requests 155 | var body = JSON.stringify(that.requests); 156 | res.writeHead(200, 'OK', { 'Content-Length': body.length }); 157 | res.end(body); 158 | } 159 | } else if (req.method == 'PUT' && req.url == '/guzzle-server/responses') { 160 | if (that.log) { 161 | console.log('Adding responses...'); 162 | } 163 | if (!request.body) { 164 | if (that.log) { 165 | console.log('No response data was provided'); 166 | } 167 | res.writeHead(400, 'NO RESPONSES IN REQUEST', { 'Content-Length': 0 }); 168 | } else { 169 | that.responses = eval('(' + request.body + ')'); 170 | for (var i = 0; i < that.responses.length; i++) { 171 | if (that.responses[i].body) { 172 | that.responses[i].body = new Buffer(that.responses[i].body, 'base64'); 173 | } 174 | } 175 | if (that.log) { 176 | console.log(that.responses); 177 | } 178 | res.writeHead(200, 'OK', { 'Content-Length': 0 }); 179 | } 180 | res.end(); 181 | } 182 | }; 183 | 184 | var receivedRequest = function(request, req, res) { 185 | if (req.url.indexOf('/guzzle-server') === 0) { 186 | controlRequest(request, req, res); 187 | } else if (req.url.indexOf('/guzzle-server') == -1 && !that.responses.length) { 188 | res.writeHead(500); 189 | res.end('No responses in queue'); 190 | } else { 191 | if (that.log) { 192 | console.log('Returning response from queue and adding request'); 193 | } 194 | that.requests.push(request); 195 | var response = that.responses.shift(); 196 | res.writeHead(response.status, response.reason, response.headers); 197 | res.end(response.body); 198 | } 199 | }; 200 | 201 | this.start = function() { 202 | 203 | that.server = http.createServer(function(req, res) { 204 | 205 | var parts = url.parse(req.url, false); 206 | var request = { 207 | http_method: req.method, 208 | scheme: parts.scheme, 209 | uri: parts.pathname, 210 | query_string: parts.query, 211 | headers: req.headers, 212 | version: req.httpVersion, 213 | body: '' 214 | }; 215 | 216 | // Receive each chunk of the request body 217 | req.addListener('data', function(chunk) { 218 | request.body += chunk; 219 | }); 220 | 221 | // Called when the request completes 222 | req.addListener('end', function() { 223 | firewallRequest(request, req, res, receivedRequest); 224 | }); 225 | }); 226 | 227 | that.server.listen(this.port, '127.0.0.1'); 228 | 229 | if (this.log) { 230 | console.log('Server running at http://127.0.0.1:8125/'); 231 | } 232 | }; 233 | }; 234 | 235 | // Get the port from the arguments 236 | port = process.argv.length >= 3 ? process.argv[2] : 8125; 237 | log = process.argv.length >= 4 ? process.argv[3] : false; 238 | 239 | // Start the server 240 | server = new GuzzleServer(port, log); 241 | server.start(); 242 | -------------------------------------------------------------------------------- /tests/CoreTest.php: -------------------------------------------------------------------------------- 1 | assertNull(Core::header([], 'Foo')); 15 | $this->assertNull(Core::firstHeader([], 'Foo')); 16 | } 17 | 18 | public function testChecksIfHasHeader() 19 | { 20 | $message = [ 21 | 'headers' => [ 22 | 'Foo' => ['Bar', 'Baz'], 23 | 'foo' => ['hello'], 24 | 'bar' => ['1'] 25 | ] 26 | ]; 27 | $this->assertTrue(Core::hasHeader($message, 'Foo')); 28 | $this->assertTrue(Core::hasHeader($message, 'foo')); 29 | $this->assertTrue(Core::hasHeader($message, 'FoO')); 30 | $this->assertTrue(Core::hasHeader($message, 'bar')); 31 | $this->assertFalse(Core::hasHeader($message, 'barr')); 32 | } 33 | 34 | public function testReturnsFirstHeaderWhenSimple() 35 | { 36 | $this->assertEquals('Bar', Core::firstHeader([ 37 | 'headers' => ['Foo' => ['Bar', 'Baz']], 38 | ], 'Foo')); 39 | } 40 | 41 | public function testReturnsFirstHeaderWhenMultiplePerLine() 42 | { 43 | $this->assertEquals('Bar', Core::firstHeader([ 44 | 'headers' => ['Foo' => ['Bar, Baz']], 45 | ], 'Foo')); 46 | } 47 | 48 | public function testExtractsCaseInsensitiveHeader() 49 | { 50 | $this->assertEquals( 51 | 'hello', 52 | Core::header(['headers' => ['foo' => ['hello']]], 'FoO') 53 | ); 54 | } 55 | 56 | public function testExtractsCaseInsensitiveHeaderLines() 57 | { 58 | $this->assertEquals( 59 | ['a', 'b', 'c', 'd'], 60 | Core::headerLines([ 61 | 'headers' => [ 62 | 'foo' => ['a', 'b'], 63 | 'Foo' => ['c', 'd'] 64 | ] 65 | ], 'foo') 66 | ); 67 | } 68 | 69 | public function testExtractsHeaderLines() 70 | { 71 | $this->assertEquals( 72 | ['bar', 'baz'], 73 | Core::headerLines([ 74 | 'headers' => [ 75 | 'Foo' => ['bar', 'baz'], 76 | ], 77 | ], 'Foo') 78 | ); 79 | } 80 | 81 | public function testExtractsHeaderAsString() 82 | { 83 | $this->assertEquals( 84 | 'bar, baz', 85 | Core::header([ 86 | 'headers' => [ 87 | 'Foo' => ['bar', 'baz'], 88 | ], 89 | ], 'Foo', true) 90 | ); 91 | } 92 | 93 | public function testReturnsNullWhenHeaderNotFound() 94 | { 95 | $this->assertNull(Core::header(['headers' => []], 'Foo')); 96 | } 97 | 98 | public function testRemovesHeaders() 99 | { 100 | $message = [ 101 | 'headers' => [ 102 | 'foo' => ['bar'], 103 | 'Foo' => ['bam'], 104 | 'baz' => ['123'], 105 | ], 106 | ]; 107 | 108 | $this->assertSame($message, Core::removeHeader($message, 'bam')); 109 | $this->assertEquals([ 110 | 'headers' => ['baz' => ['123']], 111 | ], Core::removeHeader($message, 'foo')); 112 | } 113 | 114 | public function testCreatesUrl() 115 | { 116 | $req = [ 117 | 'scheme' => 'http', 118 | 'headers' => ['host' => ['foo.com']], 119 | 'uri' => '/', 120 | ]; 121 | 122 | $this->assertEquals('http://foo.com/', Core::url($req)); 123 | } 124 | 125 | /** 126 | * @expectedException \InvalidArgumentException 127 | * @expectedExceptionMessage No Host header was provided 128 | */ 129 | public function testEnsuresHostIsAvailableWhenCreatingUrls() 130 | { 131 | Core::url([]); 132 | } 133 | 134 | public function testCreatesUrlWithQueryString() 135 | { 136 | $req = [ 137 | 'scheme' => 'http', 138 | 'headers' => ['host' => ['foo.com']], 139 | 'uri' => '/', 140 | 'query_string' => 'foo=baz', 141 | ]; 142 | 143 | $this->assertEquals('http://foo.com/?foo=baz', Core::url($req)); 144 | } 145 | 146 | public function testUsesUrlIfSet() 147 | { 148 | $req = ['url' => 'http://foo.com']; 149 | $this->assertEquals('http://foo.com', Core::url($req)); 150 | } 151 | 152 | public function testReturnsNullWhenNoBody() 153 | { 154 | $this->assertNull(Core::body([])); 155 | } 156 | 157 | public function testReturnsStreamAsString() 158 | { 159 | $this->assertEquals( 160 | 'foo', 161 | Core::body(['body' => Stream::factory('foo')]) 162 | ); 163 | } 164 | 165 | public function testReturnsString() 166 | { 167 | $this->assertEquals('foo', Core::body(['body' => 'foo'])); 168 | } 169 | 170 | public function testReturnsResourceContent() 171 | { 172 | $r = fopen('php://memory', 'w+'); 173 | fwrite($r, 'foo'); 174 | rewind($r); 175 | $this->assertEquals('foo', Core::body(['body' => $r])); 176 | fclose($r); 177 | } 178 | 179 | public function testReturnsIteratorContent() 180 | { 181 | $a = new \ArrayIterator(['a', 'b', 'cd', '']); 182 | $this->assertEquals('abcd', Core::body(['body' => $a])); 183 | } 184 | 185 | public function testReturnsObjectToString() 186 | { 187 | $this->assertEquals('foo', Core::body(['body' => new StrClass])); 188 | } 189 | 190 | /** 191 | * @expectedException \InvalidArgumentException 192 | */ 193 | public function testEnsuresBodyIsValid() 194 | { 195 | Core::body(['body' => false]); 196 | } 197 | 198 | public function testParsesHeadersFromLines() 199 | { 200 | $lines = ['Foo: bar', 'Foo: baz', 'Abc: 123', 'Def: a, b']; 201 | $this->assertEquals([ 202 | 'Foo' => ['bar', 'baz'], 203 | 'Abc' => ['123'], 204 | 'Def' => ['a, b'], 205 | ], Core::headersFromLines($lines)); 206 | } 207 | 208 | public function testParsesHeadersFromLinesWithMultipleLines() 209 | { 210 | $lines = ['Foo: bar', 'Foo: baz', 'Foo: 123']; 211 | $this->assertEquals([ 212 | 'Foo' => ['bar', 'baz', '123'], 213 | ], Core::headersFromLines($lines)); 214 | } 215 | 216 | public function testCreatesArrayCallFunctions() 217 | { 218 | $called = []; 219 | $a = function ($a, $b) use (&$called) { 220 | $called['a'] = func_get_args(); 221 | }; 222 | $b = function ($a, $b) use (&$called) { 223 | $called['b'] = func_get_args(); 224 | }; 225 | $c = Core::callArray([$a, $b]); 226 | $c(1, 2); 227 | $this->assertEquals([1, 2], $called['a']); 228 | $this->assertEquals([1, 2], $called['b']); 229 | } 230 | 231 | public function testRewindsGuzzleStreams() 232 | { 233 | $str = Stream::factory('foo'); 234 | $this->assertTrue(Core::rewindBody(['body' => $str])); 235 | } 236 | 237 | public function testRewindsStreams() 238 | { 239 | $str = Stream::factory('foo')->detach(); 240 | $this->assertTrue(Core::rewindBody(['body' => $str])); 241 | } 242 | 243 | public function testRewindsIterators() 244 | { 245 | $iter = new \ArrayIterator(['foo']); 246 | $this->assertTrue(Core::rewindBody(['body' => $iter])); 247 | } 248 | 249 | public function testRewindsStrings() 250 | { 251 | $this->assertTrue(Core::rewindBody(['body' => 'hi'])); 252 | } 253 | 254 | public function testRewindsToStrings() 255 | { 256 | $this->assertTrue(Core::rewindBody(['body' => new StrClass()])); 257 | } 258 | 259 | public function typeProvider() 260 | { 261 | return [ 262 | ['foo', 'string(3) "foo"'], 263 | [true, 'bool(true)'], 264 | [false, 'bool(false)'], 265 | [10, 'int(10)'], 266 | [1.0, 'float(1)'], 267 | [new StrClass(), 'object(GuzzleHttp\Tests\Ring\StrClass)'], 268 | [['foo'], 'array(1)'] 269 | ]; 270 | } 271 | 272 | /** 273 | * @dataProvider typeProvider 274 | */ 275 | public function testDescribesType($input, $output) 276 | { 277 | $this->assertEquals($output, Core::describeType($input)); 278 | } 279 | 280 | public function testDoesSleep() 281 | { 282 | $t = microtime(true); 283 | $expected = $t + (100 / 1000); 284 | Core::doSleep(['client' => ['delay' => 100]]); 285 | $this->assertGreaterThanOrEqual($expected, microtime(true)); 286 | } 287 | 288 | public function testProxiesFuture() 289 | { 290 | $f = new CompletedFutureArray(['status' => 200]); 291 | $res = null; 292 | $proxied = Core::proxy($f, function ($value) use (&$res) { 293 | $value['foo'] = 'bar'; 294 | $res = $value; 295 | return $value; 296 | }); 297 | $this->assertNotSame($f, $proxied); 298 | $this->assertEquals(200, $f->wait()['status']); 299 | $this->assertArrayNotHasKey('foo', $f->wait()); 300 | $this->assertEquals('bar', $proxied->wait()['foo']); 301 | $this->assertEquals(200, $proxied->wait()['status']); 302 | } 303 | 304 | public function testProxiesDeferredFuture() 305 | { 306 | $d = new Deferred(); 307 | $f = new FutureArray($d->promise()); 308 | $f2 = Core::proxy($f); 309 | $d->resolve(['foo' => 'bar']); 310 | $this->assertEquals('bar', $f['foo']); 311 | $this->assertEquals('bar', $f2['foo']); 312 | } 313 | 314 | public function testProxiesDeferredFutureFailure() 315 | { 316 | $d = new Deferred(); 317 | $f = new FutureArray($d->promise()); 318 | $f2 = Core::proxy($f); 319 | $d->reject(new \Exception('foo')); 320 | try { 321 | $f2['hello?']; 322 | $this->fail('did not throw'); 323 | } catch (\Exception $e) { 324 | $this->assertEquals('foo', $e->getMessage()); 325 | } 326 | 327 | } 328 | } 329 | 330 | final class StrClass 331 | { 332 | public function __toString() 333 | { 334 | return 'foo'; 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /tests/Future/CompletedFutureArrayTest.php: -------------------------------------------------------------------------------- 1 | 'bar']); 11 | $this->assertEquals('bar', $f['foo']); 12 | $this->assertFalse(isset($f['baz'])); 13 | $f['abc'] = '123'; 14 | $this->assertTrue(isset($f['abc'])); 15 | $this->assertEquals(['foo' => 'bar', 'abc' => '123'], iterator_to_array($f)); 16 | $this->assertEquals(2, count($f)); 17 | unset($f['abc']); 18 | $this->assertEquals(1, count($f)); 19 | $this->assertEquals(['foo' => 'bar'], iterator_to_array($f)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Future/CompletedFutureValueTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('hi', $f->wait()); 13 | $f->cancel(); 14 | 15 | $a = null; 16 | $f->then(function ($v) use (&$a) { 17 | $a = $v; 18 | }); 19 | $this->assertSame('hi', $a); 20 | } 21 | 22 | public function testThrows() 23 | { 24 | $ex = new \Exception('foo'); 25 | $f = new CompletedFutureValue(null, $ex); 26 | $f->cancel(); 27 | try { 28 | $f->wait(); 29 | $this->fail('did not throw'); 30 | } catch (\Exception $e) { 31 | $this->assertSame($e, $ex); 32 | } 33 | } 34 | 35 | public function testMarksAsCancelled() 36 | { 37 | $ex = new CancelledFutureAccessException(); 38 | $f = new CompletedFutureValue(null, $ex); 39 | try { 40 | $f->wait(); 41 | $this->fail('did not throw'); 42 | } catch (\Exception $e) { 43 | $this->assertSame($e, $ex); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Future/FutureArrayTest.php: -------------------------------------------------------------------------------- 1 | promise(), 15 | function () use (&$c, $deferred) { 16 | $c = true; 17 | $deferred->resolve(['status' => 200]); 18 | } 19 | ); 20 | $this->assertFalse($c); 21 | $this->assertFalse($this->readAttribute($f, 'isRealized')); 22 | $this->assertEquals(200, $f['status']); 23 | $this->assertTrue($c); 24 | } 25 | 26 | public function testActsLikeArray() 27 | { 28 | $deferred = new Deferred(); 29 | $f = new FutureArray( 30 | $deferred->promise(), 31 | function () use (&$c, $deferred) { 32 | $deferred->resolve(['status' => 200]); 33 | } 34 | ); 35 | 36 | $this->assertTrue(isset($f['status'])); 37 | $this->assertEquals(200, $f['status']); 38 | $this->assertEquals(['status' => 200], $f->wait()); 39 | $this->assertEquals(1, count($f)); 40 | $f['baz'] = 10; 41 | $this->assertEquals(10, $f['baz']); 42 | unset($f['baz']); 43 | $this->assertFalse(isset($f['baz'])); 44 | $this->assertEquals(['status' => 200], iterator_to_array($f)); 45 | } 46 | 47 | /** 48 | * @expectedException \RuntimeException 49 | */ 50 | public function testThrowsWhenAccessingInvalidProperty() 51 | { 52 | $deferred = new Deferred(); 53 | $f = new FutureArray($deferred->promise(), function () {}); 54 | $f->foo; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/Future/FutureValueTest.php: -------------------------------------------------------------------------------- 1 | promise(), 17 | function () use ($deferred, &$called) { 18 | $called++; 19 | $deferred->resolve('foo'); 20 | } 21 | ); 22 | 23 | $this->assertEquals('foo', $f->wait()); 24 | $this->assertEquals(1, $called); 25 | $this->assertEquals('foo', $f->wait()); 26 | $this->assertEquals(1, $called); 27 | $f->cancel(); 28 | $this->assertTrue($this->readAttribute($f, 'isRealized')); 29 | } 30 | 31 | /** 32 | * @expectedException \GuzzleHttp\Ring\Exception\CancelledFutureAccessException 33 | */ 34 | public function testThrowsWhenAccessingCancelled() 35 | { 36 | $f = new FutureValue( 37 | (new Deferred())->promise(), 38 | function () {}, 39 | function () { return true; } 40 | ); 41 | $f->cancel(); 42 | $f->wait(); 43 | } 44 | 45 | /** 46 | * @expectedException \OutOfBoundsException 47 | */ 48 | public function testThrowsWhenDerefFailure() 49 | { 50 | $called = false; 51 | $deferred = new Deferred(); 52 | $f = new FutureValue( 53 | $deferred->promise(), 54 | function () use(&$called) { 55 | $called = true; 56 | } 57 | ); 58 | $deferred->reject(new \OutOfBoundsException()); 59 | $f->wait(); 60 | $this->assertFalse($called); 61 | } 62 | 63 | /** 64 | * @expectedException \GuzzleHttp\Ring\Exception\RingException 65 | * @expectedExceptionMessage Waiting did not resolve future 66 | */ 67 | public function testThrowsWhenDerefDoesNotResolve() 68 | { 69 | $deferred = new Deferred(); 70 | $f = new FutureValue( 71 | $deferred->promise(), 72 | function () use(&$called) { 73 | $called = true; 74 | } 75 | ); 76 | $f->wait(); 77 | } 78 | 79 | public function testThrowingCancelledFutureAccessExceptionCancels() 80 | { 81 | $deferred = new Deferred(); 82 | $f = new FutureValue( 83 | $deferred->promise(), 84 | function () use ($deferred) { 85 | throw new CancelledFutureAccessException(); 86 | } 87 | ); 88 | try { 89 | $f->wait(); 90 | $this->fail('did not throw'); 91 | } catch (CancelledFutureAccessException $e) {} 92 | } 93 | 94 | /** 95 | * @expectedException \Exception 96 | * @expectedExceptionMessage foo 97 | */ 98 | public function testThrowingExceptionInDerefMarksAsFailed() 99 | { 100 | $deferred = new Deferred(); 101 | $f = new FutureValue( 102 | $deferred->promise(), 103 | function () { 104 | throw new \Exception('foo'); 105 | } 106 | ); 107 | $f->wait(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |