diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6a92c81 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +#trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3a1a9a7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto + +/examples/ export-ignore +/tests/ export-ignore +/build.sh export-ignore +/.editorconfig export-ignore +/.scrutinizer.yml export-ignore +/.styleci.yml export-ignore +/.gitattributes export-ignore +/.github/ export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/circle.yml export-ignore +/phpcs.php_cs export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..d6b098d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# How to Contribute + +## Pull Requests + +1. Create your own [fork](https://help.github.com/articles/fork-a-repo) of this repo +2. Create a new branch for each feature or improvement +3. Send a pull request from each feature branch to the **master** branch + +It is very important to separate new features or improvements into separate +feature branches, and to send a pull request for each branch. This allows me to +review and pull in new features or improvements individually. + +## Style Guide + +All pull requests must adhere to the [PSR-2 standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md). + +## Unit Testing + +All pull requests must be accompanied by passing PHPUnit unit tests and +complete code coverage. + +[Learn about PHPUnit](https://github.com/sebastianbergmann/phpunit/) \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e7ce193 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +github: [voku] +patreon: voku +open_collective: anti-xss +tidelift: "packagist/voku/httpful" +custom: https://www.paypal.me/moelleken diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..4aa4367 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,7 @@ +#### What is this feature about (expected vs actual behaviour)? + +#### How can I reproduce it? + +#### Does it take minutes, hours or days to fix? + +#### Any additional information? \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..04a0e4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master + +defaults: + run: + shell: bash + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '7.4' + - '8.0' + - '8.1' + - '8.2' + composer: [basic] + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@2.9.0 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + extensions: zip + tools: composer + + - name: Determine composer cache directory + id: composer-cache + run: echo "::set-output name=directory::$(composer config cache-dir)" + + - name: Cache composer dependencies + uses: actions/cache@v2.1.3 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ matrix.php }}-composer- + + - name: Install dependencies + run: | + if [[ "${{ matrix.php }}" == "7.4" ]]; then + composer require phpstan/phpstan --no-update + fi; + + if [[ "${{ matrix.composer }}" == "lowest" ]]; then + composer update --prefer-dist --no-interaction --prefer-lowest --prefer-stable + fi; + + if [[ "${{ matrix.composer }}" == "basic" ]]; then + composer update --prefer-dist --no-interaction + fi; + + composer dump-autoload -o + + - name: Run tests + run: | + mkdir -p build/logs + php vendor/bin/phpunit -c phpunit.xml.dist --coverage-clover=build/logs/clover.xml + + - name: Run phpstan + continue-on-error: true + if: ${{ matrix.php == '7.4' }} + run: | + php vendor/bin/phpstan analyse + + - name: Upload coverage results to Coveralls + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + composer global require php-coveralls/php-coveralls + php-coveralls --coverage_clover=build/logs/clover.xml -v + + - name: Upload coverage results to Codecov + uses: codecov/codecov-action@v1 + with: + files: build/logs/clover.xml + + - name: Archive logs artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@v2 + with: + name: logs_composer-${{ matrix.composer }}_php-${{ matrix.php }} + path: | + build/logs diff --git a/.gitignore b/.gitignore index 6cf3a90..1b59613 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,16 @@ .DS_Store -composer.lock -vendor downloads -.idea/* + +# Tests +server.log +.phpunit.result.cache + +# Build +/build/ + +# IDE +/.idea + +# Composer +vendor +composer.lock \ No newline at end of file diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..c71272d --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,3 @@ +tools: + external_code_coverage: + timeout: 800 diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..8f7c875 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,10 @@ +preset: psr2 + +enabled: + - unused_use + - include + - single_quote + - ordered_use + +disabled: + - indentation diff --git a/.travis.yml b/.travis.yml index d962e3a..85a436b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,27 @@ language: php +sudo: false + php: - - 5.3 - - 5.4 - - 5.5 - - 5.6 - 7.0 - - hhvm - -matrix: - fast_finish: true - allow_failures: - - php: 7.0 - + - 7.1 + - 7.2 + - 7.3 + +before_script: + - wget https://scrutinizer-ci.com/ocular.phar + - travis_retry composer self-update + - travis_retry composer require satooshi/php-coveralls + - if [ "$(phpenv version-name)" == 7.3 ]; then travis_retry composer require phpstan/phpstan; fi + - travis_retry composer install --no-interaction --prefer-source + - composer dump-autoload -o + script: - - phpunit -c ./tests/phpunit.xml + - mkdir -p build/logs + - php vendor/bin/phpunit -c phpunit.xml.dist + - if [ "$(phpenv version-name)" == 7.3 ]; then php vendor/bin/phpstan analyse; fi + +after_script: + - php vendor/bin/coveralls -v + - php ocular.phar code-coverage:upload --format=php-clover build/logs/clover.xml + - bash <(curl -s https://codecov.io/bash) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5321352 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,301 @@ +# Changelog + +## 3.0.1 (2023-07-22) + +- "composer.json" -> provide "psr/http-factory-implementation" + +## 3.0.0 (2023-07-20) + +- minimal PHP version 7.4 +- allow to use "psr/http-message" 2.0.* +- allow to use "psr/log" 2.0.* || 3.0.* + +breaking change: +- "Response->hasBody()" was fixed, now it will return `false` for an empty body +- "Request->getUri()" now always returns an `UriInterface` , if we need the old behaviors, use can use "Request->getUriOrNull()" +- "Stream->getContents()" now always returns a `string`, if we need the old behaviors, use can use "Stream->getContentsUnserialized()" +- "psr/http-message" v2 has return types, so you need to use them too, if you extend one of this classes + +## 2.4.9 (2023-07-15) + +- use "ReturnTypeWillChange" to ignore return type changes from PHP >= 8.1 + +## 2.4.8 (2023-07-14) + +- update dependencies "httplug / http-message" + +## 2.4.7 (2021-12-08) + +- update "portable-utf8" + +## 2.4.6 (2021-10-15) + +- fix file upload + +## 2.4.5 (2021-09-14) + +- "XmlMimeHandler" -> show the broken xml + +## 2.4.4 (2021-09-09) + +- fixes for phpdoc only + +## 2.4.3 (2021-04-07) + +- fix for old PHP versions +- use Github Actions + +## 2.4.2 (2020-11-18) + +- update vendor stuff + fix tests + +## 2.4.1 (2020-05-04) + +- "Client->download()" -> added timeout parameter +- "HtmlMimeHandler" -> fix non UTF-8 string input +- "Response" -> fix Header-Parsing for empty responses + +## 2.4.0 (2020-03-06) + +- add "Request->withPort(int $port)" +- fix "Request Body Not Preserved" #7 + +## 2.3.2 (2020-02-29) + +- "ClientMulti" -> add "add_html()" + +## 2.3.1 (2020-02-29) + +- merge upstream fixes from https://github.com/php-curl-class/php-curl-class/ + +## 2.3.0 (2020-01-28) + +- optimize "RequestInterface"-integration + +## 2.2.0 (2020-01-28) + +- add "ClientPromise" (\Http\Client\HttpAsyncClient) + +## 2.1.0 (2019-12-19) + +- return $this for many methods from "Curl" & "MultiCurl" +- optimize the speed of "MultiCurl" +- use phpstan (0.12) + add more phpdocs + +## 2.0.0 (2019-11-15) + +- add $params for "GET" / "DELETE" requests +- free some more memory +- more helpfully exception messages +- fixes callbacks for "ClientMulti" + +## 1.0.0 (2019-11-13) + + - fix all bugs reported by phpstan + - clean-up dependencies + - fix async support for POST data + +## 0.10.0 (2019-11-12) + + - add support for async requests via CurlMulti + +## 0.9.0 (2019-07-16) + + - add new header functions + many tests + +## 0.8.0 (2019-07-06) + + - fix implementation of PSR standards + many tests + +## 0.7.1 (2019-05-01) + + - fix "addHeaders()" + +## 0.7.0 (2019-04-30) + + - fix return types of "Handlers" + - add more helper functions for "Client" (with auto-completion via phpdoc) + +## 0.6.0 (2019-04-30) + + - make more properties private && classes final v2 + - fix array usage with "Stream" + - move "Request->init" into the "__constructor" + - rename some internal classes + methods + +## 0.5.0 (2019-04-29) + + - FEATURE Add "PSR-3" logging + - FEATURE Add "PSR-18" HTTP Client - "\Httpful\Client" + - FEATURE Add "PSR-7" - RequestInterface && ResponseInterface + - fix issues reported by phpstan (level 7) + - make properties private && classes final v1 + +## 0.4.x + + - update vendor + - fix return types + +## 0.3.x + + - drop support for < PHP7 + - use return types + +## 0.2.x + + - "Add convenience methods for appending parameters to query string." [PR #65](https://github.com/nategood/httpful/pull/65) + - "Give more information to the Exception object to enable better error handling" [PR #117](https://github.com/nategood/httpful/pull/117) + - "Solves issue #170: HTTP Header parsing is inconsistent" [PR #182](https://github.com/nategood/httpful/pull/182) + - "added support for http_proxy environment variable" [PR #183](https://github.com/nategood/httpful/pull/183) + - "Fix for frameworks that use object proxies" + fixes phpdoc [PR #205](https://github.com/nategood/httpful/pull/205) + - "ConnectionErrorException cURLError" [PR #207](https://github.com/nategood/httpful/pull/208) + - "Added explicit support for expectsXXX" [PR #210](https://github.com/nategood/httpful/pull/210) + - "Add connection timeout" [PR #215](https://github.com/nategood/httpful/pull/215) + - use "portable-utf8" [voku](https://github.com/voku/httpful/commit/3b4b36bd65bdecd0dafaa7ace336ac9f629a0e5a) + - fixed code-style / added php-docs / added "alias"-methods ... [voku](https://github.com/voku/httpful/commit/3b82723609d5decc6521b94d336f090bc9d764e3) + +## 0.2.20 + + - MINOR Move Response building logic into separate function [PR #193](https://github.com/nategood/httpful/pull/193) + +## 0.2.19 + + - FEATURE Before send hook [PR #164](https://github.com/nategood/httpful/pull/164) + - MINOR More descriptive connection exceptions [PR #166](https://github.com/nategood/httpful/pull/166) + +## 0.2.18 + + - FIX [PR #149](https://github.com/nategood/httpful/pull/149) + - FIX [PR #150](https://github.com/nategood/httpful/pull/150) + - FIX [PR #156](https://github.com/nategood/httpful/pull/156) + +## 0.2.17 + + - FEATURE [PR #144](https://github.com/nategood/httpful/pull/144) Adds additional parameter to the Response class to specify additional meta data about the request/response (e.g. number of redirect). + +## 0.2.16 + + - FEATURE Added support for whenError to define a custom callback to be fired upon error. Useful for logging or overriding the default error_log behavior. + +## 0.2.15 + + - FEATURE [I #131](https://github.com/nategood/httpful/pull/131) Support for SOCKS proxy + +## 0.2.14 + + - FEATURE [I #138](https://github.com/nategood/httpful/pull/138) Added alternative option for XML request construction. In the next major release this will likely supplant the older version. + +## 0.2.13 + + - REFACTOR [I #121](https://github.com/nategood/httpful/pull/121) Throw more descriptive exception on curl errors + - REFACTOR [I #122](https://github.com/nategood/httpful/issues/122) Better proxy scrubbing in Request + - REFACTOR [I #119](https://github.com/nategood/httpful/issues/119) Better document the mimeType param on Request::body + - Misc code and test cleanup + +## 0.2.12 + + - REFACTOR [I #123](https://github.com/nategood/httpful/pull/123) Support new curl file upload method + - FEATURE [I #118](https://github.com/nategood/httpful/pull/118) 5.4 HTTP Test Server + - FIX [I #109](https://github.com/nategood/httpful/pull/109) Typo + - FIX [I #103](https://github.com/nategood/httpful/pull/103) Handle also CURLOPT_SSL_VERIFYHOST for strictSsl mode + +## 0.2.11 + + - FIX [I #99](https://github.com/nategood/httpful/pull/99) Prevent hanging on HEAD requests + +## 0.2.10 + + - FIX [I #93](https://github.com/nategood/httpful/pull/86) Fixes edge case where content-length would be set incorrectly + +## 0.2.9 + + - FEATURE [I #89](https://github.com/nategood/httpful/pull/89) multipart/form-data support (a.k.a. file uploads)! Thanks @dtelaroli! + +## 0.2.8 + + - FIX Notice fix for Pull Request 86 + +## 0.2.7 + + - FIX [I #86](https://github.com/nategood/httpful/pull/86) Remove Connection Established header when using a proxy + +## 0.2.6 + + - FIX [I #85](https://github.com/nategood/httpful/issues/85) Empty Content Length issue resolved + +## 0.2.5 + + - FEATURE [I #80](https://github.com/nategood/httpful/issues/80) [I #81](https://github.com/nategood/httpful/issues/81) Proxy support added with `useProxy` method. + +## 0.2.4 + + - FEATURE [I #77](https://github.com/nategood/httpful/issues/77) Convenience method for setting a timeout (seconds) `$req->timeoutIn(10);` + - FIX [I #75](https://github.com/nategood/httpful/issues/75) [I #78](https://github.com/nategood/httpful/issues/78) Bug with checking if digest auth is being used. + +## 0.2.3 + + - FIX Overriding default Mime Handlers + - FIX [PR #73](https://github.com/nategood/httpful/pull/73) Parsing http status codes + +## 0.2.2 + + - FEATURE Add support for parsing JSON responses as associative arrays instead of objects + - FEATURE Better support for setting constructor arguments on Mime Handlers + +## 0.2.1 + + - FEATURE [PR #72](https://github.com/nategood/httpful/pull/72) Allow support for custom Accept header + +## 0.2.0 + + - REFACTOR [PR #49](https://github.com/nategood/httpful/pull/49) Broke headers out into their own class + - REFACTOR [PR #54](https://github.com/nategood/httpful/pull/54) Added more specific Exceptions + - FIX [PR #58](https://github.com/nategood/httpful/pull/58) Fixes throwing an error on an empty xml response + - FEATURE [PR #57](https://github.com/nategood/httpful/pull/57) Adds support for digest authentication + +## 0.1.6 + + - Ability to set the number of max redirects via overloading `followRedirects(int max_redirects)` + - Standards Compliant fix to `Accepts` header + - Bug fix for bootstrap process when installed via Composer + +## 0.1.5 + + - Use `DIRECTORY_SEPARATOR` constant [PR #33](https://github.com/nategood/httpful/pull/32) + - [PR #35](https://github.com/nategood/httpful/pull/35) + - Added the raw\_headers property reference to response. + - Compose request header and added raw\_header to Request object. + - Fixed response has errors and added more comments for clarity. + - Fixed header parsing to allow the minimum (status line only) and also cater for the actual CRLF ended headers as per RFC2616. + - Added the perfect test Accept: header for all Acceptable scenarios see @b78e9e82cd9614fbe137c01bde9439c4e16ca323 for details. + - Added default User-Agent header + - `User-Agent: Httpful/0.1.5` + curl version + server software + PHP version + - To bypass this "default" operation simply add a User-Agent to the request headers even a blank User-Agent is sufficient and more than simple enough to produce me thinks. + - Completed test units for additions. + - Added phpunit coverage reporting and helped phpunit auto locate the tests a bit easier. + +## 0.1.4 + + - Add support for CSV Handling [PR #32](https://github.com/nategood/httpful/pull/32) + +## 0.1.3 + + - Handle empty responses in JsonParser and XmlParser + +## 0.1.2 + + - Added support for setting XMLHandler configuration options + - Added examples for overriding XmlHandler and registering a custom parser + - Removed the httpful.php download (deprecated in favor of httpful.phar) + +## 0.1.1 + + - Bug fix serialization default case and phpunit tests + +## 0.1.0 + + - Added Support for Registering Mime Handlers + - Created AbstractMimeHandler type that all Mime Handlers must extend + - Pulled out the parsing/serializing logic from the Request/Response classes into their own MimeHandler classes + - Added ability to register new mime handlers for mime types + diff --git a/README.md b/README.md index 3dfa25a..27eaa7f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ -# Httpful +[![Build Status](https://github.com/voku/httpful/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/voku/httpful/actions) +[![codecov.io](https://codecov.io/github/voku/httpful/coverage.svg?branch=master)](https://codecov.io/github/voku/httpful?branch=master) +[![Codacy Badge](https://api.codacy.com/project/badge/Grade/5882e37a6cd24f6c9d1cf70a08064146)](https://www.codacy.com/app/voku/httpful) +[![Latest Stable Version](https://poser.pugx.org/voku/httpful/v/stable)](https://packagist.org/packages/voku/httpful) +[![Total Downloads](https://poser.pugx.org/voku/httpful/downloads)](https://packagist.org/packages/voku/httpful) +[![License](https://poser.pugx.org/voku/httpful/license)](https://packagist.org/packages/voku/httpful) +[![Donate to this project using Paypal](https://img.shields.io/badge/paypal-donate-yellow.svg)](https://www.paypal.me/moelleken) +[![Donate to this project using Patreon](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://www.patreon.com/voku) -[![Build Status](https://secure.travis-ci.org/nategood/httpful.png?branch=master)](http://travis-ci.org/nategood/httpful) [![Total Downloads](https://poser.pugx.org/nategood/httpful/downloads.png)](https://packagist.org/packages/nategood/httpful) +# 📯 Httpful -[Httpful](http://phphttpclient.com) is a simple Http Client library for PHP 5.3+. There is an emphasis of readability, simplicity, and flexibility – basically provide the features and flexibility to get the job done and make those features really easy to use. +Forked some years ago from [nategood/httpful](https://github.com/nategood/httpful) + added support for parallel request and implemented many PSR Interfaces: A Chainable, REST Friendly Wrapper for cURL with many "PSR-HTTP" implemented interfaces. Features @@ -11,218 +18,140 @@ Features - Automatic "Smart" Parsing - Automatic Payload Serialization - Basic Auth - - Client Side Certificate Auth + - Client Side Certificate Auth (SSL) + - Request "Download" - Request "Templates" + - Parallel Request (via curl_multi) + - PSR-3: Logger Interface + - PSR-7: HTTP Message Interface + - PSR-17: HTTP Factory Interface + - PSR-18: HTTP Client Interface -# Sneak Peak - -Here's something to whet your appetite. Search the twitter API for tweets containing "#PHP". Include a trivial header for the heck of it. Notice that the library automatically interprets the response as JSON (can override this if desired) and parses it as an array of objects. +# Examples ```php +expectsJson() - ->withXTrivialHeader('Just as a demo') - ->send(); - -echo "{$response->body->name} joined GitHub on " . - date('M jS', strtotime($response->body->created_at)) ."\n"; -``` - -# Installation +// Make a request to the GitHub API. -## Phar +$uri = 'https://api.github.com/users/voku'; +$response = \Httpful\Client::get($uri, null, \Httpful\Mime::JSON); -A [PHP Archive](http://php.net/manual/en/book.phar.php) (or .phar) file is available for [downloading](http://phphttpclient.com/downloads/httpful.phar). Simply [download](http://phphttpclient.com/downloads/httpful.phar) the .phar, drop it into your project, and include it like you would any other php file. _This method is ideal for smaller projects, one off scripts, and quick API hacking_. - -```php -include('httpful.phar'); -$r = \Httpful\Request::get($uri)->sendIt(); -... +echo $response->getBody()->name . ' joined GitHub on ' . date('M jS Y', strtotime($response->getBody()->created_at)) . "\n"; ``` -## Composer - -Httpful is PSR-0 compliant and can be installed using [composer](http://getcomposer.org/). Simply add `nategood/httpful` to your composer.json file. _Composer is the sane alternative to PEAR. It is excellent for managing dependencies in larger projects_. - - { - "require": { - "nategood/httpful": "*" - } - } - -## Install from Source - -Because Httpful is PSR-0 compliant, you can also just clone the Httpful repository and use a PSR-0 compatible autoloader to load the library, like [Symfony's](http://symfony.com/doc/current/components/class_loader.html). Alternatively you can use the PSR-0 compliant autoloader included with the Httpful (simply `require("bootstrap.php")`). - -## Build your Phar - -If you want the build your own [Phar Archive](http://php.net/manual/en/book.phar.php) you can use the `build` script included. -Make sure that your `php.ini` has the *Off* or 0 value for the `phar.readonly` setting. -Also you need to create an empty `downloads` directory in the project root. - -# Show Me More! - -You can checkout the [Httpful Landing Page](http://phphttpclient.com) for more info including many examples and [documentation](http://phphttpclient.com/docs). - -# Contributing - -Httpful highly encourages sending in pull requests. When submitting a pull request please: - - - All pull requests should target the `dev` branch (not `master`) - - Make sure your code follows the [coding conventions](http://pear.php.net/manual/en/standards.php) - - Please use soft tabs (four spaces) instead of hard tabs - - Make sure you add appropriate test coverage for your changes - - Run all unit tests in the test directory via `phpunit ./tests` - - Include commenting where appropriate and add a descriptive pull request message - -# Changelog - -## 0.2.20 - - - MINOR Move Response building logic into separate function [PR #193](https://github.com/nategood/httpful/pull/193) - -## 0.2.19 - - - FEATURE Before send hook [PR #164](https://github.com/nategood/httpful/pull/164) - - MINOR More descriptive connection exceptions [PR #166](https://github.com/nategood/httpful/pull/166) - -## 0.2.18 - - - FIX [PR #149](https://github.com/nategood/httpful/pull/149) - - FIX [PR #150](https://github.com/nategood/httpful/pull/150) - - FIX [PR #156](https://github.com/nategood/httpful/pull/156) - -## 0.2.17 - - - FEATURE [PR #144](https://github.com/nategood/httpful/pull/144) Adds additional parameter to the Response class to specify additional meta data about the request/response (e.g. number of redirect). - -## 0.2.16 - - - FEATURE Added support for whenError to define a custom callback to be fired upon error. Useful for logging or overriding the default error_log behavior. - -## 0.2.15 - - - FEATURE [I #131](https://github.com/nategood/httpful/pull/131) Support for SOCKS proxy - -## 0.2.14 - - - FEATURE [I #138](https://github.com/nategood/httpful/pull/138) Added alternative option for XML request construction. In the next major release this will likely supplant the older version. - -## 0.2.13 - - - REFACTOR [I #121](https://github.com/nategood/httpful/pull/121) Throw more descriptive exception on curl errors - - REFACTOR [I #122](https://github.com/nategood/httpful/issues/122) Better proxy scrubbing in Request - - REFACTOR [I #119](https://github.com/nategood/httpful/issues/119) Better document the mimeType param on Request::body - - Misc code and test cleanup - -## 0.2.12 - - - REFACTOR [I #123](https://github.com/nategood/httpful/pull/123) Support new curl file upload method - - FEATURE [I #118](https://github.com/nategood/httpful/pull/118) 5.4 HTTP Test Server - - FIX [I #109](https://github.com/nategood/httpful/pull/109) Typo - - FIX [I #103](https://github.com/nategood/httpful/pull/103) Handle also CURLOPT_SSL_VERIFYHOST for strictSsl mode - -## 0.2.11 - - - FIX [I #99](https://github.com/nategood/httpful/pull/99) Prevent hanging on HEAD requests - -## 0.2.10 - - - FIX [I #93](https://github.com/nategood/httpful/pull/86) Fixes edge case where content-length would be set incorrectly - -## 0.2.9 - - - FEATURE [I #89](https://github.com/nategood/httpful/pull/89) multipart/form-data support (a.k.a. file uploads)! Thanks @dtelaroli! - -## 0.2.8 - - - FIX Notice fix for Pull Request 86 - -## 0.2.7 - - - FIX [I #86](https://github.com/nategood/httpful/pull/86) Remove Connection Established header when using a proxy - -## 0.2.6 +```php +withAddedHeader('X-Foo-Header', 'Just as a demo') + ->expectsJson() + ->send(); - - FEATURE [I #80](https://github.com/nategood/httpful/issues/80) [I #81](https://github.com/nategood/httpful/issues/81) Proxy support added with `useProxy` method. +$result = $response->getRawBody(); -## 0.2.4 +echo $result['name'] . ' joined GitHub on ' . \date('M jS Y', \strtotime($result['created_at'])) . "\n"; +``` - - FEATURE [I #77](https://github.com/nategood/httpful/issues/77) Convenience method for setting a timeout (seconds) `$req->timeoutIn(10);` - - FIX [I #75](https://github.com/nategood/httpful/issues/75) [I #78](https://github.com/nategood/httpful/issues/78) Bug with checking if digest auth is being used. +```php +withUriFromString('https://postman-echo.com/basic-auth') + ->withBasicAuth('postman', 'password'); - - FEATURE Add support for parsing JSON responses as associative arrays instead of objects - - FEATURE Better support for setting constructor arguments on Mime Handlers +$multi->add_request($request); +// $multi->add_request(...); // add more calls here -## 0.2.1 +$multi->start(); - - FEATURE [PR #72](https://github.com/nategood/httpful/pull/72) Allow support for custom Accept header +// DEBUG +//print_r($results); +``` -## 0.2.0 +# Installation - - REFACTOR [PR #49](https://github.com/nategood/httpful/pull/49) Broke headers out into their own class - - REFACTOR [PR #54](https://github.com/nategood/httpful/pull/54) Added more specific Exceptions - - FIX [PR #58](https://github.com/nategood/httpful/pull/58) Fixes throwing an error on an empty xml response - - FEATURE [PR #57](https://github.com/nategood/httpful/pull/57) Adds support for digest authentication +```shell +composer require voku/httpful +``` -## 0.1.6 +## Handlers - - Ability to set the number of max redirects via overloading `followRedirects(int max_redirects)` - - Standards Compliant fix to `Accepts` header - - Bug fix for bootstrap process when installed via Composer +We can override the default parser configuration options be registering +a parser with different configuration options for a particular mime type -## 0.1.5 +Example: setting a namespace for the XMLHandler parser +```php +$conf = ['namespace' => 'http://example.com']; +\Httpful\Setup::registerMimeHandler(\Httpful\Mime::XML, new \Httpful\Handlers\XmlMimeHandler($conf)); +``` - - Use `DIRECTORY_SEPARATOR` constant [PR #33](https://github.com/nategood/httpful/pull/32) - - [PR #35](https://github.com/nategood/httpful/pull/35) - - Added the raw\_headers property reference to response. - - Compose request header and added raw\_header to Request object. - - Fixed response has errors and added more comments for clarity. - - Fixed header parsing to allow the minimum (status line only) and also cater for the actual CRLF ended headers as per RFC2616. - - Added the perfect test Accept: header for all Acceptable scenarios see @b78e9e82cd9614fbe137c01bde9439c4e16ca323 for details. - - Added default User-Agent header - - `User-Agent: Httpful/0.1.5` + curl version + server software + PHP version - - To bypass this "default" operation simply add a User-Agent to the request headers even a blank User-Agent is sufficient and more than simple enough to produce me thinks. - - Completed test units for additions. - - Added phpunit coverage reporting and helped phpunit auto locate the tests a bit easier. +--- -## 0.1.4 +Handlers are simple classes that are used to parse response bodies and serialize request payloads. All Handlers must implement the `MimeHandlerInterface` interface and implement two methods: `serialize($payload)` and `parse($response)`. Let's build a very basic Handler to register for the `text/csv` mime type. - - Add support for CSV Handling [PR #32](https://github.com/nategood/httpful/pull/32) +```php +setStub($stub); -} catch(Exception $e) { - $phar = false; + $phar->setStub($stub); +} catch (Exception $e) { + $phar = false; } + exit_unless($phar, "Unable to create a phar. Make certain you have phar.readonly=0 set in your ini file."); $phar->buildFromDirectory(dirname($source_dir)); echo "[ OK ]\n"; - - // Add it to git! //echo "Adding httpful.phar to the repo... "; //$return_code = 0; diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..c1a71a7 --- /dev/null +++ b/circle.yml @@ -0,0 +1,3 @@ +test: + override: + - php vendor/bin/phpunit -c phpunit.xml.dist diff --git a/composer.json b/composer.json index dd7a549..444d382 100644 --- a/composer.json +++ b/composer.json @@ -1,27 +1,62 @@ { - "name": "nategood/httpful", - "description": "A Readable, Chainable, REST friendly, PHP HTTP Client", - "homepage": "http://github.com/nategood/httpful", - "license": "MIT", - "keywords": ["http", "curl", "rest", "restful", "api", "requests"], - "version": "0.2.20", - "authors": [ - { - "name": "Nate Good", - "email": "me@nategood.com", - "homepage": "http://nategood.com" - } - ], - "require": { - "php": ">=5.3", - "ext-curl": "*" + "name": "voku/httpful", + "description": "A Readable, Chainable, REST friendly, PHP HTTP Client", + "keywords": [ + "http", + "curl", + "rest", + "restful", + "api", + "requests" + ], + "homepage": "https://github.com/voku/httpful", + "license": "MIT", + "authors": [ + { + "name": "Nate Good", + "email": "me@nategood.com", + "homepage": "http://nategood.com" }, - "autoload": { - "psr-0": { - "Httpful": "src/" - } - }, - "require-dev": { - "phpunit/phpunit": "*" + { + "name": "Lars Moelleken", + "email": "lars@moelleken.org", + "homepage": "https://moelleken.org/" + } + ], + "require": { + "php": ">=7.4", + "ext-curl": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-json": "*", + "ext-simplexml": "*", + "ext-xmlwriter": "*", + "php-http/httplug": "2.4.* || 2.3.* || 2.2.* || 2.1.*", + "php-http/promise": "1.1.* || 1.0.*", + "psr/http-client": "1.0.*", + "psr/http-factory": "1.0.*", + "psr/http-message": "2.0.* || 1.1.* || 1.0.*", + "psr/log": "1.1.* || 2.0.* || 3.0.*", + "voku/portable-utf8": "~6.0", + "voku/simple_html_dom": "~4.7" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "provide": { + "php-http/async-client-implementation": "1.0", + "php-http/client-implementation": "1.0", + "psr/http-client-implementation": "1.0", + "psr/http-factory-implementation": "1.0" + }, + "autoload": { + "psr-0": { + "Httpful": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Httpful\\tests\\": "tests/Httpful/" } + } } diff --git a/examples/freebase.php b/examples/freebase.php deleted file mode 100644 index bb3b528..0000000 --- a/examples/freebase.php +++ /dev/null @@ -1,12 +0,0 @@ -expectsJson() - ->sendIt(); - -echo 'The Dead Weather has ' . count($response->body->result->album) . " albums.\n"; \ No newline at end of file diff --git a/examples/github.php b/examples/github.php index 8eb3f3b..155d504 100644 --- a/examples/github.php +++ b/examples/github.php @@ -1,9 +1,17 @@ send(); +declare(strict_types=1); -echo "{$request->body->name} joined GitHub on " . date('M jS', strtotime($request->body->{'created-at'})) ."\n"; \ No newline at end of file +// JSON Example via GitHub-API + +require __DIR__ . '/../vendor/autoload.php'; + +$uri = 'https://api.github.com/users/voku'; +$response = \Httpful\Client::get_request($uri) + ->withHeader('X-Foo-Header', 'Just as a demo') + ->expectsJson() + ->send(); + +$result = $response->getRawBody(); + +echo $result['name'] . ' joined GitHub on ' . \date('M jS Y', \strtotime($result['created_at'])) . "\n"; diff --git a/examples/override.php b/examples/overrideMimeHandler.php similarity index 54% rename from examples/override.php rename to examples/overrideMimeHandler.php index 2c3bdd5..15bd93e 100644 --- a/examples/override.php +++ b/examples/overrideMimeHandler.php @@ -1,26 +1,36 @@ 'http://example.com'); -\Httpful\Httpful::register(\Httpful\Mime::XML, new \Httpful\Handlers\XmlHandler($conf)); +$conf = ['namespace' => 'http://example.com']; +Setup::registerMimeHandler(Mime::XML, new XmlMimeHandler($conf)); + +// We can also add the parsers with our own ... -// We can also add the parsers with our own... -class SimpleCsvHandler extends \Httpful\Handlers\MimeHandlerAdapter +class SimpleCsvMimeHandler extends DefaultMimeHandler { /** * Takes a response body, and turns it into * a two dimensional array. * * @param string $body - * @return mixed + * + * @return array */ public function parse($body) { - return str_getcsv($body); + return \str_getcsv($body); } /** @@ -29,16 +39,20 @@ public function parse($body) * body of a request * * @param mixed $payload + * * @return string */ public function serialize($payload) { + // init $serialized = ''; + foreach ($payload as $line) { - $serialized .= '"' . implode('","', $line) . '"' . "\n"; + $serialized .= '"' . \implode('","', $line) . '"' . "\n"; } + return $serialized; } } -\Httpful\Httpful::register('text/csv', new SimpleCsvHandler()); \ No newline at end of file +Setup::registerMimeHandler(Mime::CSV, new SimpleCsvMimeHandler()); diff --git a/examples/post_form.php b/examples/post_form.php new file mode 100644 index 0000000..c61abdf --- /dev/null +++ b/examples/post_form.php @@ -0,0 +1,46 @@ + 'PHP']); +echo $result['form']['foo1'] . "\n"; // response from postman + +// ------------------- LONG VERSION + +$query = \http_build_query(['foo1' => 'PHP']); +$http = new \Httpful\Factory(); + +$response = (new \Httpful\Client())->sendRequest( + $http->createRequest( + \Httpful\Http::POST, + 'https://postman-echo.com/post', + \Httpful\Mime::FORM, + $query + ) +); +$result = $response->getRawBody(); +echo $result['form']['foo1'] . "\n"; // response from postman + +// ------------------- LONG VERSION + UPLOAD + +$form = ['foo1' => 'PHP']; +$http = new \Httpful\Factory(); + +$filename = __DIR__ . '/../tests/static/test_image.jpg'; + +$response = (new \Httpful\Client())->sendRequest( + $http->createRequest( + \Httpful\Http::POST, + 'https://postman-echo.com/post', + \Httpful\Mime::FORM, + $form + )->withAttachment(['foo2' => $filename]) +); +$result = $response->getRawBody(); +echo $result['form']['foo1'] . "\n"; // response from postman +echo $result['files']['test_image.jpg'] . "\n"; // response from postman diff --git a/examples/scraping_imdb.php b/examples/scraping_imdb.php new file mode 100644 index 0000000..8d352b7 --- /dev/null +++ b/examples/scraping_imdb.php @@ -0,0 +1,36 @@ +expectsHtml() + ->disableStrictSSL() + ->send(); + + /** @var \voku\helper\HtmlDomParser $dom */ + $dom = $response->getRawBody(); + + // get title + $return['Title'] = $dom->find('title', 0)->innertext; + + // get rating + $return['Rating'] = $dom->find('.ratingValue strong', 0)->getAttribute('title'); + + return $return; +} + +// ----------------------------------------------------------------------------- + +$data = scraping_imdb('http://imdb.com/title/tt0335266/'); + +foreach ($data as $k => $v) { + echo '' . $k . ' ' . $v . '
'; +} diff --git a/examples/scraping_multi.php b/examples/scraping_multi.php new file mode 100644 index 0000000..671ea37 --- /dev/null +++ b/examples/scraping_multi.php @@ -0,0 +1,47 @@ +add_html($url); + } + + $promise = $client->getPromise(); + + $return = []; + $promise->then(static function (Httpful\Response $response, Httpful\Request $request) use (&$return) { + /** @var \voku\helper\HtmlDomParser $dom */ + $dom = $response->getRawBody(); + + // get title + $return[] = $dom->find('title', 0)->innertext; + }); + + $promise->wait(); + + return $return; +} + +// ----------------------------------------------------------------------------- + +$data = scraping_multi( + [ + 'https://moelleken.org', + 'https://google.com', + ] +); + +foreach ($data as $title) { + echo '' . $title . '
' . "\n"; +} diff --git a/examples/showclix.php b/examples/showclix.php deleted file mode 100644 index 861537e..0000000 --- a/examples/showclix.php +++ /dev/null @@ -1,24 +0,0 @@ -expectsType('json') - ->send(); - -// Print out the event details -echo "The event {$response->body->event} will take place on {$response->body->event_start}\n"; - -// Example overriding the default JSON handler with one that encodes the response as an array -\Httpful\Httpful::register(\Httpful\Mime::JSON, new \Httpful\Handlers\JsonHandler(array('decode_as_array' => true))); - -$response = Request::get($uri) - ->expectsType('json') - ->send(); - -// Print out the event details -echo "The event {$response->body['event']} will take place on {$response->body['event_start']}\n"; \ No newline at end of file diff --git a/examples/xml.php b/examples/xml.php new file mode 100644 index 0000000..0da689c --- /dev/null +++ b/examples/xml.php @@ -0,0 +1,42 @@ +sendRequest( + ( + new \Httpful\Request( + \Httpful\Http::GET, + Mime::PLAIN + ) + )->followRedirects() + ); + +// ------------------------------------------------------- + +$responseMedium = \Httpful\Client::get_request($uri) + ->withExpectedType(Mime::PLAIN) + ->followRedirects() + ->send(); + +// ------------------------------------------------------- + +$responseSimple = \Httpful\Client::get($uri); + +// ------------------------------------------------------- + +if ( + $responseComplex->getRawBody() === $responseSimple->getRawBody() + && + $responseComplex->getRawBody() === $responseMedium->getRawBody() +) { + echo ' - same output - '; +} diff --git a/phpcs.php_cs b/phpcs.php_cs new file mode 100644 index 0000000..38907f0 --- /dev/null +++ b/phpcs.php_cs @@ -0,0 +1,238 @@ +setUsingCache(false) + ->setRiskyAllowed(true) + ->setRules( + [ + 'align_multiline_comment' => [ + 'comment_type' => 'all_multiline', + ], + 'array_indentation' => true, + 'array_syntax' => [ + 'syntax' => 'short', + ], + 'backtick_to_shell_exec' => true, + 'binary_operator_spaces' => [ + 'operators' => ['=>' => 'align_single_space_minimal'], + ], + 'blank_line_after_namespace' => true, + 'blank_line_after_opening_tag' => false, + 'blank_line_before_statement' => true, + 'braces' => true, + 'cast_spaces' => [ + 'space' => 'single', + ], + 'class_attributes_separation' => true, + 'class_keyword_remove' => false, + 'combine_consecutive_issets' => true, + 'combine_consecutive_unsets' => true, + 'combine_nested_dirname' => true, + // 'compact_nullable_typehint' => true, // PHP >= 7.1 + 'concat_space' => [ + 'spacing' => 'one', + ], + 'date_time_immutable' => false, + 'declare_equal_normalize' => true, + 'declare_strict_types' => true, + 'dir_constant' => true, + 'elseif' => true, + 'encoding' => true, + 'ereg_to_preg' => true, + 'error_suppression' => false, + 'escape_implicit_backslashes' => false, + 'explicit_indirect_variable' => true, + 'explicit_string_variable' => true, + 'final_internal_class' => true, + 'fopen_flag_order' => true, + 'fopen_flags' => true, + 'full_opening_tag' => true, + 'fully_qualified_strict_types' => true, + 'function_declaration' => true, + 'function_to_constant' => true, + 'function_typehint_space' => true, + 'general_phpdoc_annotation_remove' => [ + 'annotations' => [ + 'author', + 'package', + 'version', + ], + ], + 'heredoc_to_nowdoc' => false, + 'implode_call' => false, + 'include' => true, + 'increment_style' => true, + 'indentation_type' => true, + 'line_ending' => true, + 'linebreak_after_opening_tag' => false, + /* // Requires PHP >= 7.1 + 'list_syntax' => [ + 'syntax' => 'short', + ], + */ + 'logical_operators' => true, + 'lowercase_cast' => true, + 'lowercase_constants' => true, + 'lowercase_keywords' => true, + 'lowercase_static_reference' => true, + 'magic_constant_casing' => true, + 'magic_method_casing' => true, + 'method_argument_space' => [ + 'ensure_fully_multiline' => true, + 'keep_multiple_spaces_after_comma' => false, + ], + 'method_chaining_indentation' => true, + 'modernize_types_casting' => true, + 'multiline_comment_opening_closing' => true, + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line', + ], + 'native_constant_invocation' => true, + 'native_function_casing' => true, + 'native_function_invocation' => true, + 'new_with_braces' => true, + 'no_alias_functions' => true, + 'no_alternative_syntax' => true, + 'no_binary_string' => true, + 'no_blank_lines_after_class_opening' => false, + 'no_blank_lines_after_phpdoc' => true, + 'no_blank_lines_before_namespace' => false, + 'no_break_comment' => true, + 'no_closing_tag' => true, + 'no_empty_comment' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => true, + 'no_homoglyph_names' => true, + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_mixed_echo_print' => [ + 'use' => 'echo', + ], + 'no_multiline_whitespace_around_double_arrow' => true, + 'no_null_property_initialization' => true, + 'no_php4_constructor' => true, + 'no_short_bool_cast' => true, + 'no_short_echo_tag' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_spaces_after_function_name' => true, + 'no_spaces_around_offset' => true, + 'no_spaces_inside_parenthesis' => true, + 'no_superfluous_elseif' => true, + 'no_superfluous_phpdoc_tags' => false, // maybe add extra description, so keep it ... + 'no_trailing_comma_in_list_call' => true, + 'no_trailing_comma_in_singleline_array' => true, + 'no_trailing_whitespace' => true, + 'no_trailing_whitespace_in_comment' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unneeded_curly_braces' => true, + 'no_unneeded_final_method' => true, + 'no_unreachable_default_argument_value' => false, // do not changes the logic of the code ... + 'no_unset_on_property' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'no_whitespace_before_comma_in_array' => true, + 'no_whitespace_in_blank_line' => true, + 'non_printable_character' => true, + 'normalize_index_brace' => true, + 'not_operator_with_space' => false, + 'not_operator_with_successor_space' => false, + 'object_operator_without_whitespace' => true, + 'ordered_class_elements' => true, + 'ordered_imports' => true, + 'phpdoc_add_missing_param_annotation' => [ + 'only_untyped' => true, + ], + 'phpdoc_align' => true, + 'phpdoc_annotation_without_dot' => true, + 'phpdoc_indent' => true, + 'phpdoc_inline_tag' => true, + 'phpdoc_no_access' => true, + 'phpdoc_no_alias_tag' => true, + 'phpdoc_no_empty_return' => false, // allow void + 'phpdoc_no_package' => true, + 'phpdoc_no_useless_inheritdoc' => true, + 'phpdoc_order' => true, + 'phpdoc_return_self_reference' => true, + 'phpdoc_scalar' => true, + 'phpdoc_separation' => true, + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_summary' => false, + 'phpdoc_to_comment' => false, + 'phpdoc_to_return_type' => false, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_types' => true, + 'phpdoc_types_order' => [ + 'null_adjustment' => 'always_last', + 'sort_algorithm' => 'alpha', + ], + 'phpdoc_var_without_name' => true, + 'php_unit_construct' => true, + 'php_unit_dedicate_assert' => true, + 'php_unit_expectation' => false, // break old code + 'php_unit_fqcn_annotation' => true, + 'php_unit_internal_class' => true, + 'php_unit_method_casing' => true, + 'php_unit_mock' => true, + 'php_unit_namespaced' => true, + 'php_unit_no_expectation_annotation' => true, + 'php_unit_ordered_covers' => true, + 'php_unit_set_up_tear_down_visibility' => true, + 'php_unit_strict' => false, + 'php_unit_test_annotation' => true, + 'php_unit_test_case_static_method_calls' => true, + 'php_unit_test_class_requires_covers' => false, + 'pow_to_exponentiation' => true, + 'pre_increment' => true, + 'protected_to_private' => true, + 'return_assignment' => true, + 'return_type_declaration' => true, + 'self_accessor' => true, + 'semicolon_after_instruction' => true, + 'set_type_to_cast' => true, + 'short_scalar_cast' => true, + 'silenced_deprecation_error' => false, + 'simplified_null_return' => false, // maybe better for readability, so keep it ... + 'single_blank_line_at_eof' => true, + 'single_class_element_per_statement' => true, + 'single_import_per_statement' => true, + 'single_line_after_imports' => true, + 'single_line_comment_style' => [ + 'comment_types' => ['hash'], + ], + 'single_quote' => true, + 'space_after_semicolon' => true, + 'standardize_increment' => true, + 'standardize_not_equals' => true, + 'static_lambda' => true, + 'strict_comparison' => true, + 'strict_param' => true, + 'string_line_ending' => true, + 'switch_case_semicolon_to_colon' => true, + 'switch_case_space' => true, + 'ternary_operator_spaces' => true, + 'ternary_to_null_coalescing' => true, + 'trailing_comma_in_multiline_array' => true, + 'trim_array_spaces' => true, + 'unary_operator_spaces' => true, + 'visibility_required' => true, + // 'void_return' => true, // PHP >= 7.1 + 'whitespace_after_comma_in_array' => true, + 'yoda_style' => [ + 'equal' => false, + 'identical' => false, + 'less_and_greater' => false, + ], + ] + ) + ->setIndent(" ") + ->setLineEnding("\n") + ->setFinder( + PhpCsFixer\Finder::create() + ->in(['src/', 'tests/', 'examples/']) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true) + ); diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..4a13491 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,19 @@ +parameters: + level: 8 + paths: + - %currentWorkingDirectory%/src/ + reportUnmatchedIgnoredErrors: false + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false + excludePaths: + - %currentWorkingDirectory%/vendor/* + - %currentWorkingDirectory%/tests/* + ignoreErrors: + - '#Unsafe usage of new static#' + - '#should return static#' + - '#function call_user_func expects callable#' + - '#Result of \&\& is always false\.#' + - '#Strict comparison using !== between null and null#' + - '#Strict comparison using === between true and false#' + - '#callback of method Httpful#' + - '#parameters of function call_user_func_array#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..386198f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + tests + + + + ./src/ + + + + + + + + + + + + diff --git a/src/Httpful/Bootstrap.php b/src/Httpful/Bootstrap.php deleted file mode 100644 index 9974bcf..0000000 --- a/src/Httpful/Bootstrap.php +++ /dev/null @@ -1,97 +0,0 @@ - - */ -class Bootstrap -{ - - const DIR_GLUE = DIRECTORY_SEPARATOR; - const NS_GLUE = '\\'; - - public static $registered = false; - - /** - * Register the autoloader and any other setup needed - */ - public static function init() - { - spl_autoload_register(array('\Httpful\Bootstrap', 'autoload')); - self::registerHandlers(); - } - - /** - * The autoload magic (PSR-0 style) - * - * @param string $classname - */ - public static function autoload($classname) - { - self::_autoload(dirname(dirname(__FILE__)), $classname); - } - - /** - * Register the autoloader and any other setup needed - */ - public static function pharInit() - { - spl_autoload_register(array('\Httpful\Bootstrap', 'pharAutoload')); - self::registerHandlers(); - } - - /** - * Phar specific autoloader - * - * @param string $classname - */ - public static function pharAutoload($classname) - { - self::_autoload('phar://httpful.phar', $classname); - } - - /** - * @param string $base - * @param string $classname - */ - private static function _autoload($base, $classname) - { - $parts = explode(self::NS_GLUE, $classname); - $path = $base . self::DIR_GLUE . implode(self::DIR_GLUE, $parts) . '.php'; - - if (file_exists($path)) { - require_once($path); - } - } - /** - * Register default mime handlers. Is idempotent. - */ - public static function registerHandlers() - { - if (self::$registered === true) { - return; - } - - // @todo check a conf file to load from that instead of - // hardcoding into the library? - $handlers = array( - \Httpful\Mime::JSON => new \Httpful\Handlers\JsonHandler(), - \Httpful\Mime::XML => new \Httpful\Handlers\XmlHandler(), - \Httpful\Mime::FORM => new \Httpful\Handlers\FormHandler(), - \Httpful\Mime::CSV => new \Httpful\Handlers\CsvHandler(), - ); - - foreach ($handlers as $mime => $handler) { - // Don't overwrite if the handler has already been registered - if (Httpful::hasParserRegistered($mime)) - continue; - Httpful::register($mime, $handler); - } - - self::$registered = true; - } -} diff --git a/src/Httpful/Client.php b/src/Httpful/Client.php new file mode 100644 index 0000000..3e6e32a --- /dev/null +++ b/src/Httpful/Client.php @@ -0,0 +1,304 @@ +send(); + } + + /** + * @param string $uri + * @param array|null $params + * @param string $mime + * + * @return Request + */ + public static function delete_request(string $uri, array $params = null, string $mime = Mime::JSON): Request + { + return Request::delete($uri, $params, $mime); + } + + /** + * @param string $uri + * @param string $file_path + * @param float|int $timeout + * + * @return Response + */ + public static function download(string $uri, $file_path, $timeout = 0): Response + { + $request = Request::download($uri, $file_path); + + if ($timeout > 0) { + $request->withTimeout($timeout) + ->withConnectionTimeoutInSeconds($timeout / 10); + } + + return $request->send(); + } + + /** + * @param string $uri + * @param array|null $params + * @param string|null $mime + * + * @return Response + */ + public static function get(string $uri, array $params = null, $mime = Mime::PLAIN): Response + { + return self::get_request($uri, $params, $mime)->send(); + } + + /** + * @param string $uri + * @param array|null $param + * + * @return \voku\helper\HtmlDomParser|null + */ + public static function get_dom(string $uri, array $param = null) + { + return self::get_request($uri, $param, Mime::HTML)->send()->getRawBody(); + } + + /** + * @param string $uri + * @param array|null $param + * + * @return array + */ + public static function get_form(string $uri, array $param = null): array + { + return self::get_request($uri, $param, Mime::FORM)->send()->getRawBody(); + } + + /** + * @param string $uri + * @param array|null $param + * + * @return mixed + */ + public static function get_json(string $uri, array $param = null) + { + return self::get_request($uri, $param, Mime::JSON)->send()->getRawBody(); + } + + /** + * @param string $uri + * @param array|null $param + * @param string|null $mime + * + * @return Request + */ + public static function get_request(string $uri, array $param = null, $mime = Mime::PLAIN): Request + { + return Request::get($uri, $param, $mime)->followRedirects(); + } + + /** + * @param string $uri + * @param array|null $param + * + * @return \SimpleXMLElement|null + */ + public static function get_xml(string $uri, array $param = null) + { + return self::get_request($uri, $param, Mime::XML)->send()->getRawBody(); + } + + /** + * @param string $uri + * + * @return Response + */ + public static function head(string $uri): Response + { + return self::head_request($uri)->send(); + } + + /** + * @param string $uri + * + * @return Request + */ + public static function head_request(string $uri): Request + { + return Request::head($uri)->followRedirects(); + } + + /** + * @param string $uri + * + * @return Response + */ + public static function options(string $uri): Response + { + return self::options_request($uri)->send(); + } + + /** + * @param string $uri + * + * @return Request + */ + public static function options_request(string $uri): Request + { + return Request::options($uri); + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return Response + */ + public static function patch(string $uri, $payload = null, string $mime = Mime::PLAIN): Response + { + return self::patch_request($uri, $payload, $mime)->send(); + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return Request + */ + public static function patch_request(string $uri, $payload = null, string $mime = Mime::PLAIN): Request + { + return Request::patch($uri, $payload, $mime); + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return Response + */ + public static function post(string $uri, $payload = null, string $mime = Mime::PLAIN): Response + { + return self::post_request($uri, $payload, $mime)->send(); + } + + /** + * @param string $uri + * @param mixed|null $payload + * + * @return \voku\helper\HtmlDomParser|null + */ + public static function post_dom(string $uri, $payload = null) + { + return self::post_request($uri, $payload, Mime::HTML)->send()->getRawBody(); + } + + /** + * @param string $uri + * @param mixed|null $payload + * + * @return array + */ + public static function post_form(string $uri, $payload = null): array + { + return self::post_request($uri, $payload, Mime::FORM)->send()->getRawBody(); + } + + /** + * @param string $uri + * @param mixed|null $payload + * + * @return mixed + */ + public static function post_json(string $uri, $payload = null) + { + return self::post_request($uri, $payload, Mime::JSON)->send()->getRawBody(); + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return Request + */ + public static function post_request(string $uri, $payload = null, string $mime = Mime::PLAIN): Request + { + return Request::post($uri, $payload, $mime)->followRedirects(); + } + + /** + * @param string $uri + * @param mixed|null $payload + * + * @return \SimpleXMLElement|null + */ + public static function post_xml(string $uri, $payload = null) + { + return self::post_request($uri, $payload, Mime::XML)->send()->getRawBody(); + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return Response + */ + public static function put(string $uri, $payload = null, string $mime = Mime::PLAIN): Response + { + return self::put_request($uri, $payload, $mime)->send(); + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return Request + */ + public static function put_request(string $uri, $payload = null, string $mime = Mime::JSON): Request + { + return Request::put($uri, $payload, $mime); + } + + /** + * @param Request|RequestInterface $request + * + * @return Response|ResponseInterface + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + if (!$request instanceof Request) { + /** @noinspection PhpSillyAssignmentInspection - helper for PhpStorm */ + /** @var RequestInterface $request */ + $request = $request; + + /** @var Request $requestNew */ + $requestNew = Request::{$request->getMethod()}($request->getUri()); + $requestNew->withHeaders($request->getHeaders()); + $requestNew->withProtocolVersion($request->getProtocolVersion()); + $requestNew->withBody($request->getBody()); + $requestNew->withRequestTarget($request->getRequestTarget()); + + $request = $requestNew; + } + + return $request->send(); + } +} diff --git a/src/Httpful/ClientMulti.php b/src/Httpful/ClientMulti.php new file mode 100644 index 0000000..ec82173 --- /dev/null +++ b/src/Httpful/ClientMulti.php @@ -0,0 +1,393 @@ +curlMulti = (new Request()) + ->initMulti($onSuccessCallback, $onCompleteCallback); + } + + /** + * @return void + */ + public function start() + { + $this->curlMulti->start(); + } + + /** + * @param string $uri + * @param array|null $params + * @param string $mime + * + * @return $this + */ + public function add_delete(string $uri, array $params = null, string $mime = Mime::JSON) + { + $request = Request::delete($uri, $params, $mime); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param string $file_path + * + * @return $this + */ + public function add_download(string $uri, $file_path) + { + $request = Request::download($uri, $file_path); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param array|null $params + * @param string|null $mime + * + * @return $this + */ + public function add_html(string $uri, array $params = null, $mime = Mime::HTML) + { + $request = Request::get($uri, $params, $mime)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param array|null $params + * @param string|null $mime + * + * @return $this + */ + public function add_get(string $uri, array $params = null, $mime = Mime::PLAIN) + { + $request = Request::get($uri, $params, $mime)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param array|null $params + * + * @return $this + */ + public function add_get_dom(string $uri, array $params = null) + { + $request = Request::get($uri, $params, Mime::HTML)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param array|null $params + * + * @return $this + */ + public function add_get_form(string $uri, array $params = null) + { + $request = Request::get($uri, $params, Mime::FORM)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param array|null $params + * + * @return $this + */ + public function add_get_json(string $uri, array $params = null) + { + $request = Request::get($uri, $params, Mime::JSON)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param array|null $params + * + * @return $this + */ + public function get_xml(string $uri, array $params = null) + { + $request = Request::get($uri, $params, Mime::XML)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * + * @return $this + */ + public function add_head(string $uri) + { + $request = Request::head($uri)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * + * @return $this + */ + public function add_options(string $uri) + { + $request = Request::options($uri); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return $this + */ + public function add_patch(string $uri, $payload = null, string $mime = Mime::PLAIN) + { + $request = Request::patch($uri, $payload, $mime); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return $this + */ + public function add_post(string $uri, $payload = null, string $mime = Mime::PLAIN) + { + $request = Request::post($uri, $payload, $mime)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param mixed|null $payload + * + * @return $this + */ + public function add_post_dom(string $uri, $payload = null) + { + $request = Request::post($uri, $payload, Mime::HTML)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param mixed|null $payload + * + * @return $this + */ + public function add_post_form(string $uri, $payload = null) + { + $request = Request::post($uri, $payload, Mime::FORM)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param mixed|null $payload + * + * @return $this + */ + public function add_post_json(string $uri, $payload = null) + { + $request = Request::post($uri, $payload, Mime::JSON)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param mixed|null $payload + * + * @return $this + */ + public function add_post_xml(string $uri, $payload = null) + { + $request = Request::post($uri, $payload, Mime::XML)->followRedirects(); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param string $uri + * @param mixed|null $payload + * @param string $mime + * + * @return $this + */ + public function add_put(string $uri, $payload = null, string $mime = Mime::PLAIN) + { + $request = Request::put($uri, $payload, $mime); + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } + + /** + * @param Request|RequestInterface $request + * + * @return $this + */ + public function add_request(RequestInterface $request) + { + if (!$request instanceof Request) { + /** @noinspection PhpSillyAssignmentInspection - helper for PhpStorm */ + /** @var RequestInterface $request */ + $request = $request; + + /** @var Request $requestNew */ + $requestNew = Request::{$request->getMethod()}($request->getUri()); + $requestNew->withHeaders($request->getHeaders()); + $requestNew->withProtocolVersion($request->getProtocolVersion()); + $requestNew->withBody($request->getBody()); + $requestNew->withRequestTarget($request->getRequestTarget()); + + $request = $requestNew; + } + + $curl = $request->_curlPrep()->_curl(); + + if ($curl) { + $curl->request = $request; + $this->curlMulti->addCurl($curl); + } + + return $this; + } +} diff --git a/src/Httpful/ClientPromise.php b/src/Httpful/ClientPromise.php new file mode 100644 index 0000000..940ba66 --- /dev/null +++ b/src/Httpful/ClientPromise.php @@ -0,0 +1,44 @@ +curlMulti = (new Request())->initMulti(); + } + + /** + * @return MultiCurlPromise + */ + public function getPromise(): MultiCurlPromise + { + return new MultiCurlPromise($this->curlMulti); + } + + /** + * Sends a PSR-7 request in an asynchronous way. + * + * Exceptions related to processing the request are available from the returned Promise. + * + * @param RequestInterface $request + * + * @return \Http\Promise\Promise resolves a PSR-7 Response or fails with an Http\Client\Exception + */ + public function sendAsyncRequest(RequestInterface $request) + { + $this->add_request($request); + + return $this->getPromise(); + } +} diff --git a/src/Httpful/Curl/Curl.php b/src/Httpful/Curl/Curl.php new file mode 100644 index 0000000..549bc35 --- /dev/null +++ b/src/Httpful/Curl/Curl.php @@ -0,0 +1,1292 @@ +curl = \curl_init(); + $this->initialize($base_url); + } + + public function __destruct() + { + $this->close(); + } + + /** + * @return bool + */ + public function attemptRetry() + { + // init + $attempt_retry = false; + + if ($this->error) { + if ($this->retryDecider === null) { + $attempt_retry = $this->remainingRetries >= 1; + } else { + $attempt_retry = \call_user_func($this->retryDecider, $this); + } + if ($attempt_retry) { + ++$this->retries; + if ($this->remainingRetries) { + --$this->remainingRetries; + } + } + } + + return $attempt_retry; + } + + /** + * @param callable $callback + * + * @return $this + */ + public function beforeSend($callback) + { + $this->beforeSendCallback = $callback; + + return $this; + } + + /** + * @param mixed $function + * + * @return void + */ + + /** + * @param callable|null $function + * @param mixed ...$args + * + * @return $this + */ + public function call($function, ...$args) + { + if (\is_callable($function)) { + \array_unshift($args, $this); + \call_user_func_array($function, $args); + } + + return $this; + } + + /** + * @return void + */ + public function close() + { + if ( + \is_resource($this->curl) + || + (\class_exists('CurlHandle') && $this->curl instanceof \CurlHandle) + ) { + \curl_close($this->curl); + } + } + + /** + * @param callable $callback + * + * @return $this + */ + public function complete($callback) + { + $this->completeCallback = $callback; + + return $this; + } + + /** + * @param callable|string $filename_or_callable + * + * @return $this + */ + public function download($filename_or_callable) + { + // Use tmpfile() or php://temp to avoid "Too many open files" error. + if (\is_callable($filename_or_callable)) { + $this->downloadCompleteCallback = $filename_or_callable; + $this->downloadFileName = null; + $this->fileHandle = \tmpfile(); + } else { + $filename = $filename_or_callable; + + // Use a temporary file when downloading. Not using a temporary file can cause an error when an existing + // file has already fully completed downloading and a new download is started with the same destination save + // path. The download request will include header "Range: bytes=$file_size-" which is syntactically valid, + // but unsatisfiable. + $download_filename = $filename . '.pccdownload'; + $this->downloadFileName = $download_filename; + + // Attempt to resume download only when a temporary download file exists and is not empty. + if ( + \is_file($download_filename) + && + $file_size = \filesize($download_filename) + ) { + $first_byte_position = $file_size; + $range = $first_byte_position . '-'; + $this->setRange($range); + $this->fileHandle = \fopen($download_filename, 'ab'); + } else { + $this->fileHandle = \fopen($download_filename, 'wb'); + } + + // Move the downloaded temporary file to the destination save path. + $this->downloadCompleteCallback = static function ($instance, $fh) use ($download_filename, $filename) { + // Close the open file handle before renaming the file. + if (\is_resource($fh)) { + \fclose($fh); + } + + \rename($download_filename, $filename); + }; + } + + if ($this->fileHandle === false) { + throw new \Httpful\Exception\ClientErrorException('Unable to write to file:' . $this->downloadFileName); + } + + $this->setFile($this->fileHandle); + + return $this; + } + + /** + * @param callable $callback + * + * @return $this + */ + public function error($callback) + { + $this->errorCallback = $callback; + + return $this; + } + + /** + * @param false|\CurlHandle|resource|null $ch + * + * @return mixed returns the value provided by parseResponse + */ + public function exec($ch = null) + { + ++$this->attempts; + + if ($ch === false || $ch === null) { + $this->responseCookies = []; + $this->call($this->beforeSendCallback); + $this->rawResponse = \curl_exec($this->curl); + $this->curlErrorCode = \curl_errno($this->curl); + $this->curlErrorMessage = \curl_error($this->curl); + } else { + $this->rawResponse = \curl_multi_getcontent($ch); + $this->curlErrorMessage = \curl_error($ch); + } + + $this->curlError = $this->curlErrorCode !== 0; + + // Transfer the header callback data and release the temporary store to avoid memory leak. + if ($this->headerCallbackData === null) { + $this->headerCallbackData = new \stdClass(); + } + $this->rawResponseHeaders = $this->headerCallbackData->rawResponseHeaders; + $this->responseCookies = $this->headerCallbackData->responseCookies; + $this->headerCallbackData->rawResponseHeaders = ''; + $this->headerCallbackData->responseCookies = []; + + // Include additional error code information in error message when possible. + if ($this->curlError && \function_exists('curl_strerror')) { + $this->curlErrorMessage = \curl_strerror($this->curlErrorCode) . (empty($this->curlErrorMessage) ? '' : ': ' . $this->curlErrorMessage); + } + + $this->httpStatusCode = $this->getInfo(\CURLINFO_HTTP_CODE); + $this->httpError = \in_array((int) \floor($this->httpStatusCode / 100), [4, 5], true); + $this->error = $this->curlError || $this->httpError; + /** @noinspection NestedTernaryOperatorInspection */ + $this->errorCode = $this->error ? ($this->curlError ? $this->curlErrorCode : $this->httpStatusCode) : 0; + $this->errorMessage = $this->curlError ? $this->curlErrorMessage : ''; + + // Reset nobody setting possibly set from a HEAD request. + $this->setOpt(\CURLOPT_NOBODY, false); + + // Allow multicurl to attempt retry as needed. + if ($this->isChildOfMultiCurl()) { + /** @noinspection PhpInconsistentReturnPointsInspection */ + return; + } + + if ($this->attemptRetry()) { + return $this->exec($ch); + } + + $this->execDone(); + + return $this->rawResponse; + } + + /** + * @return void + */ + public function execDone() + { + if ($this->error) { + $this->call($this->errorCallback); + } else { + $this->call($this->successCallback); + } + + $this->call($this->completeCallback); + + // Close open file handles and reset the curl instance. + if (\is_resource($this->fileHandle)) { + $this->downloadComplete($this->fileHandle); + } + + // Free some memory + help the GC to free some more memory. + if ($this->request instanceof Request) { + $this->request->clearHelperData(); + } + + $this->request = null; + } + + /** + * @return int + */ + public function getAttempts() + { + return $this->attempts; + } + + /** + * @return callable|null + */ + public function getBeforeSendCallback() + { + return $this->beforeSendCallback; + } + + /** + * @return callable|null + */ + public function getCompleteCallback() + { + return $this->completeCallback; + } + + /** + * @param string $key + * + * @return mixed + */ + public function getCookie($key) + { + return $this->getResponseCookie($key); + } + + /** + * @return false|resource|\CurlHandle + */ + public function getCurl() + { + return $this->curl; + } + + /** + * @return int + */ + public function getCurlErrorCode() + { + return $this->curlErrorCode; + } + + /** + * @return string|null + */ + public function getCurlErrorMessage() + { + return $this->curlErrorMessage; + } + + /** + * @return callable|null + */ + public function getDownloadCompleteCallback() + { + return $this->downloadCompleteCallback; + } + + /** + * @return string|null + */ + public function getDownloadFileName() + { + return $this->downloadFileName; + } + + /** + * @return callable|null + */ + public function getErrorCallback() + { + return $this->errorCallback; + } + + /** + * @return int + */ + public function getErrorCode() + { + return $this->errorCode; + } + + /** + * @return string|null + */ + public function getErrorMessage() + { + return $this->errorMessage; + } + + /** + * @return false|resource|null + */ + public function getFileHandle() + { + return $this->fileHandle; + } + + /** + * @return int + */ + public function getHttpStatusCode() + { + return $this->httpStatusCode; + } + + /** + * @return int|string|null + */ + public function getId() + { + return $this->id; + } + + /** + * @param int|null $opt + * + * @return mixed + */ + public function getInfo($opt = null) + { + $args = []; + $args[] = $this->curl; + + if (\func_num_args()) { + $args[] = $opt; + } + + return \curl_getinfo(...$args); + } + + /** + * @return null|bool|string + */ + public function getRawResponse() + { + return $this->rawResponse; + } + + /** + * @return string + */ + public function getRawResponseHeaders() + { + return $this->rawResponseHeaders; + } + + /** + * @return int + */ + public function getRemainingRetries() + { + return $this->remainingRetries; + } + + /** + * @param string $key + * + * @return mixed + */ + public function getResponseCookie($key) + { + return $this->responseCookies[$key] ?? null; + } + + /** + * @return array + */ + public function getResponseCookies() + { + return $this->responseCookies; + } + + /** + * @return int + */ + public function getRetries() + { + return $this->retries; + } + + /** + * @return callable|null + */ + public function getRetryDecider() + { + return $this->retryDecider; + } + + /** + * @return callable|null + */ + public function getSuccessCallback() + { + return $this->successCallback; + } + + /** + * @return UriInterface|null + */ + public function getUrl() + { + return $this->url; + } + + /** + * @return bool + */ + public function isChildOfMultiCurl(): bool + { + return $this->childOfMultiCurl; + } + + /** + * @return bool + */ + public function isCurlError(): bool + { + return $this->curlError; + } + + /** + * @return bool + */ + public function isError(): bool + { + return $this->error; + } + + /** + * @return bool + */ + public function isHttpError(): bool + { + return $this->httpError; + } + + /** + * @param callable $callback + * + * @return $this + */ + public function progress($callback) + { + $this->setOpt(\CURLOPT_PROGRESSFUNCTION, $callback); + $this->setOpt(\CURLOPT_NOPROGRESS, false); + + return $this; + } + + /** + * @return void + */ + public function reset() + { + if ( + \function_exists('curl_reset') + && + ( + \is_resource($this->curl) + || + (\class_exists('CurlHandle') && $this->curl instanceof \CurlHandle) + ) + ) { + \curl_reset($this->curl); + } else { + $this->curl = \curl_init(); + } + + $this->initialize(''); + } + + /** + * @param string $username + * @param string $password + * + * @return $this + */ + public function setBasicAuthentication($username, $password = '') + { + $this->setOpt(\CURLOPT_HTTPAUTH, \CURLAUTH_BASIC); + $this->setOpt(\CURLOPT_USERPWD, $username . ':' . $password); + + return $this; + } + + /** + * @param bool $bool + * + * @return $this + */ + public function setChildOfMultiCurl(bool $bool) + { + $this->childOfMultiCurl = $bool; + + return $this; + } + + /** + * @param int $seconds + * + * @return $this + */ + public function setConnectTimeout($seconds) + { + $this->setOpt(\CURLOPT_CONNECTTIMEOUT, $seconds); + + return $this; + } + + /** + * @param string $key + * @param mixed $value + * + * @return $this + */ + public function setCookie($key, $value) + { + $this->setEncodedCookie($key, $value); + $this->buildCookies(); + + return $this; + } + + /** + * @param string $cookie_file + * + * @return $this + */ + public function setCookieFile($cookie_file) + { + $this->setOpt(\CURLOPT_COOKIEFILE, $cookie_file); + + return $this; + } + + /** + * @param string $cookie_jar + * + * @return $this + */ + public function setCookieJar($cookie_jar) + { + $this->setOpt(\CURLOPT_COOKIEJAR, $cookie_jar); + + return $this; + } + + /** + * @param string $string + * + * @return $this + */ + public function setCookieString($string) + { + $this->setOpt(\CURLOPT_COOKIE, $string); + + return $this; + } + + /** + * @param array $cookies + * + * @return $this + */ + public function setCookies($cookies) + { + foreach ($cookies as $key => $value) { + $this->setEncodedCookie($key, $value); + } + $this->buildCookies(); + + return $this; + } + + /** + * @return $this + */ + public function setDefaultTimeout() + { + $this->setTimeout(self::DEFAULT_TIMEOUT); + + return $this; + } + + /** + * @param string $username + * @param string $password + * + * @return $this + */ + public function setDigestAuthentication($username, $password = '') + { + $this->setOpt(\CURLOPT_HTTPAUTH, \CURLAUTH_DIGEST); + $this->setOpt(\CURLOPT_USERPWD, $username . ':' . $password); + + return $this; + } + + /** + * @param int|string $id + * + * @return $this + */ + public function setId($id) + { + $this->id = $id; + + return $this; + } + + /** + * @param int $bytes + * + * @return $this + */ + public function setMaxFilesize($bytes) + { + $callback = static function ($resource, $download_size, $downloaded, $upload_size, $uploaded) use ($bytes) { + // Abort the transfer when $downloaded bytes exceeds maximum $bytes by returning a non-zero value. + return $downloaded > $bytes ? 1 : 0; + }; + + $this->progress($callback); + + return $this; + } + + /** + * @param int $option + * @param mixed $value + * + * @return bool + */ + public function setOpt($option, $value) + { + return \curl_setopt($this->curl, $option, $value); + } + + /** + * @param resource $file + * + * @return $this + */ + public function setFile($file) + { + $this->setOpt(\CURLOPT_FILE, $file); + + return $this; + } + + /** + * @param array $options + * + * @return bool + *

Returns true if all options were successfully set. If an option could not be successfully set, + * false is immediately returned, ignoring any future options in the options array. Similar to + * curl_setopt_array().

+ */ + public function setOpts($options) + { + foreach ($options as $option => $value) { + if (!$this->setOpt($option, $value)) { + return false; + } + } + + return true; + } + + /** + * @param int $port + * + * @return $this + */ + public function setPort($port) + { + $this->setOpt(\CURLOPT_PORT, (int) $port); + + return $this; + } + + /** + * Set an HTTP proxy to tunnel requests through. + * + * @param string $proxy - The HTTP proxy to tunnel requests through. May include port number. + * @param int $port - The port number of the proxy to connect to. This port number can also be set in $proxy. + * @param string $username - The username to use for the connection to the proxy + * @param string $password - The password to use for the connection to the proxy + * + * @return $this + */ + public function setProxy($proxy, $port = null, $username = null, $password = null) + { + $this->setOpt(\CURLOPT_PROXY, $proxy); + + if ($port !== null) { + $this->setOpt(\CURLOPT_PROXYPORT, $port); + } + + if ($username !== null && $password !== null) { + $this->setOpt(\CURLOPT_PROXYUSERPWD, $username . ':' . $password); + } + + return $this; + } + + /** + * Set the HTTP authentication method(s) to use for the proxy connection. + * + * @param int $auth + * + * @return $this + */ + public function setProxyAuth($auth) + { + $this->setOpt(\CURLOPT_PROXYAUTH, $auth); + + return $this; + } + + /** + * Set the proxy to tunnel through HTTP proxy. + * + * @param bool $tunnel + * + * @return $this + */ + public function setProxyTunnel($tunnel = true) + { + $this->setOpt(\CURLOPT_HTTPPROXYTUNNEL, $tunnel); + + return $this; + } + + /** + * Set the proxy protocol type. + * + * @param int $type + *

CURLPROXY_*

+ * + * @return $this + */ + public function setProxyType($type) + { + $this->setOpt(\CURLOPT_PROXYTYPE, $type); + + return $this; + } + + /** + * @param string $range

e.g. "0-4096"

+ * + * @return $this + */ + public function setRange($range) + { + $this->setOpt(\CURLOPT_RANGE, $range); + + return $this; + } + + /** + * @param string $referer + * + * @return $this + */ + public function setReferer($referer) + { + $this->setReferrer($referer); + + return $this; + } + + /** + * @param string $referrer + * + * @return $this + */ + public function setReferrer($referrer) + { + $this->setOpt(\CURLOPT_REFERER, $referrer); + + return $this; + } + + /** + * Number of retries to attempt or decider callable. + * + * When using a number of retries to attempt, the maximum number of attempts + * for the request is $maximum_number_of_retries + 1. + * + * When using a callable decider, the request will be retried until the + * function returns a value which evaluates to false. + * + * @param callable|int $retry + * + * @return $this + */ + public function setRetry($retry) + { + if (\is_callable($retry)) { + $this->retryDecider = $retry; + } elseif (\is_int($retry)) { + $maximum_number_of_retries = $retry; + $this->remainingRetries = $maximum_number_of_retries; + } + + return $this; + } + + /** + * @param int $seconds + * + * @return $this + */ + public function setTimeout($seconds) + { + $this->setOpt(\CURLOPT_TIMEOUT, $seconds); + + return $this; + } + + /** + * @param string $url + * @param scalar|array $mixed_data + * + * @return $this + */ + public function setUrl($url, $mixed_data = '') + { + $built_url = new Uri($this->buildUrl($url, $mixed_data)); + + if ($this->url === null) { + $this->url = UriResolver::resolve($built_url, new Uri('')); + } else { + $this->url = UriResolver::resolve($this->url, $built_url); + } + + $this->setOpt(\CURLOPT_URL, (string) $this->url); + + return $this; + } + + /** + * @param string $user_agent + * + * @return $this + */ + public function setUserAgent($user_agent) + { + $this->setOpt(\CURLOPT_USERAGENT, $user_agent); + + return $this; + } + + /** + * @param callable $callback + * + * @return $this + */ + public function success($callback) + { + $this->successCallback = $callback; + + return $this; + } + + /** + * Disable use of the proxy. + * + * @return $this + */ + public function unsetProxy() + { + $this->setOpt(\CURLOPT_PROXY, null); + + return $this; + } + + /** + * @param bool $on + * @param resource|null $output + * + * @return $this + */ + public function verbose($on = true, $output = null) + { + // fallback + if ($output === null) { + if (!\defined('STDERR')) { + \define('STDERR', \fopen('php://stderr', 'wb')); + } + $output = \STDERR; + } + + $this->setOpt(\CURLOPT_VERBOSE, $on); + $this->setOpt(\CURLOPT_STDERR, $output); + + return $this; + } + + /** + * Build Cookies + * + * @return void + */ + private function buildCookies() + { + // Avoid using http_build_query() as unnecessary encoding is performed. + // http_build_query($this->cookies, '', '; '); + $this->setOpt( + \CURLOPT_COOKIE, + \implode( + '; ', + \array_map( + static function ($k, $v) { + return $k . '=' . $v; + }, + \array_keys($this->cookies), + \array_values($this->cookies) + ) + ) + ); + } + + /** + * @param string $url + * @param scalar|array $mixed_data + * + * @return string + */ + private function buildUrl($url, $mixed_data = '') + { + // init + $query_string = ''; + + if (!empty($mixed_data)) { + $query_mark = \strpos($url, '?') > 0 ? '&' : '?'; + if (\is_scalar($mixed_data)) { + $query_string .= $query_mark . $mixed_data; + } elseif (\is_array($mixed_data)) { + $query_string .= $query_mark . \http_build_query($mixed_data, '', '&'); + } + } + + return $url . $query_string; + } + + /** + * Create Header Callback + * + * Gather headers and parse cookies as response headers are received. Keep this function separate from the class so + * that unset($curl) automatically calls __destruct() as expected. Otherwise, manually calling $curl->close() will + * be necessary to prevent a memory leak. + * + * @param \stdClass $header_callback_data + * + * @return callable + */ + private function createHeaderCallback($header_callback_data) + { + return static function ($ch, $header) use ($header_callback_data) { + if (\preg_match('/^Set-Cookie:\s*([^=]+)=([^;]+)/mi', $header, $cookie) === 1) { + $header_callback_data->responseCookies[$cookie[1]] = \trim($cookie[2], " \n\r\t\0\x0B"); + } + $header_callback_data->rawResponseHeaders .= $header; + + return \strlen($header); + }; + } + + /** + * @param resource $fh + * + * @return void + */ + private function downloadComplete($fh) + { + if ( + $this->error + && + $this->downloadFileName + && + \is_file($this->downloadFileName) + ) { + /** @noinspection PhpUsageOfSilenceOperatorInspection */ + @\unlink($this->downloadFileName); + } elseif ( + !$this->error + && + $this->downloadCompleteCallback + ) { + \rewind($fh); + $this->call($this->downloadCompleteCallback, $fh); + $this->downloadCompleteCallback = null; + } + + if (\is_resource($fh)) { + \fclose($fh); + } + + // Fix "PHP Notice: Use of undefined constant STDOUT" when reading the + // PHP script from stdin. Using null causes "Warning: curl_setopt(): + // supplied argument is not a valid File-Handle resource". + if (!\defined('STDOUT')) { + \define('STDOUT', \fopen('php://stdout', 'wb')); + } + + // Reset CURLOPT_FILE with STDOUT to avoid: "curl_exec(): CURLOPT_FILE + // resource has gone away, resetting to default". + $this->setFile(\STDOUT); + + // Reset CURLOPT_RETURNTRANSFER to tell cURL to return subsequent + // responses as the return value of curl_exec(). Without this, + // curl_exec() will revert to returning boolean values. + $this->setOpt(\CURLOPT_RETURNTRANSFER, true); + } + + /** + * @param string $base_url + * + * @return void + */ + private function initialize($base_url) + { + $this->setId(\uniqid('', true)); + $this->setDefaultTimeout(); + $this->setOpt(\CURLINFO_HEADER_OUT, true); + + // Create a placeholder to temporarily store the header callback data. + $header_callback_data = new \stdClass(); + $header_callback_data->rawResponseHeaders = ''; + $header_callback_data->responseCookies = []; + $this->headerCallbackData = $header_callback_data; + $this->setOpt(\CURLOPT_HEADERFUNCTION, $this->createHeaderCallback($header_callback_data)); + + $this->setOpt(\CURLOPT_RETURNTRANSFER, true); + $this->setUrl($base_url); + } + + /** + * @param string $key + * @param mixed $value + * + * @return $this + */ + private function setEncodedCookie($key, $value) + { + $name_chars = []; + foreach (\str_split($key) as $name_char) { + $name_chars[] = \rawurlencode($name_char); + } + + $value_chars = []; + foreach (\str_split($value) as $value_char) { + $value_chars[] = \rawurlencode($value_char); + } + + $this->cookies[\implode('', $name_chars)] = \implode('', $value_chars); + + return $this; + } +} diff --git a/src/Httpful/Curl/MultiCurl.php b/src/Httpful/Curl/MultiCurl.php new file mode 100644 index 0000000..9981bdb --- /dev/null +++ b/src/Httpful/Curl/MultiCurl.php @@ -0,0 +1,437 @@ +multiCurl = \curl_multi_init(); + } + + public function __destruct() + { + $this->close(); + } + + /** + * Add a Curl instance to the handle queue. + * + * @param Curl $curl + * + * @return $this + */ + public function addCurl(Curl $curl) + { + $this->queueHandle($curl); + + return $this; + } + + /** + * @param callable $callback + * + * @return $this + */ + public function beforeSend($callback) + { + $this->beforeSendCallback = $callback; + + return $this; + } + + /** + * @return void + */ + public function close() + { + foreach ($this->curls as $curl) { + $curl->close(); + } + + if ( + \is_resource($this->multiCurl) + || + (class_exists('CurlMultiHandle') && $this->multiCurl instanceof \CurlMultiHandle) + ) { + \curl_multi_close($this->multiCurl); + } + } + + /** + * @param callable $callback + * + * @return $this; + */ + public function complete($callback) + { + $this->completeCallback = $callback; + + return $this; + } + + /** + * @param callable $callback + * + * @return $this + */ + public function error($callback) + { + $this->errorCallback = $callback; + + return $this; + } + + /** + * @param Curl $curl + * @param callable|string $mixed_filename + * + * @return Curl + */ + public function addDownload(Curl $curl, $mixed_filename) + { + $this->queueHandle($curl); + + // Use tmpfile() or php://temp to avoid "Too many open files" error. + if (\is_callable($mixed_filename)) { + $curl->downloadCompleteCallback = $mixed_filename; + $curl->downloadFileName = null; + $curl->fileHandle = \tmpfile(); + } else { + $filename = $mixed_filename; + + // Use a temporary file when downloading. Not using a temporary file can cause an error when an existing + // file has already fully completed downloading and a new download is started with the same destination save + // path. The download request will include header "Range: bytes=$filesize-" which is syntactically valid, + // but unsatisfiable. + $download_filename = $filename . '.pccdownload'; + $curl->downloadFileName = $download_filename; + + // Attempt to resume download only when a temporary download file exists and is not empty. + if (\is_file($download_filename) && $filesize = \filesize($download_filename)) { + $first_byte_position = $filesize; + $range = $first_byte_position . '-'; + $curl->setRange($range); + $curl->fileHandle = \fopen($download_filename, 'ab'); + + // Move the downloaded temporary file to the destination save path. + $curl->downloadCompleteCallback = static function ($instance, $fh) use ($download_filename, $filename) { + // Close the open file handle before renaming the file. + if (\is_resource($fh)) { + \fclose($fh); + } + + \rename($download_filename, $filename); + }; + } else { + $curl->fileHandle = \fopen('php://temp', 'wb'); + $curl->downloadCompleteCallback = static function ($instance, $fh) use ($filename) { + \file_put_contents($filename, \stream_get_contents($fh)); + }; + } + } + + if ($curl->fileHandle === false) { + throw new \Httpful\Exception\ClientErrorException('Unable to write to file:' . $curl->downloadFileName); + } + + $curl->setFile($curl->fileHandle); + $curl->setOpt(\CURLOPT_CUSTOMREQUEST, 'GET'); + $curl->setOpt(\CURLOPT_HTTPGET, true); + + return $curl; + } + + /** + * @param int $concurrency + * + * @return $this + */ + public function setConcurrency($concurrency) + { + $this->concurrency = $concurrency; + + return $this; + } + + /** + * @param string $key + * @param mixed $value + * + * @return $this + */ + public function setCookie($key, $value) + { + $this->cookies[$key] = $value; + + return $this; + } + + /** + * @param array $cookies + * + * @return $this + */ + public function setCookies($cookies) + { + foreach ($cookies as $key => $value) { + $this->cookies[$key] = $value; + } + + return $this; + } + + /** + * Number of retries to attempt or decider callable. + * + * When using a number of retries to attempt, the maximum number of attempts + * for the request is $maximum_number_of_retries + 1. + * + * When using a callable decider, the request will be retried until the + * function returns a value which evaluates to false. + * + * @param callable|int $mixed + * + * @return $this + */ + public function setRetry($mixed) + { + $this->retry = $mixed; + + return $this; + } + + /** + * @return $this|null + */ + public function start() + { + if ($this->isStarted) { + return null; + } + + $this->isStarted = true; + + $concurrency = $this->concurrency; + if ($concurrency > \count($this->curls)) { + $concurrency = \count($this->curls); + } + + for ($i = 0; $i < $concurrency; ++$i) { + $curlOrNull = \array_shift($this->curls); + if ($curlOrNull !== null) { + $this->initHandle($curlOrNull); + } + } + + $active = null; + do { + // Wait for activity on any curl_multi connection when curl_multi_select (libcurl) fails to correctly block. + // https://bugs.php.net/bug.php?id=63411 + if ($active && \curl_multi_select($this->multiCurl) === -1) { + \usleep(250); + } + + \curl_multi_exec($this->multiCurl, $active); + + while (!(($info_array = \curl_multi_info_read($this->multiCurl)) === false)) { + if ($info_array['msg'] === \CURLMSG_DONE) { + foreach ($this->activeCurls as $key => $curl) { + $curlRes = $curl->getCurl(); + if ($curlRes === false) { + continue; + } + + if ($curlRes === $info_array['handle']) { + // Set the error code for multi handles using the "result" key in the array returned by + // curl_multi_info_read(). Using curl_errno() on a multi handle will incorrectly return 0 + // for errors. + $curl->curlErrorCode = $info_array['result']; + $curl->exec($curlRes); + + if ($curl->attemptRetry()) { + // Remove completed handle before adding again in order to retry request. + \curl_multi_remove_handle($this->multiCurl, $curlRes); + + $curlm_error_code = \curl_multi_add_handle($this->multiCurl, $curlRes); + if ($curlm_error_code !== \CURLM_OK) { + throw new \ErrorException( + 'cURL multi add handle error: ' . \curl_multi_strerror($curlm_error_code) + ); + } + } else { + $curl->execDone(); + + // Remove completed instance from active curls. + unset($this->activeCurls[$key]); + + // Start new requests before removing the handle of the completed one. + while (\count($this->curls) >= 1 && \count($this->activeCurls) < $this->concurrency) { + $curlOrNull = \array_shift($this->curls); + if ($curlOrNull !== null) { + $this->initHandle($curlOrNull); + } + } + \curl_multi_remove_handle($this->multiCurl, $curlRes); + + // Clean up completed instance. + $curl->close(); + } + + break; + } + } + } + } + + if (!$active) { + $active = \count($this->activeCurls); + } + } while ($active > 0); + + $this->isStarted = false; + + return $this; + } + + /** + * @param callable $callback + * + * @return $this + */ + public function success($callback) + { + $this->successCallback = $callback; + + return $this; + } + + /** + * @return resource|\CurlMultiHandle + */ + public function getMultiCurl() + { + return $this->multiCurl; + } + + /** + * @param Curl $curl + * + * @throws \ErrorException + * + * @return void + */ + private function initHandle($curl) + { + // Set callbacks if not already individually set. + + if ($curl->beforeSendCallback === null) { + $curl->beforeSend($this->beforeSendCallback); + } + + if ($curl->successCallback === null) { + $curl->success($this->successCallback); + } + + if ($curl->errorCallback === null) { + $curl->error($this->errorCallback); + } + + if ($curl->completeCallback === null) { + $curl->complete($this->completeCallback); + } + + $curl->setRetry($this->retry); + $curl->setCookies($this->cookies); + + $curlRes = $curl->getCurl(); + if ($curlRes === false) { + throw new \ErrorException('cURL multi add handle error from curl: curl === false'); + } + + $curlm_error_code = \curl_multi_add_handle($this->multiCurl, $curlRes); + if ($curlm_error_code !== \CURLM_OK) { + throw new \ErrorException('cURL multi add handle error: ' . \curl_multi_strerror($curlm_error_code)); + } + + $this->activeCurls[$curl->getId()] = $curl; + $curl->call($curl->beforeSendCallback); + } + + /** + * @param Curl $curl + * + * @return void + */ + private function queueHandle($curl) + { + // Use sequential ids to allow for ordered post processing. + ++$this->nextCurlId; + $curl->setId($this->nextCurlId); + $curl->setChildOfMultiCurl(true); + $this->curls[$this->nextCurlId] = $curl; + } +} diff --git a/src/Httpful/Curl/MultiCurlPromise.php b/src/Httpful/Curl/MultiCurlPromise.php new file mode 100644 index 0000000..2de8241 --- /dev/null +++ b/src/Httpful/Curl/MultiCurlPromise.php @@ -0,0 +1,163 @@ +clientMulti = $clientMulti; + $this->state = Promise::PENDING; + } + + /** + * Add behavior for when the promise is resolved or rejected. + * + * If you do not care about one of the cases, you can set the corresponding callable to null + * The callback will be called when the response or exception arrived and never more than once. + * + * @param callable $onComplete Called when a response will be available + * @param callable $onRejected Called when an error happens. + * + * You must always return the Response in the interface or throw an Exception + * + * @return Promise Always returns a new promise which is resolved with value of the executed + * callback (onFulfilled / onRejected) + */ + public function then(callable $onComplete = null, callable $onRejected = null) + { + if ($onComplete) { + $this->clientMulti->complete( + static function (Curl $instance) use ($onComplete) { + if ($instance->request instanceof \Httpful\Request) { + $response = $instance->request->_buildResponse($instance->rawResponse, $instance); + } else { + $response = $instance->rawResponse; + } + + $onComplete( + $response, + $instance->request, + $instance + ); + } + ); + } + + if ($onRejected) { + $this->clientMulti->error( + static function (Curl $instance) use ($onRejected) { + if ($instance->request instanceof \Httpful\Request) { + $response = $instance->request->_buildResponse($instance->rawResponse, $instance); + } else { + $response = $instance->rawResponse; + } + + $onRejected( + $response, + $instance->request, + $instance + ); + } + ); + } + + return new self($this->clientMulti); + } + + /** + * Get the state of the promise, one of PENDING, FULFILLED or REJECTED. + * + * @return string + */ + public function getState() + { + return $this->state; + } + + /** + * Wait for the promise to be fulfilled or rejected. + * + * When this method returns, the request has been resolved and the appropriate callable has terminated. + * + * When called with the unwrap option + * + * @param bool $unwrap Whether to return resolved value / throw reason or not + * + * @return MultiCurl|null Resolved value, null if $unwrap is set to false + */ + public function wait($unwrap = true) + { + if ($unwrap) { + $this->clientMulti->start(); + $this->state = Promise::FULFILLED; + + return $this->clientMulti; + } + + try { + $this->clientMulti->start(); + $this->state = Promise::FULFILLED; + } catch (\ErrorException $e) { + $this->_error((string) $e); + } + + return null; + } + + /** + * @param string $error + * + * @return void + */ + private function _error($error) + { + $this->state = Promise::REJECTED; + + // global error handling + + $global_error_handler = \Httpful\Setup::getGlobalErrorHandler(); + if ($global_error_handler) { + if ($global_error_handler instanceof \Psr\Log\LoggerInterface) { + // PSR-3 https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md + $global_error_handler->error($error); + } elseif (\is_callable($global_error_handler)) { + // error callback + /** @noinspection VariableFunctionsUsageInspection */ + \call_user_func($global_error_handler, $error); + } + } + + // local error handling + + /** @noinspection ForgottenDebugOutputInspection */ + \error_log($error); + } +} diff --git a/src/Httpful/Encoding.php b/src/Httpful/Encoding.php new file mode 100644 index 0000000..d82c17e --- /dev/null +++ b/src/Httpful/Encoding.php @@ -0,0 +1,14 @@ +curl_object = $curl_object; + + parent::__construct($message, $code, $previous); + } + + /** + * @return int + */ + public function getCurlErrorNumber(): int + { + return $this->curlErrorNumber; + } + + /** + * @return string + */ + public function getCurlErrorString(): string + { + return $this->curlErrorString; + } + + /** + * @return \Httpful\Curl\Curl|null + */ + public function getCurlObject() + { + return $this->curl_object; + } + + /** + * @param int $curlErrorNumber + * + * @return static + */ + public function setCurlErrorNumber($curlErrorNumber) + { + $this->curlErrorNumber = $curlErrorNumber; + + return $this; + } + + /** + * @param string $curlErrorString + * + * @return static + */ + public function setCurlErrorString($curlErrorString) + { + $this->curlErrorString = $curlErrorString; + + return $this; + } + + /** + * @return bool + */ + public function wasTimeout(): bool + { + return $this->code === \CURLE_OPERATION_TIMEOUTED; + } +} diff --git a/src/Httpful/Exception/ConnectionErrorException.php b/src/Httpful/Exception/ConnectionErrorException.php deleted file mode 100644 index b0a3391..0000000 --- a/src/Httpful/Exception/ConnectionErrorException.php +++ /dev/null @@ -1,54 +0,0 @@ -curlErrorNumber; - } - - /** - * @param string $curlErrorNumber - * @return $this - */ - public function setCurlErrorNumber($curlErrorNumber) { - $this->curlErrorNumber = $curlErrorNumber; - - return $this; - } - - /** - * @return string - */ - public function getCurlErrorString() { - return $this->curlErrorString; - } - - /** - * @param string $curlErrorString - * @return $this - */ - public function setCurlErrorString($curlErrorString) { - $this->curlErrorString = $curlErrorString; - - return $this; - } - - -} \ No newline at end of file diff --git a/src/Httpful/Exception/CsvParseException.php b/src/Httpful/Exception/CsvParseException.php new file mode 100644 index 0000000..775e4f2 --- /dev/null +++ b/src/Httpful/Exception/CsvParseException.php @@ -0,0 +1,9 @@ +curl_object = $curl_object; + $this->request = $request; + + parent::__construct($message, $code, $previous); + } + + /** + * @return int + */ + public function getCurlErrorNumber(): int + { + return $this->curlErrorNumber; + } + + /** + * @return string + */ + public function getCurlErrorString(): string + { + return $this->curlErrorString; + } + + /** + * @return \Httpful\Curl\Curl|null + */ + public function getCurlObject() + { + return $this->curl_object; + } + + /** + * Returns the request. + * + * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request ?? new Request(); + } + + /** + * @param int $curlErrorNumber + * + * @return static + */ + public function setCurlErrorNumber($curlErrorNumber) + { + $this->curlErrorNumber = $curlErrorNumber; + + return $this; + } + + /** + * @param string $curlErrorString + * + * @return static + */ + public function setCurlErrorString($curlErrorString) + { + $this->curlErrorString = $curlErrorString; + + return $this; + } + + /** + * @return bool + */ + public function wasTimeout(): bool + { + return $this->code === \CURLE_OPERATION_TIMEOUTED; + } +} diff --git a/src/Httpful/Exception/RequestException.php b/src/Httpful/Exception/RequestException.php new file mode 100644 index 0000000..b736027 --- /dev/null +++ b/src/Httpful/Exception/RequestException.php @@ -0,0 +1,43 @@ +request = $request; + } + + /** + * Returns the request. + * + * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } +} diff --git a/src/Httpful/Exception/ResponseException.php b/src/Httpful/Exception/ResponseException.php new file mode 100644 index 0000000..3b69606 --- /dev/null +++ b/src/Httpful/Exception/ResponseException.php @@ -0,0 +1,9 @@ +withUriFromString($uri); + + if (is_array($body)) { + $return = $return->withBodyFromArray($body); + } else { + $return = $return->withBodyFromString($body); + } + + return $return; + } + + /** + * @param int $code + * @param string|null $reasonPhrase + * + * @return Response + */ + public function createResponse(int $code = 200, string $reasonPhrase = null): ResponseInterface + { + return (new Response())->withStatus($code, $reasonPhrase); + } + + /** + * @param string $method + * @param string $uri + * @param array $serverParams + * @param string|null $mime + * @param string $body + * + * @return ServerRequest + */ + public function createServerRequest(string $method, $uri, array $serverParams = [], $mime = null, string $body = ''): ServerRequestInterface + { + return (new ServerRequest($method, $mime, null, $serverParams)) + ->withUriFromString($uri) + ->withBodyFromString($body); + } + + /** + * @param string $content + * + * @return StreamInterface + */ + public function createStream(string $content = ''): StreamInterface + { + return Stream::createNotNull($content); + } + + /** + * @param string $filename + * @param string $mode + * + * @return StreamInterface + */ + public function createStreamFromFile(string $filename, string $mode = 'rb'): StreamInterface + { + /** @noinspection PhpUsageOfSilenceOperatorInspection */ + $resource = @\fopen($filename, $mode); + if ($resource === false) { + if ($mode === '' || \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true) === false) { + throw new \InvalidArgumentException('The mode ' . $mode . ' is invalid.'); + } + + throw new \RuntimeException('The file ' . $filename . ' cannot be opened.'); + } + + return Stream::createNotNull($resource); + } + + /** + * @param resource|StreamInterface|string $resource + * + * @return StreamInterface + */ + public function createStreamFromResource($resource): StreamInterface + { + return Stream::createNotNull($resource); + } + + /** + * @param StreamInterface $stream + * @param int|null $size + * @param int $error + * @param string|null $clientFilename + * @param string|null $clientMediaType + * + * @return UploadedFileInterface + */ + public function createUploadedFile( + StreamInterface $stream, + int $size = null, + int $error = \UPLOAD_ERR_OK, + string $clientFilename = null, + string $clientMediaType = null + ): UploadedFileInterface { + if ($size === null) { + $size = (int) $stream->getSize(); + } + + return new UploadedFile( + $stream, + $size, + $error, + $clientFilename, + $clientMediaType + ); + } + + /** + * @param string $uri + * + * @return UriInterface + */ + public function createUri(string $uri = ''): UriInterface + { + return new Uri($uri); + } +} diff --git a/src/Httpful/Handlers/AbstractMimeHandler.php b/src/Httpful/Handlers/AbstractMimeHandler.php new file mode 100644 index 0000000..99c04d3 --- /dev/null +++ b/src/Httpful/Handlers/AbstractMimeHandler.php @@ -0,0 +1,25 @@ + - */ - -namespace Httpful\Handlers; - -class CsvHandler extends MimeHandlerAdapter -{ - /** - * @param string $body - * @return mixed - * @throws \Exception - */ - public function parse($body) - { - if (empty($body)) - return null; - - $parsed = array(); - $fp = fopen('data://text/plain;base64,' . base64_encode($body), 'r'); - while (($r = fgetcsv($fp)) !== FALSE) { - $parsed[] = $r; - } - - if (empty($parsed)) - throw new \Exception("Unable to parse response as CSV"); - return $parsed; - } - - /** - * @param mixed $payload - * @return string - */ - public function serialize($payload) - { - $fp = fopen('php://temp/maxmemory:'. (6*1024*1024), 'r+'); - $i = 0; - foreach ($payload as $fields) { - if($i++ == 0) { - fputcsv($fp, array_keys($fields)); - } - fputcsv($fp, $fields); - } - rewind($fp); - $data = stream_get_contents($fp); - fclose($fp); - return $data; - } -} diff --git a/src/Httpful/Handlers/CsvMimeHandler.php b/src/Httpful/Handlers/CsvMimeHandler.php new file mode 100644 index 0000000..4260800 --- /dev/null +++ b/src/Httpful/Handlers/CsvMimeHandler.php @@ -0,0 +1,73 @@ +init($args); + } + + /** + * @param array $args + * + * @return void + */ + public function init(array $args) + { + } + + /** + * @param mixed $body + * + * @return mixed + */ + public function parse($body) + { + return $body; + } + + /** + * @param mixed $payload + * + * @return mixed + */ + public function serialize($payload) + { + if ( + \is_array($payload) + || + $payload instanceof \Serializable + ) { + $payload = \serialize($payload); + } + + return $payload; + } +} diff --git a/src/Httpful/Handlers/FormHandler.php b/src/Httpful/Handlers/FormHandler.php deleted file mode 100644 index fea1c37..0000000 --- a/src/Httpful/Handlers/FormHandler.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ - -namespace Httpful\Handlers; - -class FormHandler extends MimeHandlerAdapter -{ - /** - * @param string $body - * @return mixed - */ - public function parse($body) - { - $parsed = array(); - parse_str($body, $parsed); - return $parsed; - } - - /** - * @param mixed $payload - * @return string - */ - public function serialize($payload) - { - return http_build_query($payload, null, '&'); - } -} \ No newline at end of file diff --git a/src/Httpful/Handlers/FormMimeHandler.php b/src/Httpful/Handlers/FormMimeHandler.php new file mode 100644 index 0000000..2238853 --- /dev/null +++ b/src/Httpful/Handlers/FormMimeHandler.php @@ -0,0 +1,43 @@ +stripBom($body); + if (empty($body)) { + return null; + } + + if (\voku\helper\UTF8::is_utf8($body) === false) { + $body = \voku\helper\UTF8::to_utf8($body); + } + + return HtmlDomParser::str_get_html($body); + } + + /** + * @param mixed $payload + * + * @return string + */ + public function serialize($payload) + { + return (string) $payload; + } +} diff --git a/src/Httpful/Handlers/JsonHandler.php b/src/Httpful/Handlers/JsonHandler.php deleted file mode 100644 index ef3bee8..0000000 --- a/src/Httpful/Handlers/JsonHandler.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ - -namespace Httpful\Handlers; - -class JsonHandler extends MimeHandlerAdapter -{ - private $decode_as_array = false; - - public function init(array $args) - { - $this->decode_as_array = !!(array_key_exists('decode_as_array', $args) ? $args['decode_as_array'] : false); - } - - /** - * @param string $body - * @return mixed - * @throws \Exception - */ - public function parse($body) - { - $body = $this->stripBom($body); - if (empty($body)) - return null; - $parsed = json_decode($body, $this->decode_as_array); - if (is_null($parsed) && 'null' !== strtolower($body)) - throw new \Exception("Unable to parse response as JSON"); - return $parsed; - } - - /** - * @param mixed $payload - * @return string - */ - public function serialize($payload) - { - return json_encode($payload); - } -} diff --git a/src/Httpful/Handlers/JsonMimeHandler.php b/src/Httpful/Handlers/JsonMimeHandler.php new file mode 100644 index 0000000..1b5397e --- /dev/null +++ b/src/Httpful/Handlers/JsonMimeHandler.php @@ -0,0 +1,62 @@ +decode_as_array = (bool) ($args['decode_as_array']); + } else { + $this->decode_as_array = true; + } + } + + /** + * @param string $body + * + * @return mixed|null + */ + public function parse($body) + { + $body = $this->stripBom($body); + if (empty($body)) { + return null; + } + + $parsed = \json_decode($body, $this->decode_as_array); + if ($parsed === null && \strtolower($body) !== 'null') { + throw new JsonParseException('Unable to parse response as JSON: ' . \json_last_error_msg() . ' | "' . \print_r($body, true) . '"'); + } + + return $parsed; + } + + /** + * @param mixed $payload + * + * @return false|string + */ + public function serialize($payload) + { + return \json_encode($payload); + } +} diff --git a/src/Httpful/Handlers/MimeHandlerAdapter.php b/src/Httpful/Handlers/MimeHandlerAdapter.php deleted file mode 100644 index e57ebb0..0000000 --- a/src/Httpful/Handlers/MimeHandlerAdapter.php +++ /dev/null @@ -1,54 +0,0 @@ -init($args); - } - - /** - * Initial setup of - * @param array $args - */ - public function init(array $args) - { - } - - /** - * @param string $body - * @return mixed - */ - public function parse($body) - { - return $body; - } - - /** - * @param mixed $payload - * @return string - */ - function serialize($payload) - { - return (string) $payload; - } - - protected function stripBom($body) - { - if ( substr($body,0,3) === "\xef\xbb\xbf" ) // UTF-8 - $body = substr($body,3); - else if ( substr($body,0,4) === "\xff\xfe\x00\x00" || substr($body,0,4) === "\x00\x00\xfe\xff" ) // UTF-32 - $body = substr($body,4); - else if ( substr($body,0,2) === "\xff\xfe" || substr($body,0,2) === "\xfe\xff" ) // UTF-16 - $body = substr($body,2); - return $body; - } -} \ No newline at end of file diff --git a/src/Httpful/Handlers/MimeHandlerInterface.php b/src/Httpful/Handlers/MimeHandlerInterface.php new file mode 100644 index 0000000..0069ec0 --- /dev/null +++ b/src/Httpful/Handlers/MimeHandlerInterface.php @@ -0,0 +1,20 @@ + - */ - -namespace Httpful\Handlers; - -class XHtmlHandler extends MimeHandlerAdapter -{ - // @todo add html specific parsing - // see DomDocument::load http://docs.php.net/manual/en/domdocument.loadhtml.php -} \ No newline at end of file diff --git a/src/Httpful/Handlers/XmlHandler.php b/src/Httpful/Handlers/XmlHandler.php deleted file mode 100644 index 9298a1f..0000000 --- a/src/Httpful/Handlers/XmlHandler.php +++ /dev/null @@ -1,152 +0,0 @@ - - * @author Nathan Good - */ - -namespace Httpful\Handlers; - -class XmlHandler extends MimeHandlerAdapter -{ - /** - * @var string $namespace xml namespace to use with simple_load_string - */ - private $namespace; - - /** - * @var int $libxml_opts see http://www.php.net/manual/en/libxml.constants.php - */ - private $libxml_opts; - - /** - * @param array $conf sets configuration options - */ - public function __construct(array $conf = array()) - { - $this->namespace = isset($conf['namespace']) ? $conf['namespace'] : ''; - $this->libxml_opts = isset($conf['libxml_opts']) ? $conf['libxml_opts'] : 0; - } - - /** - * @param string $body - * @return mixed - * @throws \Exception if unable to parse - */ - public function parse($body) - { - $body = $this->stripBom($body); - if (empty($body)) - return null; - $parsed = simplexml_load_string($body, null, $this->libxml_opts, $this->namespace); - if ($parsed === false) - throw new \Exception("Unable to parse response as XML"); - return $parsed; - } - - /** - * @param mixed $payload - * @return string - * @throws \Exception if unable to serialize - */ - public function serialize($payload) - { - list($_, $dom) = $this->_future_serializeAsXml($payload); - return $dom->saveXml(); - } - - /** - * @param mixed $payload - * @return string - * @author Ted Zellers - */ - public function serialize_clean($payload) - { - $xml = new \XMLWriter; - $xml->openMemory(); - $xml->startDocument('1.0','ISO-8859-1'); - $this->serialize_node($xml, $payload); - return $xml->outputMemory(true); - } - - /** - * @param \XMLWriter $xmlw - * @param mixed $node to serialize - * @author Ted Zellers - */ - public function serialize_node(&$xmlw, $node){ - if (!is_array($node)){ - $xmlw->text($node); - } else { - foreach ($node as $k => $v){ - $xmlw->startElement($k); - $this->serialize_node($xmlw, $v); - $xmlw->endElement(); - } - } - } - - /** - * @author Zack Douglas - */ - private function _future_serializeAsXml($value, $node = null, $dom = null) - { - if (!$dom) { - $dom = new \DOMDocument; - } - if (!$node) { - if (!is_object($value)) { - $node = $dom->createElement('response'); - $dom->appendChild($node); - } else { - $node = $dom; - } - } - if (is_object($value)) { - $objNode = $dom->createElement(get_class($value)); - $node->appendChild($objNode); - $this->_future_serializeObjectAsXml($value, $objNode, $dom); - } else if (is_array($value)) { - $arrNode = $dom->createElement('array'); - $node->appendChild($arrNode); - $this->_future_serializeArrayAsXml($value, $arrNode, $dom); - } else if (is_bool($value)) { - $node->appendChild($dom->createTextNode($value?'TRUE':'FALSE')); - } else { - $node->appendChild($dom->createTextNode($value)); - } - return array($node, $dom); - } - /** - * @author Zack Douglas - */ - private function _future_serializeArrayAsXml($value, &$parent, &$dom) - { - foreach ($value as $k => &$v) { - $n = $k; - if (is_numeric($k)) { - $n = "child-{$n}"; - } - $el = $dom->createElement($n); - $parent->appendChild($el); - $this->_future_serializeAsXml($v, $el, $dom); - } - return array($parent, $dom); - } - /** - * @author Zack Douglas - */ - private function _future_serializeObjectAsXml($value, &$parent, &$dom) - { - $refl = new \ReflectionObject($value); - foreach ($refl->getProperties() as $pr) { - if (!$pr->isPrivate()) { - $el = $dom->createElement($pr->getName()); - $parent->appendChild($el); - $this->_future_serializeAsXml($pr->getValue($value), $el, $dom); - } - } - return array($parent, $dom); - } -} \ No newline at end of file diff --git a/src/Httpful/Handlers/XmlMimeHandler.php b/src/Httpful/Handlers/XmlMimeHandler.php new file mode 100644 index 0000000..516343d --- /dev/null +++ b/src/Httpful/Handlers/XmlMimeHandler.php @@ -0,0 +1,189 @@ +namespace = $conf['namespace'] ?? ''; + $this->libxml_opts = $conf['libxml_opts'] ?? 0; + } + + /** + * @param string $body + * + * @return \SimpleXMLElement|null + */ + public function parse($body) + { + $body = $this->stripBom($body); + if (empty($body)) { + return null; + } + + $parsed = \simplexml_load_string($body, \SimpleXMLElement::class, $this->libxml_opts, $this->namespace); + if ($parsed === false) { + throw new XmlParseException('Unable to parse response as XML: ' . $body); + } + + return $parsed; + } + + /** @noinspection PhpMissingParentCallCommonInspection */ + + /** + * @param mixed $payload + * + * @return false|string + */ + public function serialize($payload) + { + /** @noinspection PhpUnusedLocalVariableInspection */ + list($_, $dom) = $this->_future_serializeAsXml($payload); + + /* @var \DOMDocument $dom */ + + return $dom->saveXML(); + } + + /** + * @param mixed $payload + * + * @return string + */ + public function serialize_clean($payload): string + { + $xml = new \XMLWriter(); + $xml->openMemory(); + $xml->startDocument('1.0', 'UTF-8'); + $this->serialize_node($xml, $payload); + + return $xml->outputMemory(true); + } + + /** + * @param \XMLWriter $xmlw + * @param mixed $node to serialize + * + * @return void + */ + public function serialize_node(&$xmlw, $node) + { + if (!\is_array($node)) { + $xmlw->text($node); + } else { + foreach ($node as $k => $v) { + $xmlw->startElement($k); + $this->serialize_node($xmlw, $v); + $xmlw->endElement(); + } + } + } + + /** + * @param mixed $value + * @param \DOMElement $parent + * @param \DOMDocument $dom + * + * @return array + */ + private function _future_serializeArrayAsXml(&$value, \DOMElement $parent, \DOMDocument $dom): array + { + foreach ($value as $k => &$v) { + $n = $k; + if (\is_numeric($k)) { + $n = "child-{$n}"; + } + + $el = $dom->createElement($n); + $parent->appendChild($el); + $this->_future_serializeAsXml($v, $el, $dom); + } + + return [$parent, $dom]; + } + + /** + * @param mixed $value + * @param \DOMElement|null $node + * @param \DOMDocument|null $dom + * + * @return array + */ + private function _future_serializeAsXml(&$value, \DOMElement $node = null, \DOMDocument $dom = null): array + { + if (!$dom) { + $dom = new \DOMDocument(); + } + + if (!$node) { + if (!\is_object($value)) { + $node = $dom->createElement('response'); + $dom->appendChild($node); + } else { + $node = $dom; // is it correct, that we use the "dom" as "node"? + } + } + + if (\is_object($value)) { + $objNode = $dom->createElement(\get_class($value)); + $node->appendChild($objNode); + $this->_future_serializeObjectAsXml($value, $objNode, $dom); + } elseif (\is_array($value)) { + $arrNode = $dom->createElement('array'); + $node->appendChild($arrNode); + $this->_future_serializeArrayAsXml($value, $arrNode, $dom); + } elseif ((bool) $value === $value) { + $node->appendChild($dom->createTextNode($value ? 'TRUE' : 'FALSE')); + } else { + $node->appendChild($dom->createTextNode($value)); + } + + return [$node, $dom]; + } + + /** + * @param mixed $value + * @param \DOMElement $parent + * @param \DOMDocument $dom + * + * @return array + */ + private function _future_serializeObjectAsXml(&$value, \DOMElement $parent, \DOMDocument $dom): array + { + $refl = new \ReflectionObject($value); + foreach ($refl->getProperties() as $pr) { + if (!$pr->isPrivate()) { + $el = $dom->createElement($pr->getName()); + $parent->appendChild($el); + $value = $pr->getValue($value); + $this->_future_serializeAsXml($value, $el, $dom); + } + } + + return [$parent, $dom]; + } +} diff --git a/src/Httpful/Headers.php b/src/Httpful/Headers.php new file mode 100644 index 0000000..f2db868 --- /dev/null +++ b/src/Httpful/Headers.php @@ -0,0 +1,375 @@ + $value) { + if (!\is_array($value)) { + $value = [$value]; + } + + $this->forceSet($key, $value); + } + } + } + + /** + * @see https://secure.php.net/manual/en/countable.count.php + * + * @return int the number of elements stored in the array + */ + #[\ReturnTypeWillChange] + public function count() + { + return (int) \count($this->data); + } + + /** + * @see https://secure.php.net/manual/en/iterator.current.php + * + * @return mixed data at the current position + */ + #[\ReturnTypeWillChange] + public function current() + { + return \current($this->data); + } + + /** + * @see https://secure.php.net/manual/en/iterator.key.php + * + * @return mixed case-sensitive key at current position + */ + #[\ReturnTypeWillChange] + public function key() + { + $key = \key($this->data); + + return $this->keys[$key] ?? $key; + } + + /** + * @see https://secure.php.net/manual/en/iterator.next.php + * + * @return void + */ + #[\ReturnTypeWillChange] + public function next() + { + \next($this->data); + } + + /** + * @see https://secure.php.net/manual/en/iterator.rewind.php + * + * @return void + */ + #[\ReturnTypeWillChange] + public function rewind() + { + \reset($this->data); + } + + /** + * @see https://secure.php.net/manual/en/iterator.valid.php + * + * @return bool if the current position is valid + */ + #[\ReturnTypeWillChange] + public function valid() + { + return \key($this->data) !== null; + } + + /** + * @param string $offset the offset to store the data at (case-insensitive) + * @param mixed $value the data to store at the specified offset + * + * @return void + */ + public function forceSet($offset, $value) + { + $value = $this->_validateAndTrimHeader($offset, $value); + + $this->offsetSetForce($offset, $value); + } + + /** + * @param string $offset + * + * @return void + */ + public function forceUnset($offset) + { + $this->offsetUnsetForce($offset); + } + + /** + * @param string $string + * + * @return Headers + */ + public static function fromString($string): self + { + // init + $parsed_headers = []; + + $headers = \preg_split("/[\r\n]+/", $string, -1, \PREG_SPLIT_NO_EMPTY); + if ($headers === false) { + return new self($parsed_headers); + } + + $headersCount = \count($headers); + for ($i = 1; $i < $headersCount; ++$i) { + $header = $headers[$i]; + + if (\strpos($header, ':') === false) { + continue; + } + + list($key, $raw_value) = \explode(':', $header, 2); + $key = \trim($key); + $value = \trim($raw_value); + + if (\array_key_exists($key, $parsed_headers)) { + $parsed_headers[$key][] = $value; + } else { + $parsed_headers[$key][] = $value; + } + } + + return new self($parsed_headers); + } + + /** + * Checks if the offset exists in data storage. The index is looked up with + * the lowercase version of the provided offset. + * + * @see https://secure.php.net/manual/en/arrayaccess.offsetexists.php + * + * @param string $offset Offset to check + * + * @return bool if the offset exists + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return (bool) \array_key_exists(\strtolower($offset), $this->data); + } + + /** + * Return the stored data at the provided offset. The offset is converted to + * lowercase and the lookup is done on the data store directly. + * + * @see https://secure.php.net/manual/en/arrayaccess.offsetget.php + * + * @param string $offset offset to lookup + * + * @return mixed the data stored at the offset + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + $offsetLower = \strtolower($offset); + + return $this->data[$offsetLower] ?? null; + } + + /** + * @param string $offset + * @param string $value + * + * @throws ResponseHeaderException + * + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + throw new ResponseHeaderException('Headers are read-only.'); + } + + /** + * @param string $offset + * + * @throws ResponseHeaderException + * + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + throw new ResponseHeaderException('Headers are read-only.'); + } + + /** + * @return array + */ + public function toArray(): array + { + // init + $return = []; + + $that = clone $this; + + foreach ($that as $key => $value) { + if (\is_array($value)) { + foreach ($value as $keyInner => $valueInner) { + $value[$keyInner] = \trim($valueInner, " \t"); + } + } + + $return[$key] = $value; + } + + return $return; + } + + /** + * Make sure the header complies with RFC 7230. + * + * Header names must be a non-empty string consisting of token characters. + * + * Header values must be strings consisting of visible characters with all optional + * leading and trailing whitespace stripped. This method will always strip such + * optional whitespace. Note that the method does not allow folding whitespace within + * the values as this was deprecated for almost all instances by the RFC. + * + * header-field = field-name ":" OWS field-value OWS + * field-name = 1*( "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" + * / "_" / "`" / "|" / "~" / %x30-39 / ( %x41-5A / %x61-7A ) ) + * OWS = *( SP / HTAB ) + * field-value = *( ( %x21-7E / %x80-FF ) [ 1*( SP / HTAB ) ( %x21-7E / %x80-FF ) ] ) + * + * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 + * + * @param mixed $header + * @param mixed $values + * + * @return string[] + */ + private function _validateAndTrimHeader($header, $values): array + { + if ( + !\is_string($header) + || + \preg_match("@^[!#$%&'*+.^_`|~0-9A-Za-z-]+$@", $header) !== 1 + ) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string: ' . \print_r($header, true)); + } + + if (!\is_array($values)) { + // This is simple, just one value. + if ( + (!\is_numeric($values) && !\is_string($values)) + || + \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $values) !== 1 + ) { + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings: ' . \print_r($header, true)); + } + + return [\trim((string) $values, " \t")]; + } + + if (empty($values)) { + throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given.'); + } + + // Assert Non empty array + $returnValues = []; + foreach ($values as $v) { + if ( + (!\is_numeric($v) && !\is_string($v)) + || + \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $v) !== 1 + ) { + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings: ' . \print_r($v, true)); + } + + $returnValues[] = \trim((string) $v, " \t"); + } + + return $returnValues; + } + + /** + * Set data at a specified offset. Converts the offset to lowercase, and + * stores the case-sensitive offset and the data at the lowercase indexes in + * $this->keys and @this->data. + * + * @see https://secure.php.net/manual/en/arrayaccess.offsetset.php + * + * @param string|null $offset the offset to store the data at (case-insensitive) + * @param mixed $value the data to store at the specified offset + * + * @return void + */ + private function offsetSetForce($offset, $value) + { + if ($offset === null) { + $this->data[] = $value; + } else { + $offsetlower = \strtolower($offset); + $this->data[$offsetlower] = $value; + $this->keys[$offsetlower] = $offset; + } + } + + /** + * Unsets the specified offset. Converts the provided offset to lowercase, + * and unsets the case-sensitive key, as well as the stored data. + * + * @see https://secure.php.net/manual/en/arrayaccess.offsetunset.php + * + * @param string $offset the offset to unset + * + * @return void + */ + private function offsetUnsetForce($offset) + { + $offsetLower = \strtolower($offset); + + unset($this->data[$offsetLower], $this->keys[$offsetLower]); + } +} diff --git a/src/Httpful/Http.php b/src/Httpful/Http.php index 1c9aa0d..105578c 100644 --- a/src/Httpful/Http.php +++ b/src/Httpful/Http.php @@ -1,86 +1,280 @@ - */ +use Httpful\Exception\ResponseException; +use Psr\Http\Message\StreamInterface; + class Http { - const HEAD = 'HEAD'; - const GET = 'GET'; - const POST = 'POST'; - const PUT = 'PUT'; - const DELETE = 'DELETE'; - const PATCH = 'PATCH'; - const OPTIONS = 'OPTIONS'; - const TRACE = 'TRACE'; + const DELETE = 'DELETE'; + + const GET = 'GET'; + + const HEAD = 'HEAD'; + + const OPTIONS = 'OPTIONS'; + + const PATCH = 'PATCH'; + + const POST = 'POST'; + + const PUT = 'PUT'; + + const TRACE = 'TRACE'; + + const HTTP_1_0 = '1.0'; + + const HTTP_1_1 = '1.1'; + + const HTTP_2_0 = '2'; /** - * @return array of HTTP method strings + * @return array */ - public static function safeMethods() + public static function allMethods(): array { - return array(self::HEAD, self::GET, self::OPTIONS, self::TRACE); + return [ + self::HEAD, + self::POST, + self::GET, + self::PUT, + self::DELETE, + self::OPTIONS, + self::TRACE, + self::PATCH, + ]; } /** - * @param string HTTP method - * @return bool + * @return array list of (always) idempotent HTTP methods */ - public static function isSafeMethod($method) + public static function idempotentMethods(): array { - return in_array($method, self::safeMethods()); + return [ + self::HEAD, + self::GET, + self::PUT, + self::DELETE, + self::OPTIONS, + self::TRACE, + self::PATCH, + ]; } /** - * @param string HTTP method + * @param string $method HTTP method + * * @return bool */ - public static function isUnsafeMethod($method) + public static function isIdempotent($method): bool { - return !in_array($method, self::safeMethods()); + return \in_array($method, self::idempotentMethods(), true); } /** - * @return array list of (always) idempotent HTTP methods + * @param string $method HTTP method + * + * @return bool */ - public static function idempotentMethods() + public static function isNotIdempotent($method): bool { - // Though it is possible to be idempotent, POST - // is not guarunteed to be, and more often than - // not, it is not. - return array(self::HEAD, self::GET, self::PUT, self::DELETE, self::OPTIONS, self::TRACE, self::PATCH); + return !\in_array($method, self::idempotentMethods(), true); } /** - * @param string HTTP method + * @param string $method HTTP method + * * @return bool */ - public static function isIdempotent($method) + public static function isSafeMethod($method): bool { - return in_array($method, self::safeidempotentMethodsMethods()); + return \in_array($method, self::safeMethods(), true); } /** - * @param string HTTP method + * @param string $method HTTP method + * * @return bool */ - public static function isNotIdempotent($method) + public static function isUnsafeMethod($method): bool { - return !in_array($method, self::idempotentMethods()); + return !\in_array($method, self::safeMethods(), true); } /** - * @deprecated Technically anything *can* have a body, - * they just don't have semantic meaning. So say's Roy - * http://tech.groups.yahoo.com/group/rest-discuss/message/9962 + * @param int $code + * + * @throws \Exception * + * @return string + */ + public static function reason(int $code): string + { + $codes = self::responseCodes(); + + if (!\array_key_exists($code, $codes)) { + throw new ResponseException('Unable to parse response code from HTTP response due to malformed response. Code: ' . $code); + } + + return $codes[$code]; + } + + /** + * @param int $code + * + * @return bool + */ + public static function responseCodeExists(int $code): bool + { + return \array_key_exists($code, self::responseCodes()); + } + + /** * @return array of HTTP method strings */ - public static function canHaveBody() + public static function safeMethods(): array { - return array(self::POST, self::PUT, self::PATCH, self::OPTIONS); + return [ + self::HEAD, + self::GET, + self::OPTIONS, + self::TRACE, + ]; } -} \ No newline at end of file + /** + * Create a new stream based on the input type. + * + * Options is an associative array that can contain the following keys: + * - metadata: Array of custom metadata. + * - size: Size of the stream. + * + * @param mixed $resource + * @param array $options + * + * @throws \InvalidArgumentException if the $resource arg is not valid + * + * @return StreamInterface + */ + public static function stream($resource = '', array $options = []): StreamInterface + { + // init + $options['serialized'] = false; + + if (\is_array($resource)) { + $resource = \serialize($resource); + + $options['serialized'] = true; + } + + if (\is_scalar($resource)) { + $stream = \fopen('php://temp', 'r+b'); + + if (!\is_resource($stream)) { + throw new \RuntimeException('fopen must create a resource'); + } + + if ($resource !== '') { + \fwrite($stream, (string) $resource); + \fseek($stream, 0); + } + + return new Stream($stream, $options); + } + + switch (\gettype($resource)) { + case 'resource': + return new Stream($resource, $options); + case 'object': + if ($resource instanceof StreamInterface) { + return $resource; + } + + if (\method_exists($resource, '__toString')) { + return self::stream((string) $resource, $options); + } + + break; + case 'NULL': + $stream = \fopen('php://temp', 'r+b'); + + if (!\is_resource($stream)) { + throw new \RuntimeException('fopen must create a resource'); + } + + return new Stream($stream, $options); + } + + throw new \InvalidArgumentException('Invalid resource type: ' . \gettype($resource)); + } + + /** + * get all response-codes + * + * @return array + */ + private static function responseCodes(): array + { + return [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => 'Switch Proxy', + 307 => 'Temporary Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Requested Range Not Satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 429 => 'Too Many Requests', + 449 => 'Retry With', + 450 => 'Blocked by Windows Parental Controls', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 509 => 'Bandwidth Limit Exceeded', + 510 => 'Not Extended', + ]; + } +} diff --git a/src/Httpful/Httpful.php b/src/Httpful/Httpful.php deleted file mode 100644 index e46053d..0000000 --- a/src/Httpful/Httpful.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ class Mime { - const JSON = 'application/json'; - const XML = 'application/xml'; - const XHTML = 'application/html+xml'; - const FORM = 'application/x-www-form-urlencoded'; - const UPLOAD = 'multipart/form-data'; - const PLAIN = 'text/plain'; - const JS = 'text/javascript'; - const HTML = 'text/html'; - const YAML = 'application/x-yaml'; - const CSV = 'text/csv'; + const CSV = 'text/csv'; + + const FORM = 'application/x-www-form-urlencoded'; + + const HTML = 'text/html'; + + const JS = 'text/javascript'; + + const JSON = 'application/json'; + + const PLAIN = 'text/plain'; + + const UPLOAD = 'multipart/form-data'; + + const XHTML = 'application/html+xml'; + + const XML = 'application/xml'; + + const YAML = 'application/x-yaml'; /** - * Map short name for a mime type - * to a full proper mime type + * Map short name for a mime type to a full proper mime type. + * + * @var array */ - public static $mimes = array( - 'json' => self::JSON, - 'xml' => self::XML, - 'form' => self::FORM, - 'plain' => self::PLAIN, - 'text' => self::PLAIN, - 'upload' => self::UPLOAD, - 'html' => self::HTML, - 'xhtml' => self::XHTML, - 'js' => self::JS, - 'javascript'=> self::JS, - 'yaml' => self::YAML, - 'csv' => self::CSV, - ); + private static $mimes = [ + 'json' => self::JSON, + 'xml' => self::XML, + 'form' => self::FORM, + 'plain' => self::PLAIN, + 'text' => self::PLAIN, + 'upload' => self::UPLOAD, + 'html' => self::HTML, + 'xhtml' => self::XHTML, + 'js' => self::JS, + 'javascript' => self::JS, + 'yaml' => self::YAML, + 'csv' => self::CSV, + ]; /** * Get the full Mime Type name from a "short name". * Returns the short if no mapping was found. + * * @param string $short_name common name for mime type (e.g. json) + * * @return string full mime type (e.g. application/json) */ - public static function getFullMime($short_name) + public static function getFullMime($short_name): string { - return array_key_exists($short_name, self::$mimes) ? self::$mimes[$short_name] : $short_name; + if (\array_key_exists($short_name, self::$mimes)) { + return self::$mimes[$short_name]; + } + + return $short_name; } /** * @param string $short_name + * * @return bool */ - public static function supportsMimeType($short_name) + public static function supportsMimeType($short_name): bool { - return array_key_exists($short_name, self::$mimes); + return \array_key_exists($short_name, self::$mimes); } } diff --git a/src/Httpful/Proxy.php b/src/Httpful/Proxy.php index 4ab9ea6..cb7a376 100644 --- a/src/Httpful/Proxy.php +++ b/src/Httpful/Proxy.php @@ -1,16 +1,26 @@ - * - * @method self sendsJson() - * @method self sendsXml() - * @method self sendsForm() - * @method self sendsPlain() - * @method self sendsText() - * @method self sendsUpload() - * @method self sendsHtml() - * @method self sendsXhtml() - * @method self sendsJs() - * @method self sendsJavascript() - * @method self sendsYaml() - * @method self sendsCsv() - * @method self expectsJson() - * @method self expectsXml() - * @method self expectsForm() - * @method self expectsPlain() - * @method self expectsText() - * @method self expectsUpload() - * @method self expectsHtml() - * @method self expectsXhtml() - * @method self expectsJs() - * @method self expectsJavascript() - * @method self expectsYaml() - * @method self expectsCsv() - */ -class Request -{ +use Httpful\Curl\Curl; +use Httpful\Curl\MultiCurl; +use Httpful\Exception\ClientErrorException; +use Httpful\Exception\NetworkErrorException; +use Httpful\Exception\RequestException; +use Psr\Http\Message\MessageInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; +use Psr\Log\LoggerInterface; +use voku\helper\UTF8; + +class Request implements \IteratorAggregate, RequestInterface +{ + const MAX_REDIRECTS_DEFAULT = 25; + + const SERIALIZE_PAYLOAD_ALWAYS = 1; + + const SERIALIZE_PAYLOAD_NEVER = 0; + + const SERIALIZE_PAYLOAD_SMART = 2; + + /** + * "Request"-template object + * + * @var Request|null + */ + private $template; + + /** + * @var array + */ + private $helperData = []; + + /** + * @var UriInterface|null + */ + private $uri; + + /** + * @var string + */ + private $uri_cache; + + /** + * @var string + */ + private $ssl_key = ''; + + /** + * @var string + */ + private $ssl_cert = ''; + + /** + * @var string + */ + private $ssl_key_type = ''; + + /** + * @var string|null + */ + private $ssl_passphrase; + + /** + * @var float|int|null + */ + private $timeout; + + /** + * @var float|int|null + */ + private $connection_timeout; + + /** + * @var string + */ + private $method = Http::GET; + + /** + * @var Headers + */ + private $headers; + + /** + * @var string + */ + private $raw_headers = ''; + + /** + * @var bool + */ + private $strict_ssl = false; + + /** + * @var string + */ + private $cache_control = ''; + + /** + * @var string + */ + private $content_type = ''; + + /** + * @var string + */ + private $content_charset = ''; + + /** + * @var string + *

e.g.: "gzip" or "deflate"

+ */ + private $content_encoding = ''; + + /** + * @var int|null + *

e.g.: 80 or 443

+ */ + private $port; + + /** + * @var int + */ + private $keep_alive = 300; + + /** + * @var string + */ + private $expected_type = ''; + + /** + * @var array + */ + private $additional_curl_opts = []; + + /** + * @var bool + */ + private $auto_parse = true; + + /** + * @var int + */ + private $serialize_payload_method = self::SERIALIZE_PAYLOAD_SMART; + + /** + * @var string + */ + private $username = ''; + + /** + * @var string + */ + private $password = ''; + + /** + * @var string|null + */ + private $serialized_payload; + + /** + * @var \CURLFile[]|string|string[] + */ + private $payload = []; + + /** + * @var array + */ + private $params = []; + + /** + * @var callable|null + */ + private $parse_callback; + + /** + * @var callable|LoggerInterface|null + */ + private $error_handler; + + /** + * @var callable[] + */ + private $send_callbacks = []; + + /** + * @var bool + */ + private $follow_redirects = false; + + /** + * @var int + */ + private $max_redirects = self::MAX_REDIRECTS_DEFAULT; + + /** + * @var array + */ + private $payload_serializers = []; + + /** + * Curl Object + * + * @var Curl|null + */ + private $curl; + + /** + * MultiCurl Object + * + * @var MultiCurl|null + */ + private $curlMulti; + + /** + * @var bool + */ + private $debug = false; + + /** + * @var string + */ + private $protocol_version = Http::HTTP_1_1; + + /** + * @var bool + */ + private $retry_by_possible_encoding_error = false; + + /** + * @var callable|string|null + */ + private $file_path_for_download; + + /** + * The Client::get, Client::post, ... syntax is preferred as it is more readable. + * + * @param string|null $method Http Method + * @param string|null $mime Mime Type to Use + * @param static|null $template "Request"-template object + */ + public function __construct( + string $method = null, + string $mime = null, + self $template = null + ) { + $this->initialize(); + + $this->template = $template; + $this->headers = new Headers(); + + // fallback + if (!isset($this->template)) { + $this->template = new static(Http::GET, null, $this); + $this->template = $this->template->disableStrictSSL(); + } + + $this->_setDefaultsFromTemplate() + ->_setMethod($method) + ->_withContentType($mime, Mime::PLAIN) + ->_withExpectedType($mime, Mime::PLAIN); + } + + /** + * Does the heavy lifting. Uses de facto HTTP + * library cURL to set up the HTTP request. + * Note: It does NOT actually send the request + * + * @throws \Exception + * + * @return static + * + * @internal + */ + public function _curlPrep(): self + { + // Check for required stuff. + if ($this->uri === null) { + throw new RequestException($this, 'Attempting to send a request before defining a URI endpoint.'); + } + + // init + $this->initialize(); + \assert($this->curl instanceof Curl); + + if ($this->params === []) { + $this->_uriPrep(); + } + + if ($this->payload === []) { + $this->serialized_payload = null; + } else { + $this->serialized_payload = $this->_serializePayload($this->payload); + + if ( + $this->serialized_payload + && + $this->content_charset + && + !$this->isUpload() + ) { + $this->serialized_payload = UTF8::encode( + $this->content_charset, + (string) $this->serialized_payload + ); + } + } + + if ($this->send_callbacks !== []) { + foreach ($this->send_callbacks as $callback) { + /** @noinspection VariableFunctionsUsageInspection */ + \call_user_func($callback, $this); + } + } + + \assert($this->curl instanceof Curl); + + $this->curl->setUrl((string) $this->uri); + + $ch = $this->curl->getCurl(); + if ($ch === false) { + throw new NetworkErrorException('Unable to connect to "' . $this->uri . '". => "curl_init" === false'); + } + + $this->curl->setOpt(\CURLOPT_IPRESOLVE, \CURL_IPRESOLVE_WHATEVER); + + if ($this->method === Http::POST) { + // Use CURLOPT_POST to have browser-like POST-to-GET redirects for 301, 302 and 303 + $this->curl->setOpt(\CURLOPT_POST, true); + } else { + $this->curl->setOpt(\CURLOPT_CUSTOMREQUEST, $this->method); + } + + if ($this->method === Http::HEAD) { + $this->curl->setOpt(\CURLOPT_NOBODY, true); + } + + if ($this->hasBasicAuth()) { + $this->curl->setOpt(\CURLOPT_USERPWD, $this->username . ':' . $this->password); + } + + if ($this->hasClientSideCert()) { + if (!\file_exists($this->ssl_key)) { + throw new RequestException($this, 'Could not read Client Key'); + } + + if (!\file_exists($this->ssl_cert)) { + throw new RequestException($this, 'Could not read Client Certificate'); + } + + $this->curl->setOpt(\CURLOPT_SSLCERTTYPE, $this->ssl_key_type); + $this->curl->setOpt(\CURLOPT_SSLKEYTYPE, $this->ssl_key_type); + $this->curl->setOpt(\CURLOPT_SSLCERT, $this->ssl_cert); + $this->curl->setOpt(\CURLOPT_SSLKEY, $this->ssl_key); + if ($this->ssl_passphrase !== null) { + $this->curl->setOpt(\CURLOPT_SSLKEYPASSWD, $this->ssl_passphrase); + } + } + + $this->curl->setOpt(\CURLOPT_TCP_NODELAY, true); + + if ($this->hasTimeout()) { + $this->curl->setOpt(\CURLOPT_TIMEOUT_MS, \round($this->timeout * 1000)); + } + + if ($this->hasConnectionTimeout()) { + $this->curl->setOpt(\CURLOPT_CONNECTTIMEOUT_MS, \round($this->connection_timeout * 1000)); + + if (\DIRECTORY_SEPARATOR !== '\\' && $this->connection_timeout < 1) { + $this->curl->setOpt(\CURLOPT_NOSIGNAL, true); + } + } + + if ($this->follow_redirects === true) { + $this->curl->setOpt(\CURLOPT_FOLLOWLOCATION, true); + $this->curl->setOpt(\CURLOPT_MAXREDIRS, $this->max_redirects); + } + + $this->curl->setOpt(\CURLOPT_SSL_VERIFYPEER, $this->strict_ssl); + // zero is safe for all curl versions + $verifyValue = $this->strict_ssl + 0; + // support for value 1 removed in cURL 7.28.1 value 2 valid in all versions + if ($verifyValue > 0) { + ++$verifyValue; + } + $this->curl->setOpt(\CURLOPT_SSL_VERIFYHOST, $verifyValue); + + $this->curl->setOpt(\CURLOPT_RETURNTRANSFER, true); + + $this->curl->setOpt(\CURLOPT_ENCODING, $this->content_encoding); + + if ($this->port !== null) { + $this->curl->setOpt(\CURLOPT_PORT, $this->port); + } + + $this->curl->setOpt(\CURLOPT_PROTOCOLS, \CURLPROTO_HTTP | \CURLPROTO_HTTPS); + + $this->curl->setOpt(\CURLOPT_REDIR_PROTOCOLS, \CURLPROTO_HTTP | \CURLPROTO_HTTPS); + + // set Content-Length to the size of the payload if present + if ($this->serialized_payload) { + $this->curl->setOpt(\CURLOPT_POSTFIELDS, $this->serialized_payload); + + if (!$this->isUpload()) { + $this->headers->forceSet('Content-Length', $this->_determineLength($this->serialized_payload)); + } + } + + // init + $headers = []; + + // Solve a bug on squid proxy, NONE/411 when miss content length. + if ( + !$this->headers->offsetExists('Content-Length') + && + !$this->isUpload() + ) { + $this->headers->forceSet('Content-Length', 0); + } + + foreach ($this->headers as $header => $value) { + if (\is_array($value)) { + foreach ($value as $valueInner) { + $headers[] = "{$header}: {$valueInner}"; + } + } else { + $headers[] = "{$header}: {$value}"; + } + } + + if ($this->keep_alive) { + $headers[] = 'Connection: Keep-Alive'; + $headers[] = 'Keep-Alive: ' . $this->keep_alive; + } else { + $headers[] = 'Connection: close'; + } + + if (!$this->headers->offsetExists('User-Agent')) { + $headers[] = $this->buildUserAgent(); + } + + if ($this->content_charset) { + $contentType = $this->content_type . '; charset=' . $this->content_charset; + } else { + $contentType = $this->content_type; + } + $headers[] = 'Content-Type: ' . $contentType; + + if ($this->cache_control) { + $headers[] = 'Cache-Control: ' . $this->cache_control; + } + + // allow custom Accept header if set + if (!$this->headers->offsetExists('Accept')) { + // http://pretty-rfc.herokuapp.com/RFC2616#header.accept + $accept = 'Accept: */*; q=0.5, text/plain; q=0.8, text/html;level=3;'; + + if (!empty($this->expected_type)) { + $accept .= 'q=0.9, ' . $this->expected_type; + } + + $headers[] = $accept; + } + + $url = \parse_url((string) $this->uri); + + if (\is_array($url) === false) { + throw new ClientErrorException('Unable to connect to "' . $this->uri . '". => "parse_url" === false'); + } + + $path = ($url['path'] ?? '/') . (isset($url['query']) ? '?' . $url['query'] : ''); + $this->raw_headers = "{$this->method} {$path} HTTP/{$this->protocol_version}\r\n"; + $this->raw_headers .= \implode("\r\n", $headers); + $this->raw_headers .= "\r\n"; + + // DEBUG + //var_dump($this->_headers->toArray(), $this->_raw_headers); + + /** @noinspection AlterInForeachInspection */ + foreach ($headers as &$header) { + $pos_tmp = \strpos($header, ': '); + if ( + $pos_tmp !== false + && + \strlen($header) - 2 === $pos_tmp + ) { + // curl requires a special syntax to send empty headers + $header = \substr_replace($header, ';', -2); + } + } + $this->curl->setOpt(\CURLOPT_HTTPHEADER, $headers); + + if ($this->debug) { + $this->curl->setOpt(\CURLOPT_VERBOSE, true); + } + + // If there are some additional curl opts that the user wants to set, we can tack them in here. + foreach ($this->additional_curl_opts as $curlOpt => $curlVal) { + $this->curl->setOpt($curlOpt, $curlVal); + } + + switch ($this->protocol_version) { + case Http::HTTP_1_0: + $this->curl->setOpt(\CURLOPT_HTTP_VERSION, \CURL_HTTP_VERSION_1_0); + + break; + case Http::HTTP_1_1: + $this->curl->setOpt(\CURLOPT_HTTP_VERSION, \CURL_HTTP_VERSION_1_1); + + break; + case Http::HTTP_2_0: + $this->curl->setOpt(\CURLOPT_HTTP_VERSION, \CURL_HTTP_VERSION_2_0); + + break; + default: + $this->curl->setOpt(\CURLOPT_HTTP_VERSION, \CURL_HTTP_VERSION_NONE); + + break; + } + + if ($this->file_path_for_download) { + $this->curl->download($this->file_path_for_download); + $this->curl->setOpt(\CURLOPT_CUSTOMREQUEST, 'GET'); + $this->curl->setOpt(\CURLOPT_HTTPGET, true); + $this->disableAutoParsing(); + } + + return $this; + } + + /** + * @return Curl|null + */ + public function _curl() + { + return $this->curl; + } + + /** + * @return MultiCurl|null + */ + public function _curlMulti() + { + return $this->curlMulti; + } + + /** + * Takes care of building the query string to be used in the request URI. + * + * Any existing query string parameters, either passed as part of the URI + * via uri() method, or passed via get() and friends will be preserved, + * with additional parameters (added via params() or param()) appended. + * + * @internal + * + * @return void + */ + public function _uriPrep() + { + if ($this->uri === null) { + throw new ClientErrorException('Unable to connect. => "uri" === null'); + } + + $url = \parse_url((string) $this->uri); + $originalParams = []; + + if ($url !== false) { + if ( + isset($url['query']) + && + $url['query'] + ) { + \parse_str($url['query'], $originalParams); + } + + $params = \array_merge($originalParams, $this->params); + } else { + $params = $this->params; + } + + $queryString = \http_build_query($params); + + if (\strpos((string) $this->uri, '?') !== false) { + $this->_withUri( + $this->uri->withQuery( + \substr( + (string) $this->uri, + 0, + \strpos((string) $this->uri, '?') + ) + ) + ); + } + + if (\count($params)) { + $this->_withUri($this->uri->withQuery($queryString)); + } + } + + /** + * Callback invoked after payload has been serialized but before the request has been built. + * + * @param callable $callback (Request $request) + * + * @return static + */ + public function beforeSend(callable $callback): self + { + $this->send_callbacks[] = $callback; + + return $this; + } + + /** + * @return string + */ + public function buildUserAgent(): string + { + $user_agent = 'User-Agent: Http/PhpClient (cURL/'; + $curl = \curl_version(); + + if ($curl && isset($curl['version'])) { + $user_agent .= $curl['version']; + } else { + $user_agent .= '?.?.?'; + } + + $user_agent .= ' PHP/' . \PHP_VERSION . ' (' . \PHP_OS . ')'; + + if (isset($_SERVER['SERVER_SOFTWARE'])) { + $tmp = \preg_replace('~PHP/[\d\.]+~U', '', $_SERVER['SERVER_SOFTWARE']); + if (\is_string($tmp)) { + $user_agent .= ' ' . $tmp; + } + } else { + if (isset($_SERVER['TERM_PROGRAM'])) { + $user_agent .= " {$_SERVER['TERM_PROGRAM']}"; + } + + if (isset($_SERVER['TERM_PROGRAM_VERSION'])) { + $user_agent .= "/{$_SERVER['TERM_PROGRAM_VERSION']}"; + } + } + + if (isset($_SERVER['HTTP_USER_AGENT'])) { + $user_agent .= " {$_SERVER['HTTP_USER_AGENT']}"; + } + + $user_agent .= ')'; + + return $user_agent; + } + + /** + * Use Client Side Cert Authentication + * + * @param string $key file path to client key + * @param string $cert file path to client cert + * @param string|null $passphrase for client key + * @param string $ssl_key_type default PEM + * + * @return static + */ + public function clientSideCertAuth($cert, $key, $passphrase = null, $ssl_key_type = 'PEM'): self + { + $this->ssl_cert = $cert; + $this->ssl_key = $key; + $this->ssl_key_type = $ssl_key_type; + $this->ssl_passphrase = $passphrase; + + return $this; + } + + /** + * @see Request::initialize() + * + * @return void + */ + public function close() + { + if ($this->curl && $this->hasBeenInitialized()) { + $this->curl->close(); + } + + if ($this->curlMulti && $this->hasBeenInitializedMulti()) { + $this->curlMulti->close(); + } + } + + /** + * HTTP Method Get + * + * @param string|UriInterface $uri + * @param string $file_path + * + * @return static + */ + public static function download($uri, $file_path): self + { + if ($uri instanceof UriInterface) { + $uri = (string) $uri; + } + + return (new self(Http::GET)) + ->withUriFromString($uri) + ->withDownload($file_path) + ->withCacheControl('no-cache') + ->withContentEncoding(Encoding::NONE); + } + + /** + * HTTP Method Delete + * + * @param string|UriInterface $uri + * @param array|null $params + * @param string|null $mime + * + * @return static + */ + public static function delete($uri, array $params = null, string $mime = null): self + { + if ($uri instanceof UriInterface) { + $uri = (string) $uri; + } + + $paramsString = ''; + if ($params !== null) { + $paramsString = \http_build_query( + $params, + '', + '&', + \PHP_QUERY_RFC3986 + ); + if ($paramsString) { + $paramsString = (\strpos($uri, '?') !== false ? '&' : '?') . $paramsString; + } + } + + return (new self(Http::DELETE)) + ->withUriFromString($uri . $paramsString) + ->withMimeType($mime); + } + + /** + * @return static + * + * @see Request::enableAutoParsing() + */ + public function disableAutoParsing(): self + { + return $this->_autoParse(false); + } + + /** + * @return static + * + * @see Request::enableKeepAlive() + */ + public function disableKeepAlive(): self + { + $this->keep_alive = 0; + + return $this; + } + + /** + * @return static + */ + public function disableRetryByPossibleEncodingError(): self + { + $this->retry_by_possible_encoding_error = false; + + return $this; + } + + /** + * @return static + * + * @see Request::enableStrictSSL() + */ + public function disableStrictSSL(): self + { + return $this->_strictSSL(false); + } + + /** + * @return static + * + * @see Request::followRedirects() + */ + public function doNotFollowRedirects(): self + { + return $this->followRedirects(false); + } + + /** + * @return static + * + * @see Request::disableAutoParsing() + */ + public function enableAutoParsing(): self + { + return $this->_autoParse(true); + } + + /** + * @param int $seconds + * + * @return static + * + * @see Request::disableKeepAlive() + */ + public function enableKeepAlive(int $seconds = 300): self + { + if ($seconds <= 0) { + throw new \InvalidArgumentException( + 'Invalid keep-alive input: ' . \var_export($seconds, true) + ); + } + + $this->keep_alive = $seconds; + + return $this; + } + + /** + * @return static + */ + public function enableRetryByPossibleEncodingError(): self + { + $this->retry_by_possible_encoding_error = true; + + return $this; + } + + /** + * @return static + * + * @see Request::disableStrictSSL() + */ + public function enableStrictSSL(): self + { + return $this->_strictSSL(true); + } + + /** + * @return static + */ + public function expectsCsv(): self + { + return $this->withExpectedType(Mime::CSV); + } + + /** + * @return static + */ + public function expectsForm(): self + { + return $this->withExpectedType(Mime::FORM); + } + + /** + * @return static + */ + public function expectsHtml(): self + { + return $this->withExpectedType(Mime::HTML); + } + + /** + * @return static + */ + public function expectsJavascript(): self + { + return $this->withExpectedType(Mime::JS); + } + + /** + * @return static + */ + public function expectsJs(): self + { + return $this->withExpectedType(Mime::JS); + } + + /** + * @return static + */ + public function expectsJson(): self + { + return $this->withExpectedType(Mime::JSON); + } + + /** + * @return static + */ + public function expectsPlain(): self + { + return $this->withExpectedType(Mime::PLAIN); + } + + /** + * @return static + */ + public function expectsText(): self + { + return $this->withExpectedType(Mime::PLAIN); + } + + /** + * @return static + */ + public function expectsUpload(): self + { + return $this->withExpectedType(Mime::UPLOAD); + } + + /** + * @return static + */ + public function expectsXhtml(): self + { + return $this->withExpectedType(Mime::XHTML); + } + + /** + * @return static + */ + public function expectsXml(): self + { + return $this->withExpectedType(Mime::XML); + } + + /** + * @return static + */ + public function expectsYaml(): self + { + return $this->withExpectedType(Mime::YAML); + } + + /** + * If the response is a 301 or 302 redirect, automatically + * send off another request to that location + * + * @param bool $follow follow or not to follow or maximal number of redirects + * + * @return static + */ + public function followRedirects(bool $follow = true): self + { + $new = clone $this; + + if ($follow === true) { + $new->max_redirects = static::MAX_REDIRECTS_DEFAULT; + } elseif ($follow === false) { + $new->max_redirects = 0; + } else { + $new->max_redirects = \max(0, $follow); + } + + $new->follow_redirects = $follow; + + return $new; + } + + /** + * HTTP Method Get + * + * @param string|UriInterface $uri + * @param array|null $params + * @param string $mime + * + * @return static + */ + public static function get($uri, array $params = null, string $mime = null): self + { + if ($uri instanceof UriInterface) { + $uri = (string) $uri; + } + + $paramsString = ''; + if ($params !== null) { + $paramsString = \http_build_query( + $params, + '', + '&', + \PHP_QUERY_RFC3986 + ); + if ($paramsString) { + $paramsString = (\strpos($uri, '?') !== false ? '&' : '?') . $paramsString; + } + } + + return (new self(Http::GET)) + ->withUriFromString($uri . $paramsString) + ->withMimeType($mime); + } + + /** + * Gets the body of the message. + * + * @return StreamInterface returns the body as a stream + */ + public function getBody(): StreamInterface + { + return Http::stream($this->payload); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name case-insensitive header field name + * + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($name): array + { + if ($this->headers->offsetExists($name)) { + $value = $this->headers->offsetGet($name); + + if (!\is_array($value)) { + return [\trim($value, " \t")]; + } + + foreach ($value as $keyInner => $valueInner) { + $value[$keyInner] = \trim($valueInner, " \t"); + } + + return $value; + } + + return []; + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name case-insensitive header field name + * + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine($name): string + { + return \implode(', ', $this->getHeader($name)); + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers->toArray(); + } + + /** + * Retrieves the HTTP method of the request. + * + * @return string returns the request method + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * Retrieves the HTTP protocol version as a string. + * + * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0"). + * + * @return string HTTP protocol version + */ + public function getProtocolVersion(): string + { + return $this->protocol_version; + } + + /** + * Retrieves the message's request target. + * + * Retrieves the message's request-target either as it will appear (for + * clients), as it appeared at request (for servers), or as it was + * specified for the instance (see withRequestTarget()). + * + * In most cases, this will be the origin-form of the composed URI, + * unless a value was provided to the concrete implementation (see + * withRequestTarget() below). + * + * If no URI is available, and no request-target has been specifically + * provided, this method MUST return the string "/". + * + * @return string + */ + public function getRequestTarget(): string + { + if ($this->uri === null) { + return '/'; + } + + $target = $this->uri->getPath(); + + if (!$target) { + $target = '/'; + } + + if ($this->uri->getQuery() !== '') { + $target .= '?' . $this->uri->getQuery(); + } + + return $target; + } + + /** + * @return null|Uri|UriInterface + */ + public function getUriOrNull(): ?UriInterface + { + return $this->uri; + } + + /** + * @return Uri|UriInterface + */ + public function getUri(): UriInterface + { + if ($this->uri === null) { + throw new RequestException($this, 'URI is not set.'); + } + + return $this->uri; + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name case-insensitive header field name + * + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader($name): bool + { + return $this->headers->offsetExists($name); + } + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name case-insensitive header field name to add + * @param string|string[] $value header value(s) + * + * @throws \InvalidArgumentException for invalid header names or values + * + * @return static + */ + public function withAddedHeader($name, $value): MessageInterface + { + if (!\is_string($name) || $name === '') { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + } + + $new = clone $this; + + if (!\is_array($value)) { + $value = [$value]; + } + + if ($new->headers->offsetExists($name)) { + $new->headers->forceSet($name, \array_merge_recursive($new->headers->offsetGet($name), $value)); + } else { + $new->headers->forceSet($name, $value); + } + + return $new; + } + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body + * + * @throws \InvalidArgumentException when the body is not valid + * + * @return static + */ + public function withBody(StreamInterface $body): MessageInterface + { + $stream = Http::stream($body); + + return (clone $this)->_setBody($stream, null); + } + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name case-insensitive header field name + * @param string|string[] $value header value(s) + * + * @throws \InvalidArgumentException for invalid header names or values + * + * @return static + */ + public function withHeader($name, $value): self + { + $new = clone $this; + + if (!\is_array($value)) { + $value = [$value]; + } + + $new->headers->forceSet($name, $value); + + return $new; + } + + /** + * Return an instance with the provided HTTP method. + * + * While HTTP method names are typically all uppercase characters, HTTP + * method names are case-sensitive and thus implementations SHOULD NOT + * modify the given string. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request method. + * + * @param string $method + *

\Httpful\Http::GET, \Httpful\Http::POST, ...

+ * + * @throws \InvalidArgumentException for invalid HTTP methods + * + * @return static + */ + public function withMethod($method): RequestInterface + { + $new = clone $this; + + $new->_setMethod($method); + + return $new; + } + + /** + * Return an instance with the specified HTTP protocol version. + * + * The version string MUST contain only the HTTP version number (e.g., + * "2, 1.1", "1.0"). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new protocol version. + * + * @param string $version + *

Http::HTTP_*

+ * + * @return static + */ + public function withProtocolVersion($version): MessageInterface + { + $new = clone $this; + + $new->protocol_version = $version; + + return $new; + } + + /** + * Return an instance with the specific request-target. + * + * If the request needs a non-origin-form request-target — e.g., for + * specifying an absolute-form, authority-form, or asterisk-form — + * this method may be used to create an instance with the specified + * request-target, verbatim. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request target. + * + * @see http://tools.ietf.org/html/rfc7230#section-5.3 (for the various + * request-target forms allowed in request messages) + * + * @param mixed $requestTarget + * + * @return static + */ + public function withRequestTarget($requestTarget): RequestInterface + { + if (\preg_match('#\\s#', $requestTarget)) { + throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace'); + } + + $new = clone $this; + + if ($new->uri !== null) { + $new->_withUri($new->uri->withPath($requestTarget)); + } + + return $new; + } + + /** + * Returns an instance with the provided URI. + * + * This method MUST update the Host header of the returned request by + * default if the URI contains a host component. If the URI does not + * contain a host component, any pre-existing Host header MUST be carried + * over to the returned request. + * + * You can opt-in to preserving the original state of the Host header by + * setting `$preserveHost` to `true`. When `$preserveHost` is set to + * `true`, this method interacts with the Host header in the following ways: + * + * - If the Host header is missing or empty, and the new URI contains + * a host component, this method MUST update the Host header in the returned + * request. + * - If the Host header is missing or empty, and the new URI does not contain a + * host component, this method MUST NOT update the Host header in the returned + * request. + * - If a Host header is present and non-empty, this method MUST NOT update + * the Host header in the returned request. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new UriInterface instance. + * + * @see http://tools.ietf.org/html/rfc3986#section-4.3 + * + * @param UriInterface $uri new request URI to use + * @param bool $preserveHost preserve the original state of the Host header + * + * @return static + */ + public function withUri(UriInterface $uri, $preserveHost = false): RequestInterface + { + return (clone $this)->_withUri($uri, $preserveHost); + } + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name case-insensitive header field name to remove + * + * @return static + */ + public function withoutHeader($name): self + { + $new = clone $this; + + $new->headers->forceUnset($name); + + return $new; + } + + /** + * @return string + */ + public function getContentType(): string + { + return $this->content_type; + } + + /** + * @return callable|LoggerInterface|null + */ + public function getErrorHandler() + { + return $this->error_handler; + } + + /** + * @return string + */ + public function getExpectedType(): string + { + return $this->expected_type; + } + + /** + * @return string + */ + public function getHttpMethod(): string + { + return $this->method; + } + + /** + * @return \ArrayObject + */ + public function getIterator(): \ArrayObject + { + // init + $elements = new \ArrayObject(); + + foreach (\get_object_vars($this) as $f => $v) { + $elements[$f] = $v; + } + + return $elements; + } + + /** + * @return callable|null + */ + public function getParseCallback() + { + return $this->parse_callback; + } + + /** + * @return array + */ + public function getPayload(): array + { + return \is_string($this->payload) ? [$this->payload] : $this->payload; + } + + /** + * @return string + */ + public function getRawHeaders(): string + { + return $this->raw_headers; + } + + /** + * @return callable[] + */ + public function getSendCallback(): array + { + return $this->send_callbacks; + } + + /** + * @return int + */ + public function getSerializePayloadMethod(): int + { + return $this->serialize_payload_method; + } + + /** + * @return mixed|null + */ + public function getSerializedPayload() + { + return $this->serialized_payload; + } + + /** + * @return string + */ + public function getUriString(): string + { + return (string) $this->uri; + } + + /** + * Is this request setup for basic auth? + * + * @return bool + */ + public function hasBasicAuth(): bool + { + return $this->password && $this->username; + } + + /** + * @return bool has the internal curl (non-multi) request been initialized? + */ + public function hasBeenInitialized(): bool + { + if (!$this->curl) { + return false; + } + + return \is_resource($this->curl->getCurl()); + } + + /** + * @return bool has the internal curl (multi) request been initialized? + */ + public function hasBeenInitializedMulti(): bool + { + if (!$this->curlMulti) { + return false; + } + + return \is_resource($this->curlMulti->getMultiCurl()); + } + + /** + * @return bool is this request setup for client side cert? + */ + public function hasClientSideCert(): bool + { + return $this->ssl_cert && $this->ssl_key; + } + + /** + * @return bool does the request have a connection timeout? + */ + public function hasConnectionTimeout(): bool + { + return isset($this->connection_timeout); + } - // Option constants - const SERIALIZE_PAYLOAD_NEVER = 0; - const SERIALIZE_PAYLOAD_ALWAYS = 1; - const SERIALIZE_PAYLOAD_SMART = 2; + /** + * Is this request setup for digest auth? + * + * @return bool + */ + public function hasDigestAuth(): bool + { + return $this->password + && + $this->username + && + $this->additional_curl_opts[\CURLOPT_HTTPAUTH] === \CURLAUTH_DIGEST; + } + + /** + * @return bool + */ + public function hasParseCallback(): bool + { + return isset($this->parse_callback) + && + \is_callable($this->parse_callback); + } - const MAX_REDIRECTS_DEFAULT = 25; + /** + * @return bool is this request setup for using proxy? + */ + public function hasProxy(): bool + { + /** + * We must be aware that proxy variables could come from environment also. + * In curl extension, http proxy can be specified not only via CURLOPT_PROXY option, + * but also by environment variable called http_proxy. + */ + return ( + isset($this->additional_curl_opts[\CURLOPT_PROXY]) + && \is_string($this->additional_curl_opts[\CURLOPT_PROXY]) + ) || \getenv('http_proxy'); + } - public $uri, - $method = Http::GET, - $headers = array(), - $raw_headers = '', - $strict_ssl = false, - $content_type, - $expected_type, - $additional_curl_opts = array(), - $auto_parse = true, - $serialize_payload_method = self::SERIALIZE_PAYLOAD_SMART, - $username, - $password, - $serialized_payload, - $payload, - $parse_callback, - $error_callback, - $send_callback, - $follow_redirects = false, - $max_redirects = self::MAX_REDIRECTS_DEFAULT, - $payload_serializers = array(); + /** + * @return bool does the request have a timeout? + */ + public function hasTimeout(): bool + { + return isset($this->timeout); + } - // Options - // private $_options = array( - // 'serialize_payload_method' => self::SERIALIZE_PAYLOAD_SMART - // 'auto_parse' => true - // ); + /** + * HTTP Method Head + * + * @param string|UriInterface $uri + * + * @return static + */ + public static function head($uri): self + { + if ($uri instanceof UriInterface) { + $uri = (string) $uri; + } - // Curl Handle - public $_ch, - $_debug; + return (new self(Http::HEAD)) + ->withUriFromString($uri) + ->withMimeType(Mime::PLAIN); + } - // Template Request object - private static $_template; + /** + * @see Request::close() + * + * @return void + */ + public function initializeMulti() + { + if (!$this->curlMulti || $this->hasBeenInitializedMulti()) { + $this->curlMulti = new MultiCurl(); + } + } /** - * We made the constructor protected to force the factory style. This was - * done to keep the syntax cleaner and better the support the idea of - * "default templates". Very basic and flexible as it is only intended - * for internal use. - * @param array $attrs hash of initial attribute values + * @see Request::close() + * + * @return void */ - protected function __construct($attrs = null) + public function initialize() { - if (!is_array($attrs)) return; - foreach ($attrs as $attr => $value) { - $this->$attr = $value; + if (!$this->curl || !$this->hasBeenInitialized()) { + $this->curl = new Curl(); } } - // Defaults Management + /** + * @return bool + */ + public function isAutoParse(): bool + { + return $this->auto_parse; + } /** - * Let's you configure default settings for this - * class from a template Request object. Simply construct a - * Request object as much as you want to and then pass it to - * this method. It will then lock in those settings from - * that template object. - * The most common of which may be default mime - * settings or strict ssl settings. - * Again some slight memory overhead incurred here but in the grand - * scheme of things as it typically only occurs once - * @param Request $template + * @return bool */ - public static function ini(Request $template) + public function isJson(): bool { - self::$_template = clone $template; + return $this->content_type === Mime::JSON; } /** - * Reset the default template back to the - * library defaults. + * @return bool */ - public static function resetIni() + public function isStrictSSL(): bool { - self::_initializeDefaults(); + return $this->strict_ssl; } /** - * Get default for a value based on the template object - * @param string|null $attr Name of attribute (e.g. mime, headers) - * if null just return the whole template object; - * @return mixed default value + * @return bool */ - public static function d($attr) + public function isUpload(): bool { - return isset($attr) ? self::$_template->$attr : self::$_template; + return $this->content_type === Mime::UPLOAD; } - // Accessors + /** + * @return static + * + * @see Request::serializePayloadMode() + */ + public function neverSerializePayload(): self + { + return $this->serializePayloadMode(static::SERIALIZE_PAYLOAD_NEVER); + } /** - * @return bool does the request have a timeout? + * HTTP Method Options + * + * @param string|UriInterface $uri + * + * @return static */ - public function hasTimeout() + public static function options($uri): self { - return isset($this->timeout); + if ($uri instanceof UriInterface) { + $uri = (string) $uri; + } + + return (new self(Http::OPTIONS))->withUriFromString($uri); + } + + /** + * HTTP Method Patch + * + * @param string|UriInterface $uri + * @param mixed $payload data to send in body of request + * @param string $mime MIME to use for Content-Type + * + * @return static + */ + public static function patch($uri, $payload = null, string $mime = null): self + { + if ($uri instanceof UriInterface) { + $uri = (string) $uri; + } + + return (new self(Http::PATCH)) + ->withUriFromString($uri) + ->_setBody($payload, null, $mime); + } + + /** + * HTTP Method Post + * + * @param string|UriInterface $uri + * @param mixed $payload data to send in body of request + * @param string $mime MIME to use for Content-Type + * + * @return static + */ + public static function post($uri, $payload = null, string $mime = null): self + { + if ($uri instanceof UriInterface) { + $uri = (string) $uri; + } + + return (new self(Http::POST)) + ->withUriFromString($uri) + ->_setBody($payload, null, $mime); } /** - * @return bool has the internal curl request been initialized? + * HTTP Method Put + * + * @param string|UriInterface $uri + * @param mixed $payload data to send in body of request + * @param string $mime MIME to use for Content-Type + * + * @return static */ - public function hasBeenInitialized() + public static function put($uri, $payload = null, string $mime = null): self { - return isset($this->_ch); + if ($uri instanceof UriInterface) { + $uri = (string) $uri; + } + + return (new self(Http::PUT)) + ->withUriFromString($uri) + ->_setBody($payload, null, $mime); } /** - * @return bool Is this request setup for basic auth? + * Register a callback that will be used to serialize the payload + * for a particular mime type. When using "*" for the mime + * type, it will use that parser for all responses regardless of the mime + * type. If a custom '*' and 'application/json' exist, the custom + * 'application/json' would take precedence over the '*' callback. + * + * @param string $mime mime type we're registering + * @param callable $callback takes one argument, $payload, + * which is the payload that we'll be + * + * @return static */ - public function hasBasicAuth() + public function registerPayloadSerializer($mime, callable $callback): self { - return isset($this->password) && isset($this->username); + $new = clone $this; + + $new->payload_serializers[Mime::getFullMime($mime)] = $callback; + + return $new; } /** - * @return bool Is this request setup for digest auth? + * @return void */ - public function hasDigestAuth() + public function reset() { - return isset($this->password) && isset($this->username) && $this->additional_curl_opts[CURLOPT_HTTPAUTH] == CURLAUTH_DIGEST; + $this->headers = new Headers(); + + $this->close(); + $this->initialize(); } /** - * Specify a HTTP timeout - * @param float|int $timeout seconds to timeout the HTTP call - * @return Request + * Actually send off the request, and parse the response. + * + * @param callable|null $onSuccessCallback + * @param callable|null $onCompleteCallback + * @param callable|null $onBeforeSendCallback + * @param callable|null $onErrorCallback + * + * @throws NetworkErrorException when unable to parse or communicate w server + * + * @return MultiCurl + */ + public function initMulti( + $onSuccessCallback = null, + $onCompleteCallback = null, + $onBeforeSendCallback = null, + $onErrorCallback = null + ) { + $this->initializeMulti(); + \assert($this->curlMulti instanceof MultiCurl); + + if ($onSuccessCallback !== null) { + $this->curlMulti->success( + static function (Curl $instance) use ($onSuccessCallback) { + if ($instance->request instanceof self) { + $response = $instance->request->_buildResponse($instance->rawResponse, $instance); + } else { + $response = $instance->rawResponse; + } + + $onSuccessCallback( + $response, + $instance->request, + $instance + ); + } + ); + } + + if ($onCompleteCallback !== null) { + $this->curlMulti->complete( + static function (Curl $instance) use ($onCompleteCallback) { + if ($instance->request instanceof self) { + $response = $instance->request->_buildResponse($instance->rawResponse, $instance); + } else { + $response = $instance->rawResponse; + } + + $onCompleteCallback( + $response, + $instance->request, + $instance + ); + } + ); + } + + if ($onBeforeSendCallback !== null) { + $this->curlMulti->beforeSend( + static function (Curl $instance) use ($onBeforeSendCallback) { + if ($instance->request instanceof self) { + $response = $instance->request->_buildResponse($instance->rawResponse, $instance); + } else { + $response = $instance->rawResponse; + } + + $onBeforeSendCallback( + $response, + $instance->request, + $instance + ); + } + ); + } + + if ($onErrorCallback !== null) { + $this->curlMulti->error( + static function (Curl $instance) use ($onErrorCallback) { + if ($instance->request instanceof self) { + $response = $instance->request->_buildResponse($instance->rawResponse, $instance); + } else { + $response = $instance->rawResponse; + } + + $onErrorCallback( + $response, + $instance->request, + $instance + ); + } + ); + } + + return $this->curlMulti; + } + + /** + * Actually send off the request, and parse the response. + * + * @throws NetworkErrorException when unable to parse or communicate w server + * + * @return Response + */ + public function send(): Response + { + $this->_curlPrep(); + \assert($this->curl instanceof Curl); + + $result = $this->curl->exec(); + + if ( + $result === false + && + $this->retry_by_possible_encoding_error + ) { + // Possibly a gzip issue makes curl unhappy. + if ( + $this->curl->errorCode === \CURLE_WRITE_ERROR + || + $this->curl->errorCode === \CURLE_BAD_CONTENT_ENCODING + ) { + // Docs say 'identity,' but 'none' seems to work (sometimes?). + $this->curl->setOpt(\CURLOPT_ENCODING, 'none'); + + $result = $this->curl->exec(); + + if ($result === false) { + if ( + /* @phpstan-ignore-next-line | FP? */ + $this->curl->errorCode === \CURLE_WRITE_ERROR + || + $this->curl->errorCode === \CURLE_BAD_CONTENT_ENCODING + ) { + $this->curl->setOpt(\CURLOPT_ENCODING, 'identity'); + + $result = $this->curl->exec(); + } + } + } + } + + if (!$this->keep_alive) { + $this->close(); + } + + return $this->_buildResponse($result); + } + + /** + * @return static + */ + public function sendsCsv(): self + { + return $this->withContentType(Mime::CSV); + } + + /** + * @return static + */ + public function sendsForm(): self + { + return $this->withContentType(Mime::FORM); + } + + /** + * @return static + */ + public function sendsHtml(): self + { + return $this->withContentType(Mime::HTML); + } + + /** + * @return static + */ + public function sendsJavascript(): self + { + return $this->withContentType(Mime::JS); + } + + /** + * @return static + */ + public function sendsJs(): self + { + return $this->withContentType(Mime::JS); + } + + /** + * @return static + */ + public function sendsJson(): self + { + return $this->withContentType(Mime::JSON); + } + + /** + * @return static + */ + public function sendsPlain(): self + { + return $this->withContentType(Mime::PLAIN); + } + + /** + * @return static + */ + public function sendsText(): self + { + return $this->withContentType(Mime::PLAIN); + } + + /** + * @return static + */ + public function sendsUpload(): self + { + return $this->withContentType(Mime::UPLOAD); + } + + /** + * @return static + */ + public function sendsXhtml(): self + { + return $this->withContentType(Mime::XHTML); + } + + /** + * @return static + */ + public function sendsXml(): self + { + return $this->withContentType(Mime::XML); + } + + /** + * Determine how/if we use the built in serialization by + * setting the serialize_payload_method + * The default (SERIALIZE_PAYLOAD_SMART) is... + * - if payload is not a scalar (object/array) + * use the appropriate serialize method according to + * the Content-Type of this request. + * - if the payload IS a scalar (int, float, string, bool) + * than just return it as is. + * When this option is set SERIALIZE_PAYLOAD_ALWAYS, + * it will always use the appropriate + * serialize option regardless of whether payload is scalar or not + * When this option is set SERIALIZE_PAYLOAD_NEVER, + * it will never use any of the serialization methods. + * Really the only use for this is if you want the serialize methods + * to handle strings or not (e.g. Blah is not valid JSON, but "Blah" + * is). Forcing the serialization helps prevent that kind of error from + * happening. + * + * @param int $mode Request::SERIALIZE_PAYLOAD_* + * + * @return static */ - public function timeout($timeout) + public function serializePayloadMode(int $mode): self { - $this->timeout = $timeout; + $this->serialize_payload_method = $mode; + return $this; } - // alias timeout - public function timeoutIn($seconds) + /** + * This method is the default behavior + * + * @return static + * + * @see Request::serializePayloadMode() + */ + public function smartSerializePayload(): self + { + return $this->serializePayloadMode(static::SERIALIZE_PAYLOAD_SMART); + } + + /** + * Specify a HTTP timeout + * + * @param float|int $timeout seconds to timeout the HTTP call + * + * @return static + */ + public function withTimeout($timeout): self { - return $this->timeout($seconds); + if (!\preg_match('/^\d+(\.\d+)?/', (string) $timeout)) { + throw new \InvalidArgumentException( + 'Invalid timeout provided: ' . \var_export($timeout, true) + ); + } + + $new = clone $this; + + $new->timeout = $timeout; + + return $new; + } + + /** + * Shortcut for useProxy to configure SOCKS 4 proxy + * + * @param string $proxy_host Hostname or address of the proxy + * @param int $proxy_port Port of the proxy. Default 80 + * @param int|null $auth_type Authentication type or null. Accepted values are CURLAUTH_BASIC, CURLAUTH_NTLM. + * Default null, no authentication + * @param string $auth_username Authentication username. Default null + * @param string $auth_password Authentication password. Default null + * + * @return static + * + * @see Request::withProxy + */ + public function useSocks4Proxy( + $proxy_host, + $proxy_port = 80, + $auth_type = null, + $auth_username = null, + $auth_password = null + ): self { + return $this->withProxy( + $proxy_host, + $proxy_port, + $auth_type, + $auth_username, + $auth_password, + Proxy::SOCKS4 + ); } /** - * If the response is a 301 or 302 redirect, automatically - * send off another request to that location - * @param bool|int $follow follow or not to follow or maximal number of redirects - * @return Request + * Shortcut for useProxy to configure SOCKS 5 proxy + * + * @param string $proxy_host + * @param int $proxy_port + * @param int|null $auth_type + * @param string|null $auth_username + * @param string|null $auth_password + * + * @return static + * + * @see Request::withProxy */ - public function followRedirects($follow = true) - { - $this->max_redirects = $follow === true ? self::MAX_REDIRECTS_DEFAULT : max(0, $follow); - $this->follow_redirects = (bool) $follow; - return $this; + public function useSocks5Proxy( + $proxy_host, + $proxy_port = 80, + $auth_type = null, + $auth_username = null, + $auth_password = null + ): self { + return $this->withProxy( + $proxy_host, + $proxy_port, + $auth_type, + $auth_username, + $auth_password, + Proxy::SOCKS5 + ); } /** - * @see Request::followRedirects() - * @return Request + * @param string $name + * @param string $value + * + * @return static */ - public function doNotFollowRedirects() + public function withAddedCookie(string $name, string $value): self { - return $this->followRedirects(false); + return $this->withAddedHeader('Cookie', "{$name}={$value}"); } /** - * Actually send off the request, and parse the response - * @return Response with parsed results - * @throws ConnectionErrorException when unable to parse or communicate w server + * @param array $files + * + * @return static */ - public function send() + public function withAttachment($files): self { - if (!$this->hasBeenInitialized()) - $this->_curlPrep(); + $new = clone $this; - $result = curl_exec($this->_ch); + $fInfo = \finfo_open(\FILEINFO_MIME_TYPE); + if ($fInfo === false) { + /** @noinspection ForgottenDebugOutputInspection */ + \error_log('finfo_open() did not work', \E_USER_WARNING); - $response = $this->buildResponse($result); - - curl_close($this->_ch); - unset($this->_ch); + return $new; + } - return $response; - } - public function sendIt() - { - return $this->send(); - } + foreach ($files as $key => $file) { + $mimeType = \finfo_file($fInfo, $file); + if ($mimeType !== false) { + if (\is_string($new->payload)) { + $new->payload = []; // reset + } + $new->payload[$key] = \curl_file_create($file, $mimeType, \basename($file)); + } + } - // Setters + \finfo_close($fInfo); - /** - * @param string $uri - * @return Request - */ - public function uri($uri) - { - $this->uri = $uri; - return $this; + return $new->_withContentType(Mime::UPLOAD); } /** * User Basic Auth. + * * Only use when over SSL/TSL/HTTPS. + * * @param string $username * @param string $password - * @return Request + * + * @return static */ - public function basicAuth($username, $password) - { - $this->username = $username; - $this->password = $password; - return $this; - } - // @alias of basicAuth - public function authenticateWith($username, $password) - { - return $this->basicAuth($username, $password); - } - // @alias of basicAuth - public function authenticateWithBasic($username, $password) + public function withBasicAuth($username, $password): self { - return $this->basicAuth($username, $password); - } - - // @alias of ntlmAuth - public function authenticateWithNTLM($username, $password) - { - return $this->ntlmAuth($username, $password); - } + $new = clone $this; + $new->username = $username; + $new->password = $password; - public function ntlmAuth($username, $password) - { - $this->addOnCurlOption(CURLOPT_HTTPAUTH, CURLAUTH_NTLM); - return $this->basicAuth($username, $password); + return $new; } /** - * User Digest Auth. - * @param string $username - * @param string $password - * @return Request + * @param array $body + * + * @return static */ - public function digestAuth($username, $password) - { - $this->addOnCurlOption(CURLOPT_HTTPAUTH, CURLAUTH_DIGEST); - return $this->basicAuth($username, $password); - } - - // @alias of digestAuth - public function authenticateWithDigest($username, $password) + public function withBodyFromArray(array $body) { - return $this->digestAuth($username, $password); + return $this->_setBody($body, null); } /** - * @return bool is this request setup for client side cert? + * @param string $body + * + * @return static */ - public function hasClientSideCert() + public function withBodyFromString(string $body) { - return isset($this->client_cert) && isset($this->client_key); + $stream = Http::stream($body); + + return $this->_setBody($stream->getContents(), null); } /** - * Use Client Side Cert Authentication - * @param string $key file path to client key - * @param string $cert file path to client cert - * @param string $passphrase for client key - * @param string $encoding default PEM - * @return Request + * Specify a HTTP connection timeout + * + * @param float|int $connection_timeout seconds to timeout the HTTP connection + * + * @throws \InvalidArgumentException + * + * @return static */ - public function clientSideCert($cert, $key, $passphrase = null, $encoding = 'PEM') + public function withConnectionTimeoutInSeconds($connection_timeout): self { - $this->client_cert = $cert; - $this->client_key = $key; - $this->client_passphrase = $passphrase; - $this->client_encoding = $encoding; + if (!\preg_match('/^\d+(\.\d+)?/', (string) $connection_timeout)) { + throw new \InvalidArgumentException( + 'Invalid connection timeout provided: ' . \var_export($connection_timeout, true) + ); + } - return $this; - } - // @alias of basicAuth - public function authenticateWithCert($cert, $key, $passphrase = null, $encoding = 'PEM') - { - return $this->clientSideCert($cert, $key, $passphrase, $encoding); - } + $new = clone $this; - /** - * Set the body of the request - * @param mixed $payload - * @param string $mimeType currently, sets the sends AND expects mime type although this - * behavior may change in the next minor release (as it is a potential breaking change). - * @return Request - */ - public function body($payload, $mimeType = null) - { - $this->mime($mimeType); - $this->payload = $payload; - // Iserntentially don't call _serializePayload yet. Wait until - // we actually send off the request to convert payload to string. - // At that time, the `serialized_payload` is set accordingly. - return $this; + $new->connection_timeout = $connection_timeout; + + return $new; } /** - * Helper function to set the Content type and Expected as same in - * one swoop - * @param string $mime mime type to use for content type and expected return type - * @return Request + * @param string $cache_control + *

e.g. 'no-cache', 'public', ...

+ * + * @return static */ - public function mime($mime) + public function withCacheControl(string $cache_control): self { - if (empty($mime)) return $this; - $this->content_type = $this->expected_type = Mime::getFullMime($mime); - if ($this->isUpload()) { - $this->neverSerializePayload(); + $new = clone $this; + + if (empty($cache_control)) { + return $new; } - return $this; - } - // @alias of mime - public function sendsAndExpectsType($mime) - { - return $this->mime($mime); - } - // @alias of mime - public function sendsAndExpects($mime) - { - return $this->mime($mime); + + $new->cache_control = $cache_control; + + return $new; } /** - * Set the method. Shouldn't be called often as the preferred syntax - * for instantiation is the method specific factory methods. - * @param string $method - * @return Request + * @param string $charset + *

e.g. "UTF-8"

+ * + * @return static */ - public function method($method) + public function withContentCharset(string $charset): self { - if (empty($method)) return $this; - $this->method = $method; - return $this; + $new = clone $this; + + if (empty($charset)) { + return $new; + } + + $new->content_charset = UTF8::normalize_encoding($charset); + + return $new; } /** - * @param string $mime - * @return Request + * @param int $port + * + * @return static */ - public function expects($mime) - { - if (empty($mime)) return $this; - $this->expected_type = Mime::getFullMime($mime); - return $this; - } - // @alias of expects - public function expectsType($mime) + public function withPort(int $port): self { - return $this->expects($mime); - } + $new = clone $this; - public function attach($files) - { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - foreach ($files as $key => $file) { - $mimeType = finfo_file($finfo, $file); - if (function_exists('curl_file_create')) { - $this->payload[$key] = curl_file_create($file, $mimeType); - } else { - $this->payload[$key] = '@' . $file; - if ($mimeType) { - $this->payload[$key] .= ';type=' . $mimeType; - } - } + $new->port = $port; + if ($new->uri) { + $new->uri = $new->uri->withPort($port); + $new->_updateHostFromUri(); } - $this->sendsType(Mime::UPLOAD); - return $this; + + return $new; } /** - * @param string $mime - * @return Request + * @param string $encoding + * + * @return static */ - public function contentType($mime) - { - if (empty($mime)) return $this; - $this->content_type = Mime::getFullMime($mime); - if ($this->isUpload()) { - $this->neverSerializePayload(); - } - return $this; - } - // @alias of contentType - public function sends($mime) - { - return $this->contentType($mime); - } - // @alias of contentType - public function sendsType($mime) + public function withContentEncoding(string $encoding): self { - return $this->contentType($mime); + $new = clone $this; + + $new->content_encoding = $encoding; + + return $new; } /** - * Do we strictly enforce SSL verification? - * @param bool $strict - * @return Request + * @param string|null $mime use a constant from Mime::* + * @param string|null $fallback use a constant from Mime::* + * + * @return static */ - public function strictSSL($strict) - { - $this->strict_ssl = $strict; - return $this; - } - public function withoutStrictSSL() + public function withContentType($mime, string $fallback = null): self { - return $this->strictSSL(false); - } - public function withStrictSSL() - { - return $this->strictSSL(true); + return (clone $this)->_withContentType($mime, $fallback); } /** - * Use proxy configuration - * @param string $proxy_host Hostname or address of the proxy - * @param int $proxy_port Port of the proxy. Default 80 - * @param string $auth_type Authentication type or null. Accepted values are CURLAUTH_BASIC, CURLAUTH_NTLM. Default null, no authentication - * @param string $auth_username Authentication username. Default null - * @param string $auth_password Authentication password. Default null - * @return Request + * @return static */ - public function useProxy($proxy_host, $proxy_port = 80, $auth_type = null, $auth_username = null, $auth_password = null, $proxy_type = Proxy::HTTP) + public function withContentTypeCsv(): self { - $this->addOnCurlOption(CURLOPT_PROXY, "{$proxy_host}:{$proxy_port}"); - $this->addOnCurlOption(CURLOPT_PROXYTYPE, $proxy_type); - if (in_array($auth_type, array(CURLAUTH_BASIC,CURLAUTH_NTLM))) { - $this->addOnCurlOption(CURLOPT_PROXYAUTH, $auth_type) - ->addOnCurlOption(CURLOPT_PROXYUSERPWD, "{$auth_username}:{$auth_password}"); - } - return $this; + $new = clone $this; + $new->content_type = Mime::getFullMime(Mime::CSV); + + return $new; } /** - * Shortcut for useProxy to configure SOCKS 4 proxy - * @see Request::useProxy - * @return Request + * @return static */ - public function useSocks4Proxy($proxy_host, $proxy_port = 80, $auth_type = null, $auth_username = null, $auth_password = null) + public function withContentTypeForm(): self { - return $this->useProxy($proxy_host, $proxy_port, $auth_type, $auth_username, $auth_password, Proxy::SOCKS4); + $new = clone $this; + $new->content_type = Mime::getFullMime(Mime::FORM); + + return $new; } /** - * Shortcut for useProxy to configure SOCKS 5 proxy - * @see Request::useProxy - * @return Request + * @return static */ - public function useSocks5Proxy($proxy_host, $proxy_port = 80, $auth_type = null, $auth_username = null, $auth_password = null) + public function withContentTypeHtml(): self { - return $this->useProxy($proxy_host, $proxy_port, $auth_type, $auth_username, $auth_password, Proxy::SOCKS5); + $new = clone $this; + $new->content_type = Mime::getFullMime(Mime::HTML); + + return $new; } /** - * @return bool is this request setup for using proxy? + * @return static */ - public function hasProxy() + public function withContentTypeJson(): self { - /* We must be aware that proxy variables could come from environment also. - In curl extension, http proxy can be specified not only via CURLOPT_PROXY option, - but also by environment variable called http_proxy. - */ - return isset($this->additional_curl_opts[CURLOPT_PROXY]) && is_string($this->additional_curl_opts[CURLOPT_PROXY]) || - getenv("http_proxy"); + $new = clone $this; + $new->content_type = Mime::getFullMime(Mime::JSON); + + return $new; } /** - * Determine how/if we use the built in serialization by - * setting the serialize_payload_method - * The default (SERIALIZE_PAYLOAD_SMART) is... - * - if payload is not a scalar (object/array) - * use the appropriate serialize method according to - * the Content-Type of this request. - * - if the payload IS a scalar (int, float, string, bool) - * than just return it as is. - * When this option is set SERIALIZE_PAYLOAD_ALWAYS, - * it will always use the appropriate - * serialize option regardless of whether payload is scalar or not - * When this option is set SERIALIZE_PAYLOAD_NEVER, - * it will never use any of the serialization methods. - * Really the only use for this is if you want the serialize methods - * to handle strings or not (e.g. Blah is not valid JSON, but "Blah" - * is). Forcing the serialization helps prevent that kind of error from - * happening. - * @param int $mode - * @return Request + * @return static */ - public function serializePayload($mode) + public function withContentTypePlain(): self { - $this->serialize_payload_method = $mode; - return $this; + $new = clone $this; + $new->content_type = Mime::getFullMime(Mime::PLAIN); + + return $new; } /** - * @see Request::serializePayload() - * @return Request + * @return static */ - public function neverSerializePayload() + public function withContentTypeXml(): self { - return $this->serializePayload(self::SERIALIZE_PAYLOAD_NEVER); + $new = clone $this; + $new->content_type = Mime::getFullMime(Mime::XML); + + return $new; } /** - * This method is the default behavior - * @see Request::serializePayload() - * @return Request + * @return static */ - public function smartSerializePayload() + public function withContentTypeYaml(): self { - return $this->serializePayload(self::SERIALIZE_PAYLOAD_SMART); + return $this->withContentType(Mime::YAML); } /** - * @see Request::serializePayload() - * @return Request + * @param string $name + * @param string $value + * + * @return static */ - public function alwaysSerializePayload() + public function withCookie(string $name, string $value): self { - return $this->serializePayload(self::SERIALIZE_PAYLOAD_ALWAYS); + return $this->withHeader('Cookie', "{$name}={$value}"); } /** - * Add an additional header to the request - * Can also use the cleaner syntax of - * $Request->withMyHeaderName($my_value); - * @see Request::__call() + * Semi-reluctantly added this as a way to add in curl opts + * that are not otherwise accessible from the rest of the API. * - * @param string $header_name - * @param string $value - * @return Request + * @param int $curl_opt + * @param mixed $curl_opt_val + * + * @return static */ - public function addHeader($header_name, $value) + public function withCurlOption($curl_opt, $curl_opt_val): self { - $this->headers[$header_name] = $value; - return $this; + $new = clone $this; + + $new->additional_curl_opts[$curl_opt] = $curl_opt_val; + + return $new; } /** - * Add group of headers all at once. Note: This is - * here just as a convenience in very specific cases. - * The preferred "readable" way would be to leverage - * the support for custom header methods. - * @param array $headers - * @return Request + * User Digest Auth. + * + * @param string $username + * @param string $password + * + * @return static */ - public function addHeaders(array $headers) + public function withDigestAuth($username, $password): self { - foreach ($headers as $header => $value) { - $this->addHeader($header, $value); - } - return $this; + $new = clone $this; + + $new = $new->withCurlOption(\CURLOPT_HTTPAUTH, \CURLAUTH_DIGEST); + + return $new->withBasicAuth($username, $password); } /** - * @param bool $auto_parse perform automatic "smart" - * parsing based on Content-Type or "expectedType" - * If not auto parsing, Response->body returns the body - * as a string. - * @return Request + * Callback called to handle HTTP errors. When nothing is set, defaults + * to logging via `error_log`. + * + * @param callable|LoggerInterface|null $error_handler + * + * @return static */ - public function autoParse($auto_parse = true) + public function withErrorHandler($error_handler): self { - $this->auto_parse = $auto_parse; - return $this; + $new = clone $this; + + $new->error_handler = $error_handler; + + return $new; } /** - * @see Request::autoParse() - * @return Request + * @param string|null $mime use a constant from Mime::* + * @param string|null $fallback use a constant from Mime::* + * + * @return static */ - public function withoutAutoParsing() + public function withExpectedType($mime, string $fallback = null): self { - return $this->autoParse(false); + return (clone $this)->_withExpectedType($mime, $fallback); } /** - * @see Request::autoParse() - * @return Request + * @param string[]|string[][] $header + * + * @return static */ - public function withAutoParsing() + public function withHeaders(array $header): self { - return $this->autoParse(true); + $new = clone $this; + + foreach ($header as $name => $value) { + $new = $new->withAddedHeader($name, $value); + } + + return $new; } /** - * Use a custom function to parse the response. - * @param \Closure $callback Takes the raw body of - * the http response and returns a mixed - * @return Request + * Helper function to set the Content type and Expected as same in one swoop. + * + * @param string|null $mime + *

\Httpful\Mime::JSON, \Httpful\Mime::XML, ...

+ * + * @return static */ - public function parseWith(\Closure $callback) + public function withMimeType($mime): self { - $this->parse_callback = $callback; - return $this; + return (clone $this)->_withMimeType($mime); } /** - * @see Request::parseResponsesWith() - * @param \Closure $callback - * @return Request + * @param string $username + * @param string $password + * + * @return static */ - public function parseResponsesWith(\Closure $callback) + public function withNtlmAuth($username, $password): self { - return $this->parseWith($callback); + $new = clone $this; + + $new->withCurlOption(\CURLOPT_HTTPAUTH, \CURLAUTH_NTLM); + + return $new->withBasicAuth($username, $password); } /** - * Callback called to handle HTTP errors. When nothing is set, defaults - * to logging via `error_log` - * @param \Closure $callback (string $error) - * @return Request + * Add additional parameter to be appended to the query string. + * + * @param int|string|null $key + * @param int|string|null $value + * + * @return static */ - public function whenError(\Closure $callback) + public function withParam($key, $value): self { - $this->error_callback = $callback; - return $this; + $new = clone $this; + + if ( + isset($key, $value) + && + $key !== '' + ) { + $new->params[$key] = $value; + } + + return $new; } /** - * Callback invoked after payload has been serialized but before - * the request has been built. - * @param \Closure $callback (Request $request) - * @return Request + * Add additional parameters to be appended to the query string. + * + * Takes an associative array of key/value pairs as an argument. + * + * @param array $params + * + * @return static this */ - public function beforeSend(\Closure $callback) + public function withParams(array $params): self { - $this->send_callback = $callback; - return $this; + $new = clone $this; + + $new->params = \array_merge($new->params, $params); + + return $new; } /** - * Register a callback that will be used to serialize the payload - * for a particular mime type. When using "*" for the mime - * type, it will use that parser for all responses regardless of the mime - * type. If a custom '*' and 'application/json' exist, the custom - * 'application/json' would take precedence over the '*' callback. + * Use a custom function to parse the response. * - * @param string $mime mime type we're registering - * @param \Closure $callback takes one argument, $payload, - * which is the payload that we'll be - * @return Request + * @param callable $callback Takes the raw body of + * the http response and returns a mixed + * + * @return static */ - public function registerPayloadSerializer($mime, \Closure $callback) + public function withParseCallback(callable $callback): self { - $this->payload_serializers[Mime::getFullMime($mime)] = $callback; - return $this; + $new = clone $this; + + $new->parse_callback = $callback; + + return $new; } /** - * @see Request::registerPayloadSerializer() - * @param \Closure $callback - * @return Request + * Use proxy configuration + * + * @param string $proxy_host Hostname or address of the proxy + * @param int $proxy_port Port of the proxy. Default 80 + * @param int|null $auth_type Authentication type or null. Accepted values are CURLAUTH_BASIC, CURLAUTH_NTLM. + * Default null, no authentication + * @param string $auth_username Authentication username. Default null + * @param string $auth_password Authentication password. Default null + * @param int $proxy_type Proxy-Type for Curl. Default is "Proxy::HTTP" + * + * @return static */ - public function serializePayloadWith(\Closure $callback) - { - return $this->registerPayloadSerializer('*', $callback); + public function withProxy( + $proxy_host, + $proxy_port = 80, + $auth_type = null, + $auth_username = null, + $auth_password = null, + $proxy_type = Proxy::HTTP + ): self { + $new = clone $this; + + $new = $new->withCurlOption(\CURLOPT_PROXY, "{$proxy_host}:{$proxy_port}"); + $new = $new->withCurlOption(\CURLOPT_PROXYTYPE, $proxy_type); + + if (\in_array($auth_type, [\CURLAUTH_BASIC, \CURLAUTH_NTLM], true)) { + $new = $new->withCurlOption(\CURLOPT_PROXYAUTH, $auth_type); + $new = $new->withCurlOption(\CURLOPT_PROXYUSERPWD, "{$auth_username}:{$auth_password}"); + } + + return $new; } /** - * Magic method allows for neatly setting other headers in a - * similar syntax as the other setters. This method also allows - * for the sends* syntax. - * @param string $method "missing" method name called - * the method name called should be the name of the header that you - * are trying to set in camel case without dashes e.g. to set a - * header for Content-Type you would use contentType() or more commonly - * to add a custom header like X-My-Header, you would use xMyHeader(). - * To promote readability, you can optionally prefix these methods with - * "with" (e.g. withXMyHeader("blah") instead of xMyHeader("blah")). - * @param array $args in this case, there should only ever be 1 argument provided - * and that argument should be a string value of the header we're setting - * @return Request + * @param string|null $key + * @param mixed|null $fallback + * + * @return mixed */ - public function __call($method, $args) + public function getHelperData($key = null, $fallback = null) { - // This method supports the sends* methods - // like sendsJSON, sendsForm - //!method_exists($this, $method) && - if (substr($method, 0, 5) === 'sends') { - $mime = strtolower(substr($method, 5)); - if (Mime::supportsMimeType($mime)) { - $this->sends(Mime::getFullMime($mime)); - return $this; - } - // else { - // throw new \Exception("Unsupported Content-Type $mime"); - // } - } - if (substr($method, 0, 7) === 'expects') { - $mime = strtolower(substr($method, 7)); - if (Mime::supportsMimeType($mime)) { - $this->expects(Mime::getFullMime($mime)); - return $this; - } - // else { - // throw new \Exception("Unsupported Content-Type $mime"); - // } + if ($key !== null) { + return $this->helperData[$key] ?? $fallback; } - // This method also adds the custom header support as described in the - // method comments - if (count($args) === 0) - return; - - // Strip the sugar. If it leads with "with", strip. - // This is okay because: No defined HTTP headers begin with with, - // and if you are defining a custom header, the standard is to prefix it - // with an "X-", so that should take care of any collisions. - if (substr($method, 0, 4) === 'with') - $method = substr($method, 4); - - // Precede upper case letters with dashes, uppercase the first letter of method - $header = ucwords(implode('-', preg_split('/([A-Z][^A-Z]*)/', $method, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY))); - $this->addHeader($header, $args[0]); - return $this; + return $this->helperData; } - // Internal Functions + /** + * @return void + */ + public function clearHelperData() + { + $this->helperData = []; + } /** - * This is the default template to use if no - * template has been provided. The template - * tells the class which default values to use. - * While there is a slight overhead for object - * creation once per execution (not once per - * Request instantiation), it promotes readability - * and flexibility within the class. + * @param string $key + * @param mixed $value + * + * @return static */ - private static function _initializeDefaults() + public function addHelperData(string $key, $value): self { - // This is the only place you will - // see this constructor syntax. It - // is only done here to prevent infinite - // recusion. Do not use this syntax elsewhere. - // It goes against the whole readability - // and transparency idea. - self::$_template = new Request(array('method' => Http::GET)); + $this->helperData[$key] = $value; - // This is more like it... - self::$_template - ->withoutStrictSSL(); + return $this; } /** - * Set the defaults on a newly instantiated object - * Doesn't copy variables prefixed with _ - * @return Request + * @param callable|null $send_callback + * + * @return static */ - private function _setDefaults() + public function withSendCallback($send_callback): self { - if (!isset(self::$_template)) - self::_initializeDefaults(); - foreach (self::$_template as $k=>$v) { - if ($k[0] != '_') - $this->$k = $v; + $new = clone $this; + + if (!empty($send_callback)) { + $new->send_callbacks[] = $send_callback; } - return $this; + + return $new; } - private function _error($error) + /** + * @param callable $callback + * + * @return static + */ + public function withSerializePayload(callable $callback): self { - // TODO add in support for various Loggers that follow - // PSR 3 https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md - if (isset($this->error_callback)) { - $this->error_callback->__invoke($error); - } else { - error_log($error); - } + return $this->registerPayloadSerializer('*', $callback); } /** - * Factory style constructor works nicer for chaining. This - * should also really only be used internally. The Request::get, - * Request::post syntax is preferred as it is more readable. - * @param string $method Http Method - * @param string $mime Mime Type to Use + * @param string $file_path + * * @return Request */ - public static function init($method = null, $mime = null) + public function withDownload($file_path): self { - // Setup our handlers, can call it here as it's idempotent - Bootstrap::init(); + $new = clone $this; - // Setup the default template if need be - if (!isset(self::$_template)) - self::_initializeDefaults(); + $new->file_path_for_download = $file_path; - $request = new Request(); - return $request - ->_setDefaults() - ->method($method) - ->sendsType($mime) - ->expectsType($mime); + return $new; } /** - * Does the heavy lifting. Uses de facto HTTP - * library cURL to set up the HTTP request. - * Note: It does NOT actually send the request - * @return Request - * @throws \Exception + * @param string $uri + * @param bool $useClone + * + * @return static */ - public function _curlPrep() + public function withUriFromString(string $uri, bool $useClone = true): self { - // Check for required stuff - if (!isset($this->uri)) - throw new \Exception('Attempting to send a request before defining a URI endpoint.'); - - if (isset($this->payload)) { - $this->serialized_payload = $this->_serializePayload($this->payload); + if ($useClone) { + return (clone $this)->withUri(new Uri($uri)); } - if (isset($this->send_callback)) { - call_user_func($this->send_callback, $this); - } + return $this->_withUri(new Uri($uri)); + } - $ch = curl_init($this->uri); + /** + * Sets user agent. + * + * @param string $userAgent + * + * @return static + */ + public function withUserAgent($userAgent): self + { + return $this->withHeader('User-Agent', $userAgent); + } - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method); - if ($this->method === Http::HEAD) { - curl_setopt($ch, CURLOPT_NOBODY, true); + /** + * Takes a curl result and generates a Response from it. + * + * @param false|mixed $result + * @param Curl|null $curl + * + * @throws NetworkErrorException + * + * @return Response + * + * @internal + */ + public function _buildResponse($result, Curl $curl = null): Response + { + // fallback + if ($curl === null) { + $curl = $this->curl; } - if ($this->hasBasicAuth()) { - curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password); + if ($curl === null) { + throw new NetworkErrorException('Unable to build the response for "' . $this->uri . '". => "curl" === null'); } - if ($this->hasClientSideCert()) { - - if (!file_exists($this->client_key)) - throw new \Exception('Could not read Client Key'); - - if (!file_exists($this->client_cert)) - throw new \Exception('Could not read Client Certificate'); - - curl_setopt($ch, CURLOPT_SSLCERTTYPE, $this->client_encoding); - curl_setopt($ch, CURLOPT_SSLKEYTYPE, $this->client_encoding); - curl_setopt($ch, CURLOPT_SSLCERT, $this->client_cert); - curl_setopt($ch, CURLOPT_SSLKEY, $this->client_key); - curl_setopt($ch, CURLOPT_SSLKEYPASSWD, $this->client_passphrase); - // curl_setopt($ch, CURLOPT_SSLCERTPASSWD, $this->client_cert_passphrase); - } + if ($result === false) { + $curlErrorNumber = $curl->getErrorCode(); + if ($curlErrorNumber) { + $curlErrorString = (string) $curl->getErrorMessage(); - if ($this->hasTimeout()) { - if (defined('CURLOPT_TIMEOUT_MS')) { - curl_setopt($ch, CURLOPT_TIMEOUT_MS, $this->timeout * 1000); - } else { - curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); - } - } + $this->_error($curlErrorString); - if ($this->follow_redirects) { - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_MAXREDIRS, $this->max_redirects); - } + $exception = new NetworkErrorException( + 'Unable to connect to "' . $this->uri . '": ' . $curlErrorNumber . ' ' . $curlErrorString, + $curlErrorNumber, + null, + $curl, + $this + ); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->strict_ssl); - // zero is safe for all curl versions - $verifyValue = $this->strict_ssl + 0; - //Support for value 1 removed in cURL 7.28.1 value 2 valid in all versions - if ($verifyValue > 0) $verifyValue++; - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyValue); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $exception->setCurlErrorNumber($curlErrorNumber)->setCurlErrorString($curlErrorString); - // https://github.com/nategood/httpful/issues/84 - // set Content-Length to the size of the payload if present - if (isset($this->payload)) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $this->serialized_payload); - if (!$this->isUpload()) { - $this->headers['Content-Length'] = - $this->_determineLength($this->serialized_payload); + throw $exception; } - } - $headers = array(); - // https://github.com/nategood/httpful/issues/37 - // Except header removes any HTTP 1.1 Continue from response headers - $headers[] = 'Expect:'; + $this->_error('Unable to connect to "' . $this->uri . '".'); - if (!isset($this->headers['User-Agent'])) { - $headers[] = $this->buildUserAgent(); + throw new NetworkErrorException('Unable to connect to "' . $this->uri . '".'); } - $headers[] = "Content-Type: {$this->content_type}"; + $curl_info = $curl->getInfo(); - // allow custom Accept header if set - if (!isset($this->headers['Accept'])) { - // http://pretty-rfc.herokuapp.com/RFC2616#header.accept - $accept = 'Accept: */*; q=0.5, text/plain; q=0.8, text/html;level=3;'; + $headers = $curl->getRawResponseHeaders(); + $rawResponse = $curl->getRawResponse(); - if (!empty($this->expected_type)) { - $accept .= "q=0.9, {$this->expected_type}"; + if ($rawResponse === false) { + $body = ''; + } elseif ($rawResponse === true && $this->file_path_for_download && \is_string($this->file_path_for_download)) { + $body = \file_get_contents($this->file_path_for_download); + if ($body === false) { + throw new \ErrorException('file_get_contents return false for: ' . $this->file_path_for_download); } - - $headers[] = $accept; - } - - // Solve a bug on squid proxy, NONE/411 when miss content length - if (!isset($this->headers['Content-Length']) && !$this->isUpload()) { - $this->headers['Content-Length'] = 0; + } else { + $body = UTF8::remove_left( + (string) $rawResponse, + $headers + ); } - foreach ($this->headers as $header => $value) { - $headers[] = "$header: $value"; + // get the protocol + version + $protocol_version_regex = "/HTTP\/(?[\d\.]*+)/i"; + $protocol_version_matches = []; + $protocol_version = null; + \preg_match($protocol_version_regex, $headers, $protocol_version_matches); + if (isset($protocol_version_matches['version'])) { + $protocol_version = $protocol_version_matches['version']; } + $curl_info['protocol_version'] = $protocol_version; - $url = \parse_url($this->uri); - $path = (isset($url['path']) ? $url['path'] : '/').(isset($url['query']) ? '?'.$url['query'] : ''); - $this->raw_headers = "{$this->method} $path HTTP/1.1\r\n"; - $host = (isset($url['host']) ? $url['host'] : 'localhost').(isset($url['port']) ? ':'.$url['port'] : ''); - $this->raw_headers .= "Host: $host\r\n"; - $this->raw_headers .= \implode("\r\n", $headers); - $this->raw_headers .= "\r\n"; - - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + // DEBUG + //var_dump($body, $headers); - if ($this->_debug) { - curl_setopt($ch, CURLOPT_VERBOSE, true); - } - - curl_setopt($ch, CURLOPT_HEADER, 1); + return new Response( + $body, + $headers, + $this, + $curl_info + ); + } - // If there are some additional curl opts that the user wants - // to set, we can tack them in here - foreach ($this->additional_curl_opts as $curlopt => $curlval) { - curl_setopt($ch, $curlopt, $curlval); - } + /** + * @param bool $auto_parse perform automatic "smart" + * parsing based on Content-Type or "expectedType" + * If not auto parsing, Response->body returns the body + * as a string + * + * @return static + */ + private function _autoParse(bool $auto_parse = true): self + { + $new = clone $this; - $this->_ch = $ch; + $new->auto_parse = $auto_parse; - return $this; + return $new; } /** - * @param string $str payload + * @param string|null $str payload + * * @return int length of payload in bytes */ - public function _determineLength($str) + private function _determineLength($str): int { - if (function_exists('mb_strlen')) { - return mb_strlen($str, '8bit'); - } else { - return strlen($str); + if ($str === null) { + return 0; } + + return \strlen($str); } /** - * @return bool + * @param string $error + * + * @return void */ - public function isUpload() + private function _error($error) { - return Mime::UPLOAD == $this->content_type; + // global error handling + + $global_error_handler = Setup::getGlobalErrorHandler(); + if ($global_error_handler) { + if ($global_error_handler instanceof LoggerInterface) { + // PSR-3 https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md + $global_error_handler->error($error); + } elseif (\is_callable($global_error_handler)) { + // error callback + /** @noinspection VariableFunctionsUsageInspection */ + \call_user_func($global_error_handler, $error); + } + } + + // local error handling + + if (isset($this->error_handler)) { + if ($this->error_handler instanceof LoggerInterface) { + // PSR-3 https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md + $this->error_handler->error($error); + } elseif (\is_callable($this->error_handler)) { + // error callback + \call_user_func($this->error_handler, $error); + } + } else { + /** @noinspection ForgottenDebugOutputInspection */ + \error_log($error); + } } /** - * @return string + * Turn payload from structured data into a string based on the current Mime type. + * This uses the auto_serialize option to determine it's course of action. + * + * See serialize method for more. + * + * Added in support for custom payload serializers. + * The serialize_payload_method stuff still holds true though. + * + * @param array|string $payload + * + * @return mixed + * + * @see Request::registerPayloadSerializer() */ - public function buildUserAgent() + private function _serializePayload($payload) { - $user_agent = 'User-Agent: Httpful/' . Httpful::VERSION . ' (cURL/'; - $curl = \curl_version(); - - if (isset($curl['version'])) { - $user_agent .= $curl['version']; - } else { - $user_agent .= '?.?.?'; + if (empty($payload)) { + return ''; } - $user_agent .= ' PHP/'. PHP_VERSION . ' (' . PHP_OS . ')'; + if ($this->serialize_payload_method === static::SERIALIZE_PAYLOAD_NEVER) { + return $payload; + } - if (isset($_SERVER['SERVER_SOFTWARE'])) { - $user_agent .= ' ' . \preg_replace('~PHP/[\d\.]+~U', '', - $_SERVER['SERVER_SOFTWARE']); - } else { - if (isset($_SERVER['TERM_PROGRAM'])) { - $user_agent .= " {$_SERVER['TERM_PROGRAM']}"; - } + // When we are in "smart" mode, don't serialize strings/scalars, assume they are already serialized. + if ( + $this->serialize_payload_method === static::SERIALIZE_PAYLOAD_SMART + && + \is_array($payload) + && + \count($payload) === 1 + && + \array_keys($payload)[0] === 0 + && + \is_scalar($payload_first = \array_values($payload)[0]) + ) { + return $payload_first; + } - if (isset($_SERVER['TERM_PROGRAM_VERSION'])) { - $user_agent .= "/{$_SERVER['TERM_PROGRAM_VERSION']}"; + // Use a custom serializer if one is registered for this mime type. + $issetContentType = isset($this->payload_serializers[$this->content_type]); + if ( + $issetContentType + || + isset($this->payload_serializers['*']) + ) { + if ($issetContentType) { + $key = $this->content_type; + } else { + $key = '*'; } - } - if (isset($_SERVER['HTTP_USER_AGENT'])) { - $user_agent .= " {$_SERVER['HTTP_USER_AGENT']}"; + return \call_user_func($this->payload_serializers[$key], $payload); } - $user_agent .= ')'; - - return $user_agent; + return Setup::setupGlobalMimeType($this->content_type)->serialize($payload); } /** - * Takes a curl result and generates a Response from it - * @return Response + * Set the body of the request. + * + * @param mixed|null $payload + * @param mixed|null $key + * @param string|null $mimeType currently, sets the sends AND expects mime type although this + * behavior may change in the next minor release (as it is a potential breaking change) + * + * @return static */ - public function buildResponse($result) { - if ($result === false) { - if ($curlErrorNumber = curl_errno($this->_ch)) { - $curlErrorString = curl_error($this->_ch); - $this->_error($curlErrorString); - - $exception = new ConnectionErrorException('Unable to connect to "'.$this->uri.'": ' - . $curlErrorNumber . ' ' . $curlErrorString); + private function _setBody($payload, $key = null, string $mimeType = null): self + { + $this->_withMimeType($mimeType); - $exception->setCurlErrorNumber($curlErrorNumber) - ->setCurlErrorString($curlErrorString); + if (!empty($payload)) { + if (\is_array($payload)) { + foreach ($payload as $keyInner => $valueInner) { + $this->_setBody($valueInner, $keyInner, $mimeType); + } - throw $exception; + return $this; } - $this->_error('Unable to connect to "'.$this->uri.'".'); - throw new ConnectionErrorException('Unable to connect to "'.$this->uri.'".'); - } + if ($payload instanceof StreamInterface) { + $this->payload = (string) $payload; + } elseif ($key === null) { + if (\is_string($this->payload)) { + $tmpPayload = $this->payload; + $this->payload = []; + $this->payload[] = $tmpPayload; + } - $info = curl_getinfo($this->_ch); + $this->payload[] = $payload; + } else { + if (\is_string($this->payload)) { + $tmpPayload = $this->payload; + $this->payload = []; + $this->payload[] = $tmpPayload; + } - // Remove the "HTTP/1.x 200 Connection established" string and any other headers added by proxy - $proxy_regex = "/HTTP\/1\.[01] 200 Connection established.*?\r\n\r\n/si"; - if ($this->hasProxy() && preg_match($proxy_regex, $result)) { - $result = preg_replace($proxy_regex, '', $result); + $this->payload[$key] = $payload; + } } - $response = explode("\r\n\r\n", $result, 2 + $info['redirect_count']); - - $body = array_pop($response); - $headers = array_pop($response); + // Don't call _serializePayload yet. + // Wait until we actually send off the request to convert payload to string. + // At that time, the `serialized_payload` is set accordingly. - return new Response($body, $headers, $this, $info); + return $this; } /** - * Semi-reluctantly added this as a way to add in curl opts - * that are not otherwise accessible from the rest of the API. - * @param string $curlopt - * @param mixed $curloptval - * @return Request + * Set the defaults on a newly instantiated object + * Doesn't copy variables prefixed with _ + * + * @return static */ - public function addOnCurlOption($curlopt, $curloptval) + private function _setDefaultsFromTemplate(): self { - $this->additional_curl_opts[$curlopt] = $curloptval; + if ($this->template !== null) { + if (\function_exists('gzdecode')) { + $this->template->content_encoding = 'gzip'; + } elseif (\function_exists('gzinflate')) { + $this->template->content_encoding = 'deflate'; + } + + foreach ($this->template as $k => $v) { + if ($k[0] !== '_') { + $this->{$k} = $v; + } + } + } + return $this; } /** - * Turn payload from structured data into - * a string based on the current Mime type. - * This uses the auto_serialize option to determine - * it's course of action. See serialize method for more. - * Renamed from _detectPayload to _serializePayload as of - * 2012-02-15. + * Set the method. Shouldn't be called often as the preferred syntax + * for instantiation is the method specific factory methods. * - * Added in support for custom payload serializers. - * The serialize_payload_method stuff still holds true though. - * @see Request::registerPayloadSerializer() + * @param string|null $method * - * @param mixed $payload - * @return string + * @return static */ - private function _serializePayload($payload) + private function _setMethod($method): self { - if (empty($payload) || $this->serialize_payload_method === self::SERIALIZE_PAYLOAD_NEVER) - return $payload; - - // When we are in "smart" mode, don't serialize strings/scalars, assume they are already serialized - if ($this->serialize_payload_method === self::SERIALIZE_PAYLOAD_SMART && is_scalar($payload)) - return $payload; + if (empty($method)) { + return $this; + } - // Use a custom serializer if one is registered for this mime type - if (isset($this->payload_serializers['*']) || isset($this->payload_serializers[$this->content_type])) { - $key = isset($this->payload_serializers[$this->content_type]) ? $this->content_type : '*'; - return call_user_func($this->payload_serializers[$key], $payload); + if (!\in_array($method, Http::allMethods(), true)) { + throw new RequestException($this, 'Unknown HTTP method: \'' . \strip_tags($method) . '\''); } - return Httpful::get($this->content_type)->serialize($payload); + $this->method = $method; + + return $this; } /** - * HTTP Method Get - * @param string $uri optional uri to use - * @param string $mime expected - * @return Request + * Do we strictly enforce SSL verification? + * + * @param bool $strict + * + * @return static */ - public static function get($uri, $mime = null) + private function _strictSSL($strict): self { - return self::init(Http::GET)->uri($uri)->mime($mime); - } + $new = clone $this; + $new->strict_ssl = $strict; - /** - * Like Request:::get, except that it sends off the request as well - * returning a response - * @param string $uri optional uri to use - * @param string $mime expected - * @return Response - */ - public static function getQuick($uri, $mime = null) - { - return self::get($uri, $mime)->send(); + return $new; } /** - * HTTP Method Post - * @param string $uri optional uri to use - * @param string $payload data to send in body of request - * @param string $mime MIME to use for Content-Type - * @return Request + * @return void */ - public static function post($uri, $payload = null, $mime = null) + private function _updateHostFromUri() { - return self::init(Http::POST)->uri($uri)->body($payload, $mime); - } + if ($this->uri === null) { + return; + } - /** - * HTTP Method Put - * @param string $uri optional uri to use - * @param string $payload data to send in body of request - * @param string $mime MIME to use for Content-Type - * @return Request - */ - public static function put($uri, $payload = null, $mime = null) - { - return self::init(Http::PUT)->uri($uri)->body($payload, $mime); + if ($this->uri_cache === \serialize($this->uri)) { + return; + } + + $host = $this->uri->getHost(); + + if ($host === '') { + return; + } + + $port = $this->uri->getPort(); + if ($port !== null) { + $host .= ':' . $port; + } + + // Ensure Host is the first header. + // See: http://tools.ietf.org/html/rfc7230#section-5.4 + $this->headers = new Headers(['Host' => [$host]] + $this->withoutHeader('Host')->getHeaders()); + + $this->uri_cache = \serialize($this->uri); } /** - * HTTP Method Patch - * @param string $uri optional uri to use - * @param string $payload data to send in body of request - * @param string $mime MIME to use for Content-Type - * @return Request + * @param string|null $mime use a constant from Mime::* + * @param string|null $fallback use a constant from Mime::* + * + * @return static */ - public static function patch($uri, $payload = null, $mime = null) + private function _withContentType($mime, string $fallback = null): self { - return self::init(Http::PATCH)->uri($uri)->body($payload, $mime); + if (empty($mime) && empty($fallback)) { + return $this; + } + + if (empty($mime)) { + $mime = $fallback; + } + + $this->content_type = Mime::getFullMime($mime); + + if ($this->isUpload()) { + $this->neverSerializePayload(); + } + + return $this; } /** - * HTTP Method Delete - * @param string $uri optional uri to use - * @return Request + * @param string|null $mime use a constant from Mime::* + * @param string|null $fallback use a constant from Mime::* + * + * @return static */ - public static function delete($uri, $mime = null) + private function _withExpectedType($mime, string $fallback = null): self { - return self::init(Http::DELETE)->uri($uri)->mime($mime); + if (empty($mime) && empty($fallback)) { + return $this; + } + + if (empty($mime)) { + $mime = $fallback; + } + + $this->expected_type = Mime::getFullMime($mime); + + return $this; } /** - * HTTP Method Head - * @param string $uri optional uri to use - * @return Request + * Helper function to set the Content type and Expected as same in one swoop. + * + * @param string|null $mime mime type to use for content type and expected return type + * + * @return static */ - public static function head($uri) + private function _withMimeType($mime): self { - return self::init(Http::HEAD)->uri($uri); + if (empty($mime)) { + return $this; + } + + $this->expected_type = Mime::getFullMime($mime); + $this->content_type = $this->expected_type; + + if ($this->isUpload()) { + $this->neverSerializePayload(); + } + + return $this; } /** - * HTTP Method Options - * @param string $uri optional uri to use - * @return Request + * @param UriInterface $uri + * @param bool $preserveHost + * + * @return static */ - public static function options($uri) + private function _withUri(UriInterface $uri, $preserveHost = false): self { - return self::init(Http::OPTIONS)->uri($uri); + if ($this->uri === $uri) { + return $this; + } + + $this->uri = $uri; + + if (!$preserveHost) { + $this->_updateHostFromUri(); + } + + return $this; } } diff --git a/src/Httpful/Response.php b/src/Httpful/Response.php index 09996b6..7197f40 100644 --- a/src/Httpful/Response.php +++ b/src/Httpful/Response.php @@ -1,53 +1,584 @@ - */ -class Response +use Httpful\Exception\ResponseException; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; +use voku\helper\UTF8; + +class Response implements ResponseInterface { + /** + * @var StreamInterface + */ + private $body; + + /** + * @var mixed|null + */ + private $raw_body; + + /** + * @var Headers + */ + private $headers; - public $body, - $raw_body, - $headers, - $raw_headers, - $request, - $code = 0, - $content_type, - $parent_type, - $charset, - $meta_data, - $is_mime_vendor_specific = false, - $is_mime_personal = false; + /** + * @var mixed|null + */ + private $raw_headers; + + /** + * @var RequestInterface|null + */ + private $request; - private $parsers; + /** + * @var int + */ + private $code; + + /** + * @var string + */ + private $reason; + + /** + * @var string + */ + private $content_type = ''; + + /** + * Parent / Generic type (e.g. xml for application/vnd.github.message+xml) + * + * @var string + */ + private $parent_type = ''; + + /** + * @var string + */ + private $charset = ''; + + /** + * @var array + */ + private $meta_data; + + /** + * @var bool + */ + private $is_mime_vendor_specific = false; + + /** + * @var bool + */ + private $is_mime_personal = false; + + /** + * @param StreamInterface|string|null $body + * @param array|string|null $headers + * @param RequestInterface|null $request + * @param array $meta_data + *

e.g. [protocol_version] = '1.1'

+ */ + public function __construct( + $body = null, + $headers = null, + RequestInterface $request = null, + array $meta_data = [] + ) { + if (!($body instanceof Stream)) { + $this->raw_body = $body; + $body = Stream::create($body); + } + + $this->request = $request; + $this->raw_headers = $headers; + $this->meta_data = $meta_data; + + if (!isset($this->meta_data['protocol_version'])) { + $this->meta_data['protocol_version'] = '1.1'; + } + + if ( + \is_string($headers) + && + $headers !== '' + ) { + $this->code = $this->_getResponseCodeFromHeaderString($headers); + $this->reason = Http::reason($this->code); + $this->headers = Headers::fromString($headers); + } elseif ( + \is_array($headers) + && + \count($headers) > 0 + ) { + $this->code = 200; + $this->reason = Http::reason($this->code); + $this->headers = new Headers($headers); + } else { + $this->code = 200; + $this->reason = Http::reason($this->code); + $this->headers = new Headers(); + } + + $this->_interpretHeaders(); + + $bodyParsed = $this->_parse($body); + $this->body = Stream::createNotNull($bodyParsed); + $this->raw_body = $bodyParsed; + } + + /** + * @return void + */ + public function __clone() + { + $this->headers = clone $this->headers; + } + + /** + * @return string + */ + public function __toString() + { + if ( + $this->body->getSize() > 0 + && + !( + $this->raw_body + && + UTF8::is_serialized((string) $this->body) + ) + ) { + return (string) $this->body; + } + + if (\is_string($this->raw_body)) { + return (string) $this->raw_body; + } + + return (string) \json_encode($this->raw_body); + } /** - * @param string $body * @param string $headers - * @param Request $request - * @param array $meta_data + * + * @throws ResponseException if we are unable to parse response code from HTTP response + * + * @return int + * + * @internal */ - public function __construct($body, $headers, Request $request, array $meta_data = array()) + public function _getResponseCodeFromHeaderString($headers): int { - $this->request = $request; - $this->raw_headers = $headers; - $this->raw_body = $body; - $this->meta_data = $meta_data; + // If there was a redirect, we will get headers from one then one request, + // but will are only interested in the last request. + $headersTmp = \explode("\r\n\r\n", $headers); + $headersTmpCount = \count($headersTmp); + if ($headersTmpCount >= 2) { + $headers = $headersTmp[$headersTmpCount - 2]; + } - $this->code = $this->_parseCode($headers); - $this->headers = Response\Headers::fromString($headers); + $end = \strpos($headers, "\r\n"); + if ($end === false) { + $end = \strlen($headers); + } - $this->_interpretHeaders(); + $parts = \explode(' ', \substr($headers, 0, $end)); + + if ( + \count($parts) < 2 + || + !\is_numeric($parts[1]) + ) { + throw new ResponseException('Unable to parse response code from HTTP response due to malformed response: "' . \print_r($headers, true) . '"'); + } + + return (int) $parts[1]; + } + + /** + * @return StreamInterface + */ + public function getBody(): StreamInterface + { + return $this->body; + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name case-insensitive header field name + * + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($name): array + { + if ($this->headers->offsetExists($name)) { + $value = $this->headers->offsetGet($name); + + if (!\is_array($value)) { + return [\trim($value, " \t")]; + } + + foreach ($value as $keyInner => $valueInner) { + $value[$keyInner] = \trim($valueInner, " \t"); + } + + return $value; + } + + return []; + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name case-insensitive header field name + * + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine($name): string + { + return \implode(', ', $this->getHeader($name)); + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers->toArray(); + } + + /** + * Retrieves the HTTP protocol version as a string. + * + * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0"). + * + * @return string HTTP protocol version + */ + public function getProtocolVersion(): string + { + if (isset($this->meta_data['protocol_version'])) { + return (string) $this->meta_data['protocol_version']; + } + + return '1.1'; + } + + /** + * Gets the response reason phrase associated with the status code. + * + * Because a reason phrase is not a required element in a response + * status line, the reason phrase value MAY be null. Implementations MAY + * choose to return the default RFC 7231 recommended reason phrase (or those + * listed in the IANA HTTP Status Code Registry) for the response's + * status code. + * + * @see http://tools.ietf.org/html/rfc7231#section-6 + * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * + * @return string reason phrase; must return an empty string if none present + */ + public function getReasonPhrase(): string + { + return $this->reason; + } + + /** + * Gets the response status code. + * + * The status code is a 3-digit integer result code of the server's attempt + * to understand and satisfy the request. + * + * @return int status code + */ + public function getStatusCode(): int + { + return $this->code; + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name case-insensitive header field name + * + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader($name): bool + { + return $this->headers->offsetExists($name); + } + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name case-insensitive header field name to add + * @param string|string[] $value header value(s) + * + * @throws \InvalidArgumentException for invalid header names or values + * + * @return static + */ + public function withAddedHeader($name, $value): \Psr\Http\Message\MessageInterface + { + $new = clone $this; + + if (!\is_array($value)) { + $value = [$value]; + } + + if ($new->headers->offsetExists($name)) { + $new->headers->forceSet($name, \array_merge_recursive($new->headers->offsetGet($name), $value)); + } else { + $new->headers->forceSet($name, $value); + } + + return $new; + } + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body body + * + * @throws \InvalidArgumentException when the body is not valid + * + * @return static + */ + public function withBody(StreamInterface $body): \Psr\Http\Message\MessageInterface + { + $new = clone $this; + + $new->body = $body; - $this->body = $this->_parse($body); + return $new; } /** - * Status Code Definitions + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name case-insensitive header field name + * @param string|string[] $value header value(s) + * + * @throws \InvalidArgumentException for invalid header names or values + * + * @return static + */ + public function withHeader($name, $value): \Psr\Http\Message\MessageInterface + { + $new = clone $this; + + if (!\is_array($value)) { + $value = [$value]; + } + + $new->headers->forceSet($name, $value); + + return $new; + } + + /** + * Return an instance with the specified HTTP protocol version. + * + * The version string MUST contain only the HTTP version number (e.g., + * "1.1", "1.0"). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new protocol version. + * + * @param string $version HTTP protocol version + * + * @return static + */ + public function withProtocolVersion($version): \Psr\Http\Message\MessageInterface + { + $new = clone $this; + + $new->meta_data['protocol_version'] = $version; + + return $new; + } + + /** + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * @see http://tools.ietf.org/html/rfc7231#section-6 + * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * + * @param int $code the 3-digit integer result code to set + * @param string $reasonPhrase the reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification + * + * @throws \InvalidArgumentException for invalid status code arguments + * + * @return static + */ + public function withStatus($code, $reasonPhrase = null): ResponseInterface + { + $new = clone $this; + + $new->code = (int) $code; + + if (Http::responseCodeExists($new->code)) { + $new->reason = Http::reason($new->code); + } else { + $new->reason = ''; + } + + if ($reasonPhrase !== null) { + $new->reason = $reasonPhrase; + } + + return $new; + } + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name case-insensitive header field name to remove + * + * @return static + */ + public function withoutHeader($name): \Psr\Http\Message\MessageInterface + { + $new = clone $this; + + $new->headers->forceUnset($name); + + return $new; + } + + /** + * @return string + */ + public function getCharset(): string + { + return $this->charset; + } + + /** + * @return string + */ + public function getContentType(): string + { + return $this->content_type; + } + + /** + * @return Headers + */ + public function getHeadersObject(): Headers + { + return $this->headers; + } + + /** + * @return array + */ + public function getMetaData(): array + { + return $this->meta_data; + } + + /** + * @return string + */ + public function getParentType(): string + { + return $this->parent_type; + } + + /** + * @return mixed + */ + public function getRawBody() + { + return $this->raw_body; + } + + /** + * @return string + */ + public function getRawHeaders(): string + { + return $this->raw_headers; + } + + public function hasBody(): bool + { + return $this->body->getSize() > 0; + } + + /** + * Status Code Definitions. * * Informational 1xx * Successful 2xx @@ -59,7 +590,7 @@ public function __construct($body, $headers, Request $request, array $meta_data * * @return bool Did we receive a 4xx or 5xx? */ - public function hasErrors() + public function hasErrors(): bool { return $this->code >= 400; } @@ -67,112 +598,127 @@ public function hasErrors() /** * @return bool */ - public function hasBody() + public function isMimePersonal(): bool { - return !empty($this->body); + return $this->is_mime_personal; } /** - * Parse the response into a clean data structure - * (most often an associative array) based on the expected - * Mime type. - * @param string Http response body - * @return array|string|object the response parse accordingly + * @return bool */ - public function _parse($body) + public function isMimeVendorSpecific(): bool { - // If the user decided to forgo the automatic - // smart parsing, short circuit. - if (!$this->request->auto_parse) { - return $body; - } - - // If provided, use custom parsing callback - if (isset($this->request->parse_callback)) { - return call_user_func($this->request->parse_callback, $body); - } - - // Decide how to parse the body of the response in the following order - // 1. If provided, use the mime type specifically set as part of the `Request` - // 2. If a MimeHandler is registered for the content type, use it - // 3. If provided, use the "parent type" of the mime type from the response - // 4. Default to the content-type provided in the response - $parse_with = $this->request->expected_type; - if (empty($this->request->expected_type)) { - $parse_with = Httpful::hasParserRegistered($this->content_type) - ? $this->content_type - : $this->parent_type; - } - - return Httpful::get($parse_with)->parse($body); + return $this->is_mime_vendor_specific; } /** - * Parse text headers from response into - * array of key value pairs - * @param string $headers raw headers - * @return array parse headers + * @param string[] $header + * + * @return static */ - public function _parseHeaders($headers) + public function withHeaders(array $header) { - return Response\Headers::fromString($headers)->toArray(); - } + $new = clone $this; - public function _parseCode($headers) - { - $end = strpos($headers, "\r\n"); - if ($end === false) $end = strlen($headers); - $parts = explode(' ', substr($headers, 0, $end)); - if (count($parts) < 2 || !is_numeric($parts[1])) { - throw new \Exception("Unable to parse response code from HTTP response due to malformed response"); + foreach ($header as $name => $value) { + $new = $new->withHeader($name, $value); } - return intval($parts[1]); + + return $new; } /** * After we've parse the headers, let's clean things * up a bit and treat some headers specially + * + * @return void */ - public function _interpretHeaders() + private function _interpretHeaders() { // Parse the Content-Type and charset - $content_type = isset($this->headers['Content-Type']) ? $this->headers['Content-Type'] : ''; - $content_type = explode(';', $content_type); + $content_type = $this->headers['Content-Type'] ?? []; + foreach ($content_type as $content_type_inner) { + $content_type = \array_merge(\explode(';', $content_type_inner)); + } - $this->content_type = $content_type[0]; - if (count($content_type) == 2 && strpos($content_type[1], '=') !== false) { - list($nill, $this->charset) = explode('=', $content_type[1]); + $this->content_type = $content_type[0] ?? ''; + if ( + \count($content_type) === 2 + && + \strpos($content_type[1], '=') !== false + ) { + /** @noinspection PhpUnusedLocalVariableInspection */ + list($nill, $this->charset) = \explode('=', $content_type[1]); } - // RFC 2616 states "text/*" Content-Types should have a default - // charset of ISO-8859-1. "application/*" and other Content-Types - // are assumed to have UTF-8 unless otherwise specified. - // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7.1 - // http://www.w3.org/International/O-HTTP-charset.en.php - if (!isset($this->charset)) { - $this->charset = substr($this->content_type, 5) === 'text/' ? 'iso-8859-1' : 'utf-8'; + // fallback + if (!$this->charset) { + $this->charset = 'utf-8'; } - // Is vendor type? Is personal type? - if (strpos($this->content_type, '/') !== false) { - list($type, $sub_type) = explode('/', $this->content_type); - $this->is_mime_vendor_specific = substr($sub_type, 0, 4) === 'vnd.'; - $this->is_mime_personal = substr($sub_type, 0, 4) === 'prs.'; + // check for vendor & personal type + if (\strpos($this->content_type, '/') !== false) { + /** @noinspection PhpUnusedLocalVariableInspection */ + list($type, $sub_type) = \explode('/', $this->content_type); + $this->is_mime_vendor_specific = \strpos($sub_type, 'vnd.') === 0; + $this->is_mime_personal = \strpos($sub_type, 'prs.') === 0; } - // Parent type (e.g. xml for application/vnd.github.message+xml) $this->parent_type = $this->content_type; - if (strpos($this->content_type, '+') !== false) { - list($vendor, $this->parent_type) = explode('+', $this->content_type, 2); + if (\strpos($this->content_type, '+') !== false) { + /** @noinspection PhpUnusedLocalVariableInspection */ + list($vendor, $this->parent_type) = \explode('+', $this->content_type, 2); $this->parent_type = Mime::getFullMime($this->parent_type); } } /** - * @return string + * Parse the response into a clean data structure + * (most often an associative array) based on the expected + * Mime type. + * + * @param StreamInterface|null $body Http response body + * + * @return mixed the response parse accordingly */ - public function __toString() + private function _parse($body) { - return $this->raw_body; + // If the user decided to forgo the automatic smart parsing, short circuit. + if ( + $this->request instanceof Request + && + !$this->request->isAutoParse() + ) { + return $body; + } + + // If provided, use custom parsing callback. + if ( + $this->request instanceof Request + && + $this->request->hasParseCallback() + ) { + return \call_user_func($this->request->getParseCallback(), $body); + } + + // Decide how to parse the body of the response in the following order: + // + // 1. If provided, use the mime type specifically set as part of the `Request` + // 2. If a MimeHandler is registered for the content type, use it + // 3. If provided, use the "parent type" of the mime type from the response + // 4. Default to the content-type provided in the response + if ($this->request instanceof Request) { + $parse_with = $this->request->getExpectedType(); + } + + if (empty($parse_with)) { + if (Setup::hasParserRegistered($this->content_type)) { + $parse_with = $this->content_type; + } else { + $parse_with = $this->parent_type; + } + } + + return Setup::setupGlobalMimeType($parse_with)->parse((string) $body); } } diff --git a/src/Httpful/Response/Headers.php b/src/Httpful/Response/Headers.php deleted file mode 100644 index b900294..0000000 --- a/src/Httpful/Response/Headers.php +++ /dev/null @@ -1,98 +0,0 @@ -headers = $headers; - } - - /** - * @param string $string - * @return Headers - */ - public static function fromString($string) - { - $headers = preg_split("/(\r|\n)+/", $string, -1, \PREG_SPLIT_NO_EMPTY); - $parse_headers = array(); - for ($i = 1; $i < count($headers); $i++) { - list($key, $raw_value) = explode(':', $headers[$i], 2); - $key = trim($key); - $value = trim($raw_value); - if (array_key_exists($key, $parse_headers)) { - // See HTTP RFC Sec 4.2 Paragraph 5 - // http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - // If a header appears more than once, it must also be able to - // be represented as a single header with a comma-separated - // list of values. We transform accordingly. - $parse_headers[$key] .= ',' . $value; - } else { - $parse_headers[$key] = $value; - } - } - return new self($parse_headers); - } - - /** - * @param string $offset - * @return bool - */ - public function offsetExists($offset) - { - return isset($this->headers[$offset]); - } - - /** - * @param string $offset - * @return mixed - */ - public function offsetGet($offset) - { - if (isset($this->headers[$offset])) { - return $this->headers[$offset]; - } - } - - /** - * @param string $offset - * @param string $value - * @throws \Exception - */ - public function offsetSet($offset, $value) - { - throw new \Exception("Headers are read-only."); - } - - /** - * @param string $offset - * @throws \Exception - */ - public function offsetUnset($offset) - { - throw new \Exception("Headers are read-only."); - } - - /** - * @return int - */ - public function count() - { - return count($this->headers); - } - - /** - * @return array - */ - public function toArray() - { - return $this->headers; - } - -} diff --git a/src/Httpful/ServerRequest.php b/src/Httpful/ServerRequest.php new file mode 100644 index 0000000..3544bad --- /dev/null +++ b/src/Httpful/ServerRequest.php @@ -0,0 +1,214 @@ +serverParams = $serverParams; + + parent::__construct($method, $mime, $template); + } + + /** + * @param string $name + * @param mixed $default + * + * @return mixed|null + */ + public function getAttribute($name, $default = null) + { + if (\array_key_exists($name, $this->attributes) === false) { + return $default; + } + + return $this->attributes[$name]; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * @return array + */ + public function getCookieParams(): array + { + return $this->cookieParams; + } + + /** + * @return array|object|null + */ + public function getParsedBody() + { + return $this->parsedBody; + } + + /** + * @return array + */ + public function getQueryParams(): array + { + return $this->queryParams; + } + + /** + * @return array + */ + public function getServerParams(): array + { + return $this->serverParams; + } + + /** + * @return array + */ + public function getUploadedFiles(): array + { + return $this->uploadedFiles; + } + + /** + * @param string $name + * @param mixed $value + * + * @return static + */ + public function withAttribute($name, $value): self + { + $new = clone $this; + $new->attributes[$name] = $value; + + return $new; + } + + /** + * @param array $cookies + * + * @return ServerRequest|ServerRequestInterface + */ + public function withCookieParams(array $cookies): ServerRequestInterface + { + $new = clone $this; + $new->cookieParams = $cookies; + + return $new; + } + + /** + * @param array|object|null $data + * + * @return ServerRequest|ServerRequestInterface + */ + public function withParsedBody($data): ServerRequestInterface + { + if ( + !\is_array($data) + && + !\is_object($data) + && + $data !== null + ) { + throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null'); + } + + $new = clone $this; + $new->parsedBody = $data; + + return $new; + } + + /** + * @param array $query + * + * @return ServerRequestInterface|static + */ + public function withQueryParams(array $query): ServerRequestInterface + { + $new = clone $this; + $new->queryParams = $query; + + return $new; + } + + /** + * @param array $uploadedFiles + * + * @return ServerRequestInterface|static + */ + public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface + { + $new = clone $this; + $new->uploadedFiles = $uploadedFiles; + + return $new; + } + + /** + * @param string $name + * + * @return static + */ + public function withoutAttribute($name): self + { + if (\array_key_exists($name, $this->attributes) === false) { + return $this; + } + + $new = clone $this; + unset($new->attributes[$name]); + + return $new; + } +} diff --git a/src/Httpful/Setup.php b/src/Httpful/Setup.php new file mode 100644 index 0000000..a894f6f --- /dev/null +++ b/src/Httpful/Setup.php @@ -0,0 +1,166 @@ + new CsvMimeHandler(), + Mime::FORM => new FormMimeHandler(), + Mime::HTML => new HtmlMimeHandler(), + Mime::JS => new DefaultMimeHandler(), + Mime::JSON => new JsonMimeHandler(['decode_as_array' => true]), + Mime::PLAIN => new DefaultMimeHandler(), + Mime::XHTML => new HtmlMimeHandler(), + Mime::XML => new XmlMimeHandler(), + Mime::YAML => new DefaultMimeHandler(), + ]; + + foreach ($handlers as $mime => $handler) { + // Don't overwrite if the handler has already been registered. + if (self::hasParserRegistered($mime)) { + continue; + } + + self::registerMimeHandler($mime, $handler); + } + + self::$mime_registered = true; + } + + /** + * @param callable|LoggerInterface|null $error_handler + * + * @return void + */ + public static function registerGlobalErrorHandler($error_handler = null) + { + if ( + !$error_handler instanceof LoggerInterface + && + !\is_callable($error_handler) + ) { + throw new \InvalidArgumentException('Only callable or LoggerInterface are allowed as global error callback.'); + } + + self::$global_error_handler = $error_handler; + } + + /** + * @param MimeHandlerInterface $global_mime_handler + * + * @return void + */ + public static function registerGlobalMimeHandler(MimeHandlerInterface $global_mime_handler) + { + self::$global_mime_handler = $global_mime_handler; + } + + /** + * @param string $mimeType + * @param MimeHandlerInterface $handler + * + * @return void + */ + public static function registerMimeHandler($mimeType, MimeHandlerInterface $handler) + { + self::$mime_registrar[$mimeType] = $handler; + } + + /** + * @return MimeHandlerInterface + */ + public static function reset(): MimeHandlerInterface + { + self::$mime_registrar = []; + self::$mime_registered = false; + self::$global_error_handler = null; + self::$global_mime_handler = null; + + self::initMimeHandlers(); + + return self::setupGlobalMimeType(); + } + + /** + * @param string $mimeType + * + * @return MimeHandlerInterface + */ + public static function setupGlobalMimeType($mimeType = null): MimeHandlerInterface + { + self::initMimeHandlers(); + + if (isset(self::$mime_registrar[$mimeType])) { + return self::$mime_registrar[$mimeType]; + } + + if (empty(self::$global_mime_handler)) { + self::$global_mime_handler = new DefaultMimeHandler(); + } + + return self::$global_mime_handler; + } +} diff --git a/src/Httpful/Stream.php b/src/Httpful/Stream.php new file mode 100644 index 0000000..68478ae --- /dev/null +++ b/src/Httpful/Stream.php @@ -0,0 +1,500 @@ +> Hash of readable and writable stream types + */ + const READ_WRITE_HASH = [ + 'read' => [ + 'r' => true, + 'w+' => true, + 'r+' => true, + 'x+' => true, + 'c+' => true, + 'rb' => true, + 'w+b' => true, + 'r+b' => true, + 'x+b' => true, + 'c+b' => true, + 'rt' => true, + 'w+t' => true, + 'r+t' => true, + 'x+t' => true, + 'c+t' => true, + 'a+' => true, + ], + 'write' => [ + 'w' => true, + 'w+' => true, + 'rw' => true, + 'r+' => true, + 'x+' => true, + 'c+' => true, + 'wb' => true, + 'w+b' => true, + 'r+b' => true, + 'x+b' => true, + 'c+b' => true, + 'w+t' => true, + 'r+t' => true, + 'x+t' => true, + 'c+t' => true, + 'a' => true, + 'a+' => true, + ], + ]; + + /** + * @var string + */ + const WRITABLE_MODES = '/a|w|r\+|rb\+|rw|x|c/'; + + /** + * @var resource|null + */ + private $stream; + + /** + * @var int|null + */ + private $size; + + /** + * @var bool + */ + private $seekable; + + /** + * @var bool + */ + private $readable; + + /** + * @var bool + */ + private $writable; + + /** + * @var string|null + */ + private $uri; + + /** + * @var array + */ + private $customMetadata; + + /** + * @var bool + */ + private $serialized; + + /** + * This constructor accepts an associative array of options. + * + * - size: (int) If a read stream would otherwise have an indeterminate + * size, but the size is known due to foreknowledge, then you can + * provide that size, in bytes. + * - metadata: (array) Any additional metadata to return when the metadata + * of the stream is accessed. + * + * @param resource $stream stream resource to wrap + * @param array $options associative array of options + * + * @throws \InvalidArgumentException if the stream is not a stream resource + */ + public function __construct($stream, $options = []) + { + if (!\is_resource($stream)) { + throw new \InvalidArgumentException('Stream must be a resource'); + } + + if (isset($options['size'])) { + $this->size = (int) $options['size']; + } + + $this->customMetadata = $options['metadata'] ?? []; + + $this->serialized = $options['serialized'] ?? false; + + $this->stream = $stream; + $meta = \stream_get_meta_data($this->stream); + $this->seekable = (bool) $meta['seekable']; + $this->readable = (bool) \preg_match(self::READABLE_MODES, $meta['mode']); + $this->writable = (bool) \preg_match(self::WRITABLE_MODES, $meta['mode']); + $this->uri = $this->getMetadata('uri'); + } + + /** + * Closes the stream when the destructed + */ + public function __destruct() + { + $this->close(); + } + + /** + * @return string + */ + public function __toString(): string + { + try { + $this->seek(0); + + if ($this->stream === null) { + return ''; + } + + return (string) \stream_get_contents($this->stream); + } catch (\Exception $e) { + return ''; + } + } + + public function close(): void + { + if (isset($this->stream)) { + if (\is_resource($this->stream)) { + \fclose($this->stream); + } + + /** @noinspection UnusedFunctionResultInspection */ + $this->detach(); + } + } + + /** + * @return resource|null + */ + public function detach() + { + if (!isset($this->stream)) { + return null; + } + + $result = $this->stream; + $this->stream = null; + $this->size = null; + $this->uri = null; + $this->readable = false; + $this->writable = false; + $this->seekable = false; + + return $result; + } + + /** + * @return bool + */ + public function eof(): bool + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + return \feof($this->stream); + } + + /** + * @return mixed + */ + public function getContentsUnserialized() + { + $contents = $this->getContents(); + + if ($this->serialized) { + /** @noinspection UnserializeExploitsInspection */ + $contents = \unserialize($contents, []); + } + + return $contents; + } + + public function getContents(): string + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + $contents = \stream_get_contents($this->stream); + if ($contents === false) { + throw new \RuntimeException('Unable to read stream contents'); + } + + return $contents; + } + + /** + * @param string|null $key + * + * @return array|mixed|null + */ + public function getMetadata($key = null) + { + if (!isset($this->stream)) { + return $key ? null : []; + } + + if (!$key) { + /** @noinspection AdditionOperationOnArraysInspection */ + return $this->customMetadata + \stream_get_meta_data($this->stream); + } + + if (isset($this->customMetadata[$key])) { + return $this->customMetadata[$key]; + } + + $meta = \stream_get_meta_data($this->stream); + + return $meta[$key] ?? null; + } + + /** + * @return int|null + */ + public function getSize(): ?int + { + if ($this->size !== null) { + return $this->size; + } + + if (!isset($this->stream)) { + return null; + } + + // Clear the stat cache if the stream has a URI + if ($this->uri) { + \clearstatcache(true, $this->uri); + } + + $stats = \fstat($this->stream); + if ($stats !== false) { + $this->size = $stats['size']; + + return $this->size; + } + + return null; + } + + /** + * @return bool + */ + public function isReadable(): bool + { + return $this->readable; + } + + /** + * @return bool + */ + public function isSeekable(): bool + { + return $this->seekable; + } + + /** + * @return bool + */ + public function isWritable(): bool + { + return $this->writable; + } + + /** + * @param int $length + * + * @return string + */ + public function read($length): string + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + if (!$this->readable) { + throw new \RuntimeException('Cannot read from non-readable stream'); + } + + if ($length < 0) { + throw new \RuntimeException('Length parameter cannot be negative'); + } + + if ($length === 0) { + return ''; + } + + $string = \fread($this->stream, $length); + if ($string === false) { + throw new \RuntimeException('Unable to read from stream'); + } + + return $string; + } + + /** + * @return void + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * @param int $offset + * @param int $whence + * + * @return void + */ + public function seek($offset, $whence = \SEEK_SET): void + { + $whence = (int) $whence; + + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + if (!$this->seekable) { + throw new \RuntimeException('Stream is not seekable'); + } + if (\fseek($this->stream, $offset, $whence) === -1) { + throw new \RuntimeException( + 'Unable to seek to stream position ' + . $offset . ' with whence ' . \var_export($whence, true) + ); + } + } + + /** + * @return int + */ + public function tell(): int + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + $result = \ftell($this->stream); + if ($result === false) { + throw new \RuntimeException('Unable to determine stream position'); + } + + return $result; + } + + /** + * @param string $string + * + * @return int + */ + public function write($string): int + { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + if (!$this->writable) { + throw new \RuntimeException('Cannot write to a non-writable stream'); + } + + // We can't know the size after writing anything + $this->size = null; + $result = \fwrite($this->stream, $string); + + if ($result === false) { + throw new \RuntimeException('Unable to write to stream'); + } + + return $result; + } + + /** + * Creates a new PSR-7 stream. + * + * @param mixed $body + * + * @return StreamInterface|null + */ + public static function create($body = '') + { + if ($body instanceof StreamInterface) { + return $body; + } + + if ($body === null) { + $body = ''; + $serialized = false; + } elseif (\is_numeric($body)) { + $body = (string) $body; + $serialized = UTF8::is_serialized($body); + } elseif ( + \is_array($body) + || + $body instanceof \Serializable + ) { + $body = \serialize($body); + $serialized = true; + } else { + $serialized = false; + } + + if (\is_string($body)) { + $resource = \fopen('php://temp', 'rwb+'); + if ($resource !== false) { + \fwrite($resource, $body); + $body = $resource; + } + } + + if (\is_resource($body)) { + $new = new static($body); + if ($new->stream === null) { + return null; + } + + $meta = \stream_get_meta_data($new->stream); + $new->serialized = $serialized; + $new->seekable = $meta['seekable']; + $new->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); + $new->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); + $new->uri = $new->getMetadata('uri'); + + return $new; + } + + return null; + } + + /** + * @param mixed $body + * + * @return StreamInterface + */ + public static function createNotNull($body = ''): StreamInterface + { + $stream = static::create($body); + if ($stream === null) { + $stream = static::create(); + } + + \assert($stream instanceof self); + + return $stream; + } +} diff --git a/src/Httpful/UploadedFile.php b/src/Httpful/UploadedFile.php new file mode 100644 index 0000000..70505e4 --- /dev/null +++ b/src/Httpful/UploadedFile.php @@ -0,0 +1,230 @@ + 1, + \UPLOAD_ERR_INI_SIZE => 1, + \UPLOAD_ERR_FORM_SIZE => 1, + \UPLOAD_ERR_PARTIAL => 1, + \UPLOAD_ERR_NO_FILE => 1, + \UPLOAD_ERR_NO_TMP_DIR => 1, + \UPLOAD_ERR_CANT_WRITE => 1, + \UPLOAD_ERR_EXTENSION => 1, + ]; + + /** + * @var string|null + */ + private $clientFilename; + + /** + * @var string|null + */ + private $clientMediaType; + + /** + * @var int + */ + private $error; + + /** + * @var string|null + */ + private $file; + + /** + * @var bool + */ + private $moved = false; + + /** + * @var int + */ + private $size; + + /** + * @var StreamInterface|null + */ + private $stream; + + /** + * @param resource|StreamInterface|string $streamOrFile + * @param int $size + * @param int $errorStatus + * @param string|null $clientFilename + * @param string|null $clientMediaType + */ + public function __construct( + $streamOrFile, + $size, + $errorStatus, + $clientFilename = null, + $clientMediaType = null + ) { + if ( + \is_int($errorStatus) === false + || + !isset(self::ERRORS[$errorStatus]) + ) { + throw new \InvalidArgumentException('Upload file error status must be an integer value and one of the "UPLOAD_ERR_*" constants.'); + } + + if (\is_int($size) === false) { + throw new \InvalidArgumentException('Upload file size must be an integer'); + } + + if ( + $clientFilename !== null + && + !\is_string($clientFilename) + ) { + throw new \InvalidArgumentException('Upload file client filename must be a string or null'); + } + + if ( + $clientMediaType !== null + && + !\is_string($clientMediaType) + ) { + throw new \InvalidArgumentException('Upload file client media type must be a string or null'); + } + + $this->error = $errorStatus; + $this->size = $size; + $this->clientFilename = $clientFilename; + $this->clientMediaType = $clientMediaType; + + if ($this->error === \UPLOAD_ERR_OK) { + // Depending on the value set file or stream variable. + if (\is_string($streamOrFile)) { + $this->file = $streamOrFile; + } elseif (\is_resource($streamOrFile)) { + $this->stream = Stream::create($streamOrFile); + } elseif ($streamOrFile instanceof StreamInterface) { + $this->stream = $streamOrFile; + } else { + throw new \InvalidArgumentException('Invalid stream or file provided for UploadedFile'); + } + } + } + + /** + * @return string|null + */ + public function getClientFilename(): ?string + { + return $this->clientFilename; + } + + /** + * @return string|null + */ + public function getClientMediaType(): ?string + { + return $this->clientMediaType; + } + + /** + * @return int + */ + public function getError(): int + { + return $this->error; + } + + /** + * @return int + */ + public function getSize(): int + { + return $this->size; + } + + /** + * @return StreamInterface + */ + public function getStream(): StreamInterface + { + $this->_validateActive(); + + if ($this->stream instanceof StreamInterface) { + return $this->stream; + } + + if ($this->file !== null) { + $resource = \fopen($this->file, 'rb'); + } else { + $resource = ''; + } + + return Stream::createNotNull($resource); + } + + /** + * @param string $targetPath + * + * @return void + */ + public function moveTo($targetPath): void + { + $this->_validateActive(); + + if ( + !\is_string($targetPath) + || + $targetPath === '' + ) { + throw new \InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + } + + if ($this->file !== null) { + $this->moved = 'cli' === \PHP_SAPI ? \rename($this->file, $targetPath) : \move_uploaded_file($this->file, $targetPath); + } else { + $stream = $this->getStream(); + if ($stream->isSeekable()) { + $stream->rewind(); + } + + // Copy the contents of a stream into another stream until end-of-file. + $dest = Stream::createNotNull(\fopen($targetPath, 'wb')); + while (!$stream->eof()) { + if (!$dest->write($stream->read(1048576))) { + break; + } + } + + $this->moved = true; + } + + if ($this->moved === false) { + throw new \RuntimeException(\sprintf('Uploaded file could not be moved to %s', $targetPath)); + } + } + + /** + * @throws \RuntimeException if is moved or not ok + * + * @return void + */ + private function _validateActive() + { + if ($this->error !== \UPLOAD_ERR_OK) { + throw new \RuntimeException('Cannot retrieve stream due to upload error'); + } + + if ($this->moved) { + throw new \RuntimeException('Cannot retrieve stream after it has already been moved'); + } + } +} diff --git a/src/Httpful/Uri.php b/src/Httpful/Uri.php new file mode 100644 index 0000000..c65f1b2 --- /dev/null +++ b/src/Httpful/Uri.php @@ -0,0 +1,860 @@ + 80, + 'https' => 443, + 'ftp' => 21, + 'gopher' => 70, + 'nntp' => 119, + 'news' => 119, + 'telnet' => 23, + 'tn3270' => 23, + 'imap' => 143, + 'pop' => 110, + 'ldap' => 389, + ]; + + /** + * @var string + */ + private static $charUnreserved = 'a-zA-Z0-9_\-\.~'; + + /** + * @var string + */ + private static $charSubDelims = '!\$&\'\(\)\*\+,;='; + + /** + * @var array + */ + private static $replaceQuery = [ + '=' => '%3D', + '&' => '%26', + ]; + + /** + * @var string uri scheme + */ + private $scheme = ''; + + /** + * @var string uri user info + */ + private $userInfo = ''; + + /** + * @var string uri host + */ + private $host = ''; + + /** + * @var int|null uri port + */ + private $port; + + /** + * @var string uri path + */ + private $path = ''; + + /** + * @var string uri query string + */ + private $query = ''; + + /** + * @var string uri fragment + */ + private $fragment = ''; + + /** + * @param string $uri URI to parse + */ + public function __construct($uri = '') + { + // weak type check to also accept null until we can add scalar type hints + if ($uri !== '') { + $parts = \parse_url($uri); + + if ($parts === false) { + throw new \InvalidArgumentException("Unable to parse URI: {$uri}"); + } + + $this->_applyParts($parts); + } + } + + /** + * @return string + */ + public function __toString(): string + { + return self::composeComponents( + $this->scheme, + $this->getAuthority(), + $this->path, + $this->query, + $this->fragment + ); + } + + /** + * @return string + */ + public function getAuthority(): string + { + if ($this->host === '') { + return ''; + } + + $authority = $this->host; + if ($this->userInfo !== '') { + $authority = $this->userInfo . '@' . $authority; + } + + if ($this->port !== null) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + /** + * @return string + */ + public function getFragment(): string + { + return $this->fragment; + } + + /** + * @return string + */ + public function getHost(): string + { + return $this->host; + } + + /** + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * @return int|null + */ + public function getPort(): ?int + { + return $this->port; + } + + /** + * @return string + */ + public function getQuery(): string + { + return $this->query; + } + + /** + * @return string + */ + public function getScheme(): string + { + return $this->scheme; + } + + /** + * @return string + */ + public function getUserInfo(): string + { + return $this->userInfo; + } + + /** + * @param string $fragment + * + * @return $this|Uri|UriInterface + */ + public function withFragment($fragment): UriInterface + { + $fragment = $this->_filterQueryAndFragment($fragment); + + if ($this->fragment === $fragment) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + /** + * @param string $host + * + * @return $this|Uri|UriInterface + */ + public function withHost($host): UriInterface + { + $host = $this->_filterHost($host); + + if ($this->host === $host) { + return $this; + } + + $new = clone $this; + $new->host = $host; + $new->_validateState(); + + return $new; + } + + /** + * @param string $path + * + * @return $this|Uri|UriInterface + */ + public function withPath($path): UriInterface + { + $path = $this->_filterPath($path); + + if ($this->path === $path) { + return $this; + } + + $new = clone $this; + $new->path = $path; + $new->_validateState(); + + return $new; + } + + /** + * @param int|null $port + * + * @return $this|Uri|UriInterface + */ + public function withPort($port): UriInterface + { + $port = $this->_filterPort($port); + + if ($this->port === $port) { + return $this; + } + + $new = clone $this; + $new->port = $port; + $new->_removeDefaultPort(); + $new->_validateState(); + + return $new; + } + + /** + * @param string $query + * + * @return $this|Uri|UriInterface + */ + public function withQuery($query): UriInterface + { + $query = $this->_filterQueryAndFragment($query); + + if ($this->query === $query) { + return $this; + } + + $new = clone $this; + $new->query = $query; + + return $new; + } + + /** + * @param string $scheme + * + * @return $this|Uri|UriInterface + */ + public function withScheme($scheme): UriInterface + { + $scheme = $this->_filterScheme($scheme); + + if ($this->scheme === $scheme) { + return $this; + } + + $new = clone $this; + $new->scheme = $scheme; + $new->_removeDefaultPort(); + $new->_validateState(); + + return $new; + } + + /** + * @param string $user + * @param string|null $password + * + * @return $this|Uri|UriInterface + */ + public function withUserInfo($user, $password = null): UriInterface + { + $info = $this->_filterUserInfoComponent($user); + if ($password !== null) { + $info .= ':' . $this->_filterUserInfoComponent($password); + } + + if ($this->userInfo === $info) { + return $this; + } + + $new = clone $this; + $new->userInfo = $info; + $new->_validateState(); + + return $new; + } + + /** + * Composes a URI reference string from its various components. + * + * Usually this method does not need to be called manually but instead is used indirectly via + * `Psr\Http\Message\UriInterface::__toString`. + * + * PSR-7 UriInterface treats an empty component the same as a missing component as + * getQuery(), getFragment() etc. always return a string. This explains the slight + * difference to RFC 3986 Section 5.3. + * + * Another adjustment is that the authority separator is added even when the authority is missing/empty + * for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with + * `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But + * `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to + * that format). + * + * @param string $scheme + * @param string $authority + * @param string $path + * @param string $query + * @param string $fragment + * + * @return string + * + * @see https://tools.ietf.org/html/rfc3986#section-5.3 + */ + public static function composeComponents($scheme, $authority, $path, $query, $fragment): string + { + // init + $uri = ''; + + // weak type checks to also accept null until we can add scalar type hints + if ($scheme !== '') { + $uri .= $scheme . ':'; + } + + if ($authority !== '' || $scheme === 'file') { + $uri .= '//' . $authority; + } + + $uri .= $path; + + if ($query !== '') { + $uri .= '?' . $query; + } + + if ($fragment !== '') { + $uri .= '#' . $fragment; + } + + return $uri; + } + + /** + * Creates a URI from a hash of `parse_url` components. + * + * @param array $parts + * + * @throws \InvalidArgumentException if the components do not form a valid URI + * + * @return UriInterface + * + * @see http://php.net/manual/en/function.parse-url.php + */ + public static function fromParts(array $parts): UriInterface + { + $uri = new self(); + $uri->_applyParts($parts); + $uri->_validateState(); + + return $uri; + } + + /** + * Whether the URI is absolute, i.e. it has a scheme. + * + * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true + * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative + * to another URI, the base URI. Relative references can be divided into several forms: + * - network-path references, e.g. '//example.com/path' + * - absolute-path references, e.g. '/path' + * - relative-path references, e.g. 'subpath' + * + * @param UriInterface $uri + * + * @return bool + * + * @see Uri::isNetworkPathReference + * @see Uri::isAbsolutePathReference + * @see Uri::isRelativePathReference + * @see https://tools.ietf.org/html/rfc3986#section-4 + */ + public static function isAbsolute(UriInterface $uri): bool + { + return $uri->getScheme() !== ''; + } + + /** + * Whether the URI is a absolute-path reference. + * + * A relative reference that begins with a single slash character is termed an absolute-path reference. + * + * @param UriInterface $uri + * + * @return bool + * + * @see https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public static function isAbsolutePathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' + && + $uri->getAuthority() === '' + && + isset($uri->getPath()[0]) + && + $uri->getPath()[0] === '/'; + } + + /** + * Whether the URI has the default port of the current scheme. + * + * `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used + * independently of the implementation. + * + * @param UriInterface $uri + * + * @return bool + */ + public static function isDefaultPort(UriInterface $uri): bool + { + return $uri->getPort() === null + || + ( + isset(self::$defaultPorts[$uri->getScheme()]) + && + $uri->getPort() === self::$defaultPorts[$uri->getScheme()] + ); + } + + /** + * Whether the URI is a network-path reference. + * + * A relative reference that begins with two slash characters is termed an network-path reference. + * + * @param UriInterface $uri + * + * @return bool + * + * @see https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public static function isNetworkPathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' && $uri->getAuthority() !== ''; + } + + /** + * Whether the URI is a relative-path reference. + * + * A relative reference that does not begin with a slash character is termed a relative-path reference. + * + * @param UriInterface $uri + * + * @return bool + * + * @see https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public static function isRelativePathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' + && + $uri->getAuthority() === '' + && + (!isset($uri->getPath()[0]) || $uri->getPath()[0] !== '/'); + } + + /** + * Whether the URI is a same-document reference. + * + * A same-document reference refers to a URI that is, aside from its fragment + * component, identical to the base URI. When no base URI is given, only an empty + * URI reference (apart from its fragment) is considered a same-document reference. + * + * @param UriInterface $uri The URI to check + * @param UriInterface|null $base An optional base URI to compare against + * + * @return bool + * + * @see https://tools.ietf.org/html/rfc3986#section-4.4 + */ + public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool + { + if ($base !== null) { + $uri = UriResolver::resolve($base, $uri); + + return ($uri->getScheme() === $base->getScheme()) + && + ($uri->getAuthority() === $base->getAuthority()) + && + ($uri->getPath() === $base->getPath()) + && + ($uri->getQuery() === $base->getQuery()); + } + + return $uri->getScheme() === '' && $uri->getAuthority() === '' && $uri->getPath() === '' && $uri->getQuery() === ''; + } + + /** + * Creates a new URI with a specific query string value. + * + * Any existing query string values that exactly match the provided key are + * removed and replaced with the given key value pair. + * + * A value of null will set the query string key without a value, e.g. "key" + * instead of "key=value". + * + * @param UriInterface $uri URI to use as a base + * @param string $key key to set + * @param string|null $value Value to set + * + * @return UriInterface + */ + public static function withQueryValue(UriInterface $uri, $key, $value): UriInterface + { + $result = self::_getFilteredQueryString($uri, [$key]); + + $result[] = self::_generateQueryString($key, $value); + + /** @noinspection ImplodeMissUseInspection */ + return $uri->withQuery(\implode('&', $result)); + } + + /** + * Creates a new URI with multiple specific query string values. + * + * It has the same behavior as withQueryValue() but for an associative array of key => value. + * + * @param UriInterface $uri URI to use as a base + * @param array $keyValueArray Associative array of key and values + * + * @return UriInterface + */ + public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface + { + $result = self::_getFilteredQueryString($uri, \array_keys($keyValueArray)); + + foreach ($keyValueArray as $key => $value) { + $result[] = self::_generateQueryString($key, $value); + } + + /** @noinspection ImplodeMissUseInspection */ + return $uri->withQuery(\implode('&', $result)); + } + + /** + * Creates a new URI with a specific query string value removed. + * + * Any existing query string values that exactly match the provided key are + * removed. + * + * @param uriInterface $uri URI to use as a base + * @param string $key query string key to remove + * + * @return UriInterface + */ + public static function withoutQueryValue(UriInterface $uri, $key): UriInterface + { + $result = self::_getFilteredQueryString($uri, [$key]); + + /** @noinspection ImplodeMissUseInspection */ + return $uri->withQuery(\implode('&', $result)); + } + + /** + * Apply parse_url parts to a URI. + * + * @param array $parts array of parse_url parts to apply + * + * @return void + */ + private function _applyParts(array $parts) + { + $this->scheme = isset($parts['scheme']) + ? $this->_filterScheme($parts['scheme']) + : ''; + $this->userInfo = isset($parts['user']) + ? $this->_filterUserInfoComponent($parts['user']) + : ''; + $this->host = isset($parts['host']) + ? $this->_filterHost($parts['host']) + : ''; + $this->port = isset($parts['port']) + ? $this->_filterPort($parts['port']) + : null; + $this->path = isset($parts['path']) + ? $this->_filterPath($parts['path']) + : ''; + $this->query = isset($parts['query']) + ? $this->_filterQueryAndFragment($parts['query']) + : ''; + $this->fragment = isset($parts['fragment']) + ? $this->_filterQueryAndFragment($parts['fragment']) + : ''; + if (isset($parts['pass'])) { + $this->userInfo .= ':' . $this->_filterUserInfoComponent($parts['pass']); + } + + $this->_removeDefaultPort(); + } + + /** + * @param string $host + * + * @throws \InvalidArgumentException if the host is invalid + * + * @return string + */ + private function _filterHost($host): string + { + if (!\is_string($host)) { + throw new \InvalidArgumentException('Host must be a string'); + } + + return \strtolower($host); + } + + /** + * Filters the path of a URI + * + * @param string $path + * + * @throws \InvalidArgumentException if the path is invalid + * + * @return string + */ + private function _filterPath($path): string + { + if (!\is_string($path)) { + throw new \InvalidArgumentException('Path must be a string'); + } + + return (string) \preg_replace_callback( + '/(?:[^' . self::$charUnreserved . self::$charSubDelims . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', + [$this, '_rawurlencodeMatchZero'], + $path + ); + } + + /** + * @param int|null $port + * + * @throws \InvalidArgumentException if the port is invalid + * + * @return int|null + */ + private function _filterPort($port) + { + if ($port === null) { + return null; + } + + $port = (int) $port; + if ($port < 1 || $port > 0xffff) { + throw new \InvalidArgumentException( + \sprintf('Invalid port: %d. Must be between 1 and 65535', $port) + ); + } + + return $port; + } + + /** + * Filters the query string or fragment of a URI. + * + * @param string $str + * + * @throws \InvalidArgumentException if the query or fragment is invalid + * + * @return string + */ + private function _filterQueryAndFragment($str): string + { + if (!\is_string($str)) { + throw new \InvalidArgumentException('Query and fragment must be a string'); + } + + return (string) \preg_replace_callback( + '/(?:[^' . self::$charUnreserved . self::$charSubDelims . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', + [$this, '_rawurlencodeMatchZero'], + $str + ); + } + + /** + * @param string $scheme + * + * @throws \InvalidArgumentException if the scheme is invalid + * + * @return string + */ + private function _filterScheme($scheme): string + { + if (!\is_string($scheme)) { + throw new \InvalidArgumentException('Scheme must be a string'); + } + + return \strtolower($scheme); + } + + /** + * @param string $component + * + * @throws \InvalidArgumentException if the user info is invalid + * + * @return string + */ + private function _filterUserInfoComponent($component): string + { + if (!\is_string($component)) { + throw new \InvalidArgumentException('User info must be a string'); + } + + return (string) \preg_replace_callback( + '/(?:[^%' . self::$charUnreserved . self::$charSubDelims . ']+|%(?![A-Fa-f0-9]{2}))/', + [$this, '_rawurlencodeMatchZero'], + $component + ); + } + + /** + * @param string $key + * @param string|null $value + * + * @return string + */ + private static function _generateQueryString($key, $value): string + { + // Query string separators ("=", "&") within the key or value need to be encoded + // (while preventing double-encoding) before setting the query string. All other + // chars that need percent-encoding will be encoded by withQuery(). + $queryString = \strtr($key, self::$replaceQuery); + + if ($value !== null) { + $queryString .= '=' . \strtr($value, self::$replaceQuery); + } + + return $queryString; + } + + /** + * @param UriInterface $uri + * @param string[] $keys + * + * @return array + */ + private static function _getFilteredQueryString(UriInterface $uri, array $keys): array + { + $current = $uri->getQuery(); + + if ($current === '') { + return []; + } + + $decodedKeys = \array_map('rawurldecode', $keys); + + return \array_filter( + \explode('&', $current), + static function ($part) use ($decodedKeys) { + return !\in_array(\rawurldecode(\explode('=', $part, 2)[0]), $decodedKeys, true); + } + ); + } + + /** + * @param string[] $match + * + * @return string + */ + private function _rawurlencodeMatchZero(array $match): string + { + return \rawurlencode($match[0]); + } + + /** + * @return void + */ + private function _removeDefaultPort() + { + if ($this->port !== null && self::isDefaultPort($this)) { + $this->port = null; + } + } + + /** + * @return void + */ + private function _validateState() + { + if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) { + $this->host = self::HTTP_DEFAULT_HOST; + } + + if ($this->getAuthority() === '') { + if (\strpos($this->path, '//') === 0) { + throw new \InvalidArgumentException('The path of a URI without an authority must not start with two slashes "//"'); + } + if ($this->scheme === '' && \strpos(\explode('/', $this->path, 2)[0], ':') !== false) { + throw new \InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon'); + } + } elseif (isset($this->path[0]) && $this->path[0] !== '/') { + /** @noinspection PhpUsageOfSilenceOperatorInspection */ + @\trigger_error( + 'The path of a URI with an authority must start with a slash "/" or be empty. Automagically fixing the URI ' . + 'by adding a leading slash to the path is deprecated since version 1.4 and will throw an exception instead.', + \E_USER_DEPRECATED + ); + $this->path = '/' . $this->path; + } + } +} diff --git a/src/Httpful/UriResolver.php b/src/Httpful/UriResolver.php new file mode 100644 index 0000000..404478f --- /dev/null +++ b/src/Httpful/UriResolver.php @@ -0,0 +1,283 @@ +getScheme() !== '' + && + ( + $base->getScheme() !== $target->getScheme() + || + ($target->getAuthority() === '' && $base->getAuthority() !== '') + ) + ) { + return $target; + } + + if (Uri::isRelativePathReference($target)) { + // As the target is already highly relative we return it as-is. It would be possible to resolve + // the target with `$target = self::resolve($base, $target);` and then try make it more relative + // by removing a duplicate query. But let's not do that automatically. + return $target; + } + + $authority = $target->getAuthority(); + if ( + $authority !== '' + && + $base->getAuthority() !== $authority + ) { + return $target->withScheme(''); + } + + // We must remove the path before removing the authority because if the path starts with two slashes, the URI + // would turn invalid. And we also cannot set a relative path before removing the authority, as that is also + // invalid. + $emptyPathUri = $target->withScheme('')->withPath('')->withUserInfo('')->withPort(null)->withHost(''); + + if ($base->getPath() !== $target->getPath()) { + return $emptyPathUri->withPath(self::getRelativePath($base, $target)); + } + + if ($base->getQuery() === $target->getQuery()) { + // Only the target fragment is left. And it must be returned even if base and target fragment are the same. + return $emptyPathUri->withQuery(''); + } + + // If the base URI has a query but the target has none, we cannot return an empty path reference as it would + // inherit the base query component when resolving. + if ($target->getQuery() === '') { + $segments = \explode('/', $target->getPath()); + $lastSegment = \end($segments); + + return $emptyPathUri->withPath($lastSegment === '' ? './' : $lastSegment); + } + + return $emptyPathUri; + } + + /** + * Removes dot segments from a path and returns the new path. + * + * @param string $path + * + * @return string + * + * @see http://tools.ietf.org/html/rfc3986#section-5.2.4 + */ + public static function removeDotSegments($path): string + { + if ($path === '' || $path === '/') { + return $path; + } + + $results = []; + $segments = \explode('/', $path); + $segment = ''; + foreach ($segments as $segment) { + if ($segment === '..') { + \array_pop($results); + } elseif ($segment !== '.') { + $results[] = $segment; + } + } + + $newPath = \implode('/', $results); + + if ( + $path[0] === '/' + && + ( + !isset($newPath[0]) + || + $newPath[0] !== '/' + ) + ) { + // Re-add the leading slash if necessary for cases like "/.." + $newPath = '/' . $newPath; + } elseif ( + $newPath !== '' + && + ( + $segment + && + ( + $segment === '.' + || + $segment === '..' + ) + ) + ) { + // Add the trailing slash if necessary + // If newPath is not empty, then $segment must be set and is the last segment from the foreach + $newPath .= '/'; + } + + return $newPath; + } + + /** + * Converts the relative URI into a new URI that is resolved against the base URI. + * + * @param UriInterface $base Base URI + * @param UriInterface $rel Relative URI + * + * @return UriInterface + * + * @see http://tools.ietf.org/html/rfc3986#section-5.2 + */ + public static function resolve(UriInterface $base, UriInterface $rel): UriInterface + { + if ((string) $rel === '') { + // we can simply return the same base URI instance for this same-document reference + return $base; + } + + if ($rel->getScheme() !== '') { + return $rel->withPath(self::removeDotSegments($rel->getPath())); + } + + if ($rel->getAuthority() !== '') { + $targetAuthority = $rel->getAuthority(); + $targetPath = self::removeDotSegments($rel->getPath()); + $targetQuery = $rel->getQuery(); + } else { + $targetAuthority = $base->getAuthority(); + if ($rel->getPath() === '') { + $targetPath = $base->getPath(); + $targetQuery = $rel->getQuery() !== '' ? $rel->getQuery() : $base->getQuery(); + } else { + if ($rel->getPath()[0] === '/') { + $targetPath = $rel->getPath(); + } elseif ($targetAuthority !== '' && $base->getPath() === '') { + $targetPath = '/' . $rel->getPath(); + } else { + $lastSlashPos = \strrpos($base->getPath(), '/'); + if ($lastSlashPos === false) { + $targetPath = $rel->getPath(); + } else { + $targetPath = \substr($base->getPath(), 0, $lastSlashPos + 1) . $rel->getPath(); + } + } + $targetPath = self::removeDotSegments($targetPath); + $targetQuery = $rel->getQuery(); + } + } + + return new Uri( + Uri::composeComponents( + $base->getScheme(), + $targetAuthority, + $targetPath, + $targetQuery, + $rel->getFragment() + ) + ); + } + + private static function getRelativePath(UriInterface $base, UriInterface $target): string + { + $sourceSegments = \explode('/', $base->getPath()); + $targetSegments = \explode('/', $target->getPath()); + + \array_pop($sourceSegments); + + $targetLastSegment = \array_pop($targetSegments); + foreach ($sourceSegments as $i => $segment) { + if (isset($targetSegments[$i]) && $segment === $targetSegments[$i]) { + unset($sourceSegments[$i], $targetSegments[$i]); + } else { + break; + } + } + + $targetSegments[] = $targetLastSegment; + + $relativePath = \str_repeat('../', \count($sourceSegments)) . \implode('/', $targetSegments); + + // A reference to am empty last segment or an empty first sub-segment must be prefixed with "./". + // This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used + // as the first segment of a relative-path reference, as it would be mistaken for a scheme name. + /* @phpstan-ignore-next-line | FP? */ + if ($relativePath === '' || \strpos(\explode('/', $relativePath, 2)[0], ':') !== false) { + $relativePath = "./{$relativePath}"; + } elseif ($relativePath[0] === '/') { + if ($base->getAuthority() !== '' && $base->getPath() === '') { + // In this case an extra slash is added by resolve() automatically. So we must not add one here. + $relativePath = ".{$relativePath}"; + } else { + $relativePath = "./{$relativePath}"; + } + } + + return $relativePath; + } +} diff --git a/tests/Httpful/ClientMultiTest.php b/tests/Httpful/ClientMultiTest.php new file mode 100644 index 0000000..db10a7d --- /dev/null +++ b/tests/Httpful/ClientMultiTest.php @@ -0,0 +1,177 @@ +add_get('http://google.com?a=b'); + $multi->add_get('http://moelleken.org'); + + $multi->start(); + + static::assertCount(2, $results); + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('', strtolower((string) $results[0])); + static::assertStringContainsString('Lars Moelleken', (string) $results[1]); + } else { + static::assertContains('', strtolower((string) $results[0])); + static::assertContains('Lars Moelleken', (string) $results[1]); + } + } + + public function testBasicAuthRequest() + { + /** @var Response[] $results */ + $results = []; + $multi = new ClientMulti( + static function (Response $response, Request $request) use (&$results) { + $results[] = $response; + } + ); + + $request = (new Request(Http::GET)) + ->withUriFromString('https://postman-echo.com/basic-auth') + ->withBasicAuth('postman', 'password'); + + $multi->add_request($request); + + $multi->start(); + + static::assertSame('{"authenticated":true}', str_replace(["\n", ' '], '', (string) $results[0])); + } + + public function testPostAuthJson() + { + /** @var Response[] $results */ + $results = []; + $multi = new ClientMulti( + static function (Response $response, Request $request) use (&$results) { + $results[] = $response; + + static::assertSame( + ['Host' => ['postman-echo.com'], 'Foo' => ['bar'], 'Content-Length' => ['29']], + $request->getHeaders() + ); + + static::assertSame( + 'bar', + $request->getHeader('Foo')[0] + ); + + static::assertInstanceOf(\stdClass::class, $request->getHelperData('Foo')); + } + ); + + $request = Client::post_request( + 'https://postman-echo.com/post', + [ + 'foo1' => 'bar1', + 'foo2' => 'bar2', + ], + Mime::JSON + )->withBasicAuth( + 'postman', + 'password' + )->withContentEncoding(Encoding::GZIP) + ->withAddedHeader('Foo', 'bar') + ->addHelperData('Foo', new \stdClass()); + + $multi->add_request($request); + + $request = Client::post_request( + 'https://postman-echo.com/post', + [ + 'foo3' => 'bar1', + 'foo4' => 'bar2', + ], + Mime::JSON + )->withBasicAuth( + 'postman', + 'password' + )->withContentEncoding(Encoding::GZIP) + ->withAddedHeader('Foo', 'bar') + ->addHelperData('Foo', new \stdClass()); + + $multi->add_request($request); + + $multi->start(); + + static::assertCount(2, $results); + + $data = $results[1]->getRawBody(); + + static::assertTrue( + [ + 'foo3' => 'bar1', + 'foo4' => 'bar2', + ] === $data['data'] + || + [ + 'foo1' => 'bar1', + 'foo2' => 'bar2', + ] === $data['data'] + ); + + $data = $results[0]->getRawBody(); + + static::assertTrue( + [ + 'foo3' => 'bar1', + 'foo4' => 'bar2', + ] === $data['data'] + || + [ + 'foo1' => 'bar1', + 'foo2' => 'bar2', + ] === $data['data'] + ); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('https://postman-echo.com/post', $data['url']); + } else { + static::assertContains('https://postman-echo.com/post', $data['url']); + } + + static::assertSame('https', $data['headers']['x-forwarded-proto']); + + static::assertSame('gzip', $data['headers']['accept-encoding']); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('Basic ', $data['headers']['authorization']); + } else { + static::assertContains('Basic ', $data['headers']['authorization']); + } + + static::assertSame('application/json', $data['headers']['content-type']); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('Http/PhpClient', $data['headers']['user-agent']); + } else { + static::assertContains('Http/PhpClient', $data['headers']['user-agent']); + } + } +} diff --git a/tests/Httpful/ClientPromiseTest.php b/tests/Httpful/ClientPromiseTest.php new file mode 100644 index 0000000..ac8f660 --- /dev/null +++ b/tests/Httpful/ClientPromiseTest.php @@ -0,0 +1,71 @@ +withUriFromString('http://moelleken.org') + ->followRedirects(); + + $promise = $client->sendAsyncRequest($request); + + /** @var Response $result */ + $result = null; + $promise->then(static function (Response $response, Request $request) use (&$result) { + $result = $response; + }); + + $promise->wait(); + + static::assertInstanceOf(Response::class, $result); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('Lars Moelleken', (string) $result); + } else { + static::assertContains('Lars Moelleken', (string) $result); + } + } + + public function testGetMultiPromise() + { + $client = new ClientPromise(); + + $client->add_get('http://google.com?a=b'); + $client->add_get('http://moelleken.org'); + + $promise = $client->getPromise(); + + /** @var Response[] $results */ + $results = []; + $promise->then(static function (Response $response, Request $request) use (&$results) { + $results[] = $response; + }); + + $promise->wait(); + + static::assertCount(2, $results); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('', strtolower((string) $results[0])); + static::assertStringContainsString('Lars Moelleken', (string) $results[1]); + } else { + static::assertContains('', strtolower((string) $results[0])); + static::assertContains('Lars Moelleken', (string) $results[1]); + } + } +} diff --git a/tests/Httpful/ClientTest.php b/tests/Httpful/ClientTest.php new file mode 100644 index 0000000..14b9954 --- /dev/null +++ b/tests/Httpful/ClientTest.php @@ -0,0 +1,523 @@ +find('html'); + + /** @noinspection PhpUnitTestsInspection */ + static::assertTrue(\strpos((string) $html, 'expectsHtml()->send(); + static::assertSame('http://www.google.com/?a=b', $get->getMetaData()['url']); + static::assertInstanceOf(HtmlDomParser::class, $get->getRawBody()); + + $head = Client::head('http://www.google.com?a=b'); + + $expectedForDifferentCurlVersions = [ + 'http://www.google.com?a=b', + 'http://www.google.com/?a=b', + ]; + static::assertContains($head->getMetaData()['url'], $expectedForDifferentCurlVersions); + + static::assertTrue(is_string((string)$head->getBody())); + static::assertSame('1.1', $head->getProtocolVersion()); + + $post = Client::post('http://www.google.com?a=b'); + + $expectedForDifferentCurlVersions = [ + 'http://www.google.com?a=b', + 'http://www.google.com/?a=b', + ]; + static::assertContains($head->getMetaData()['url'], $expectedForDifferentCurlVersions); + static::assertSame(405, $post->getStatusCode()); + } + + public function testHttpFormClient() + { + $get = Client::post_request('http://google.com?a=b', ['a' => ['=', ' ', 2, 'ö']])->withContentTypeForm()->_curlPrep(); + static::assertSame('0=%3D&1=+&2=2&3=%C3%B6', $get->getSerializedPayload()); + } + + public function testSendRequest() + { + $expected_params = [ + 'foo1' => 'bar1', + 'foo2' => 'bar2', + ]; + $query = \http_build_query($expected_params); + $http = new Factory(); + + $response = (new Client())->sendRequest( + $http->createRequest( + Http::GET, + "https://postman-echo.com/get?{$query}", + Mime::JSON + ) + ); + + static::assertSame('1.1', $response->getProtocolVersion()); + static::assertSame(200, $response->getStatusCode()); + \assert($response instanceof Response); + $result = $response->getRawBody(); + static::assertSame($expected_params, $result['args']); + } + + public function testSendFormRequest() + { + $expected_data = [ + 'foo1' => 'bar1', + 'foo2' => 'bar2', + ]; + + $response = Client::post_form('https://postman-echo.com/post', $expected_data); + + static::assertSame($expected_data, $response['form'], 'server received x-www-form POST data'); + } + + public function testPostAuthJson() + { + $request = Client::post_request( + 'https://postman-echo.com/post', + [ + 'foo1' => 'bar1', + 'foo2' => 'bar2', + ], + Mime::JSON + )->withBasicAuth( + 'postman', + 'password' + )->withContentEncoding(Encoding::DEFLATE); + + $response = $request->send(); + + $data = $response->getRawBody(); + + static::assertSame( + [ + 'foo1' => 'bar1', + 'foo2' => 'bar2', + ], + $data['data'] + ); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('https://postman-echo.com/post', $data['url']); + } else { + static::assertContains('https://postman-echo.com/post', $data['url']); + } + + static::assertSame('https', $data['headers']['x-forwarded-proto']); + + static::assertSame('deflate', $data['headers']['accept-encoding']); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('Basic ', $data['headers']['authorization']); + } else { + static::assertContains('Basic ', $data['headers']['authorization']); + } + + static::assertSame('application/json', $data['headers']['content-type']); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('Http/PhpClient', $data['headers']['user-agent']); + } else { + static::assertContains('Http/PhpClient', $data['headers']['user-agent']); + } + } + + public function testBasicAuthRequest() + { + $response = (new Client())->sendRequest( + (new Request(Http::GET)) + ->withUriFromString('https://postman-echo.com/basic-auth') + ->withBasicAuth('postman', 'password') + ); + + static::assertSame('{"authenticated":true}', str_replace(["\n", ' '], '', (string) $response)); + } + + public function testDigestAuthRequest() + { + $response = (new Client())->sendRequest( + (new Request(Http::GET)) + ->withUriFromString('https://postman-echo.com/digest-auth') + ->withDigestAuth('postman', 'password') + ); + + static::assertSame('{"authenticated":true}', str_replace(["\n", ' '], '', (string) $response)); + } + + public function testSendJsonRequest() + { + $expected_data = [ + 'foo1' => 123, + 'foo2' => 456, + ]; + + $http = new Factory(); + + $body = $http->createStream( + \json_encode($expected_data) + ); + + $response = (new Client())->sendRequest( + $http->createRequest( + Http::POST, + 'https://postman-echo.com/post', + Mime::JSON + )->withBody($body) + ); + + static::assertSame('1.1', $response->getProtocolVersion()); + static::assertSame(200, $response->getStatusCode()); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('"content-type":"application\/json"', (string) $response); + } else { + static::assertContains('"content-type":"application\/json"', (string) $response); + } + } + + public function testPutCall() + { + $response = Client::put('https://postman-echo.com/put', 'lall'); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('"data":"lall"', str_replace(["\n", ' '], '', (string) $response)); + } else { + static::assertContains('"data":"lall"', str_replace(["\n", ' '], '', (string) $response)); + } + } + + public function testPatchCall() + { + $response = Client::patch('https://postman-echo.com/patch', 'lall'); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('"data":"lall"', str_replace(["\n", ' '], '', (string) $response)); + } else { + static::assertContains('"data":"lall"', str_replace(["\n", ' '], '', (string) $response)); + } + } + + public function testJsonHelper() + { + $expected_params = [ + 'foo1' => 'b%20a%20r%201', + 'foo2' => 'b a r 2', + ]; + + $response = Client::get_json('https://postman-echo.com/get', $expected_params); + static::assertSame($expected_params, $response['args']); + + $response = Client::get_json('https://postman-echo.com/get?', $expected_params); + static::assertSame($expected_params, $response['args']); + } + + public function testDownloadSimple() + { + $testFileUrl = 'http://thetofu.com/webtest/webmachine/test100k/test100.log'; + $tmpFile = \tempnam('/tmp', 'FOO'); + $expectedFileContent = \file_get_contents($testFileUrl); + + $response = Client::download($testFileUrl, $tmpFile, 5); + + static::assertTrue(\count($response->getHeaders()) > 0); + static::assertSame($expectedFileContent, $response->getRawBody()); + static::assertSame($expectedFileContent, \file_get_contents($tmpFile)); + } + + public function testReceiveHeader() + { + $http = new Factory(); + + $response = (new Client())->sendRequest( + $http->createRequest( + Http::GET, + 'https://postman-echo.com/headers', + Mime::JSON + )->withHeader('X-Hello', 'Hello World') + ); + + static::assertSame('1.1', $response->getProtocolVersion()); + static::assertSame(200, $response->getStatusCode()); + + static::assertSame( + 'application/json; charset=utf-8', + $response->getHeaderLine('Content-Type'), + 'Response model was populated with headers' + ); + + static::assertSame( + 'Hello World', + \json_decode((string) $response, true)['headers']['x-hello'], + 'server received custom header' + ); + } + + public function testReceiveHeaders() + { + $http = new Factory(); + + $response = (new Client())->sendRequest( + $http->createRequest( + Http::GET, + 'https://postman-echo.com/response-headers?x-hello[]=one&x-hello[]=two', + Mime::JSON + ) + ); + + static::assertSame('1.1', $response->getProtocolVersion()); + static::assertSame(200, $response->getStatusCode()); + + static::assertSame( + 'application/json; charset=utf-8', + $response->getHeaderLine('Content-Type'), + 'Response model was populated with headers' + ); + + static::assertSame( + ['one', 'two'], + $response->getHeader('X-Hello'), + 'Can parse multi-line header' + ); + } + + public function testHttp2() + { + \curl_version()['features']; + + if (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304) { + static::markTestSkipped('PHP 7.3.0 to 7.3.3 don\'t support HTTP/2 PUSH'); + } + + /** @noinspection SuspiciousBinaryOperationInspection */ + if (!\defined('CURLMOPT_PUSHFUNCTION') || ($v = \curl_version())['version_number'] < 0x073d00 || !(\CURL_VERSION_HTTP2 & $v['features'])) { + static::markTestSkipped('curl <7.61 is used or it is not compiled with support for HTTP/2 PUSH'); + } + + $http = new Factory(); + + $response = (new Client())->sendRequest( + $http->createRequest( + Http::GET, + 'https://http2.akamai.com/demo/tile-0.png' + )->withProtocolVersion(Http::HTTP_2_0) + ); + + static::assertSame('2', $response->getProtocolVersion()); + static::assertSame(200, $response->getStatusCode()); + + static::assertSame( + 'image/png', + $response->getHeaderLine('Content-Type') + ); + } + + public function testSelfSignedCertificate() + { + $this->expectException(NetworkExceptionInterface::class); + if (\method_exists(__CLASS__, 'expectExceptionMessageRegExp')) { + $this->expectExceptionMessageRegExp('/.*certificat.*/'); + } else { + $this->expectExceptionMessageMatches('/.*certificat.*/'); + } + + $client = new Client(); + $request = (new Request('GET'))->withUriFromString('https://self-signed.badssl.com/')->enableStrictSSL(); + /** @noinspection UnusedFunctionResultInspection */ + $client->sendRequest($request); + } + + public function testIgnoreCertificateErrors() + { + $client = new Client(); + $request = (new Request('GET', Mime::PLAIN)) + ->withUriFromString('https://self-signed.badssl.com/') + ->disableStrictSSL(); + $response = $client->sendRequest($request); + + static::assertEquals(200, $response->getStatusCode()); + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('self-signed.
badssl.com', (string) $response); + } else { + static::assertContains('self-signed.
badssl.com', (string) $response); + } + + // --- + + $client = new Client(); + $request = (new Request('GET', Mime::HTML)) + ->withUriFromString('https://self-signed.badssl.com/') + ->disableStrictSSL(); + $response = $client->sendRequest($request); + + static::assertEquals(200, $response->getStatusCode()); + \assert($response instanceof Response); + static::assertInstanceOf(DomParserInterface::class, $response->getRawBody()); + } + + public function testPageNotFound() + { + $client = new Client(); + $request = (new Request('GET'))->withUriFromString('http://www.google.com/DOES/NOT/EXISTS'); + $response = $client->sendRequest($request); + static::assertEquals(404, $response->getStatusCode()); + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('Error 404 (Not Found)', (string) $response->getBody()); + } else { + static::assertContains('<title>Error 404 (Not Found)', (string) $response->getBody()); + } + } + + public function testHostNotFound() + { + $this->expectException(NetworkExceptionInterface::class); + $this->expectExceptionMessage('Could not resolve host: www.does.not.exists'); + $client = new Client(); + $request = (new Request('GET'))->withUriFromString('http://www.does.not.exists'); + /** @noinspection UnusedFunctionResultInspection */ + $client->sendRequest($request); + } + + public function testInvalidMethod() + { + $this->expectException(RequestExceptionInterface::class); + $this->expectExceptionMessage("Unknown HTTP method: 'ASD'"); + $client = new Client(); + $request = (new Request('ASD'))->withUriFromString('http://www.google.it'); + /** @noinspection UnusedFunctionResultInspection */ + $client->sendRequest($request); + } + + public function testGet() + { + $client = new Client(); + $request = (new Request('GET')) + ->disableStrictSSL() + ->withUriFromString('https://moelleken.org/'); + $response = $client->sendRequest($request); + static::assertEquals(200, $response->getStatusCode()); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('Lars Moelleken', (string) $response->getBody()); + } else { + static::assertContains('Lars Moelleken', (string) $response->getBody()); + } + static::assertContains($response->getProtocolVersion(), ['1.1', '2']); + + static::assertEquals(['text/html; charset=utf-8'], $response->getHeader('content-type')); + } + + public function testCookie() + { + $client = new Client(); + $request = (new Request('GET'))->withUriFromString('https://httpbin.org/get'); + $request = $request->withAddedCookie('name', 'value'); + $response = $client->sendRequest($request); + static::assertEquals(200, $response->getStatusCode()); + $body = \json_decode((string) $response->getBody(), true); + $cookieSent = $body['headers']['Cookie']; + static::assertEquals('name=value', $cookieSent); + } + + public function testMultipleCookies() + { + $client = new Client(); + $request = (new Request('GET'))->withUriFromString('https://httpbin.org/get'); + $request = $request->withAddedCookie('name', 'value'); + $request = $request->withAddedCookie('foo', 'bar'); + $response = $client->sendRequest($request); + static::assertEquals(200, $response->getStatusCode()); + $body = \json_decode((string) $response->getBody(), true); + $cookieSent = $body['headers']['Cookie']; + static::assertEquals('name=value,foo=bar', $cookieSent); + } + + public function testPutSendData() + { + $client = new Client(); + $dataToSend = ['abc' => 'def']; + $request = (new Request('PUT', Mime::JSON)) + ->withUriFromString('https://httpbin.org/put') + ->withBodyFromArray($dataToSend) + ->withTimeout(60); + $response = $client->sendRequest($request); + static::assertEquals(200, $response->getStatusCode()); + $body = \json_decode((string) $response, true); + $dataSent = \json_decode($body['data'], true); + static::assertEquals($dataToSend, $dataSent); + } + + public function testFollowsRedirect() + { + $client = new Client(); + $request = (new Request('GET')) + ->withUriFromString('http://google.de') + ->followRedirects(); + $response = $client->sendRequest($request); + static::assertEquals(200, $response->getStatusCode()); + } + + public function testNotFollowsRedirect() + { + $client = new Client(); + $request = (new Request('GET')) + ->withUriFromString('http://google.de') + ->doNotFollowRedirects(); + $response = $client->sendRequest($request); + static::assertEquals(301, $response->getStatusCode()); + } + + public function testExpiredTimeout() + { + $this->expectException(NetworkExceptionInterface::class); + if (\method_exists(__CLASS__, 'expectExceptionMessageRegExp')) { + $this->expectExceptionMessageRegExp('/Timeout was reached/'); + } else { + $this->expectExceptionMessageMatches('/Timeout was reached/'); + } + $client = new Client(); + $request = (new Request())->withUriFromString('http://slowwly.robertomurray.co.uk/delay/10000/url/http://www.example.com') + ->withConnectionTimeoutInSeconds(0.001); + /** @noinspection UnusedFunctionResultInspection */ + $client->sendRequest($request); + } + + public function testNotExpiredTimeout() + { + $client = new Client(); + $request = (new Request('GET'))->withUriFromString('https://www.google.com/robots.txt') + ->withConnectionTimeoutInSeconds(10); + $response = $client->sendRequest($request); + static::assertEquals(200, $response->getStatusCode()); + } +} diff --git a/tests/Httpful/DevtoTest.php b/tests/Httpful/DevtoTest.php new file mode 100644 index 0000000..d51afdd --- /dev/null +++ b/tests/Httpful/DevtoTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Httpful\tests; + +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +final class DevtoTest extends TestCase +{ + public function testSimpleCall() + { + // init + $user = 'suckup_de'; + $ARTICLES_ENDPOINT = 'https://dev.to/api/articles?page=1&per_page=2'; + + // Prepare client-side promise handling. + $client = new \Httpful\ClientPromise(); + + // Send a simple client-side request. (non async) + $articles = ((\Httpful\Request::get($ARTICLES_ENDPOINT . '?username=' . $user)->withExpectedType(\Httpful\Mime::JSON))->send())->getRawBody(); + foreach ($articles as $article) { + // Representation of an outgoing, client-side request. + $request = \Httpful\Request::get($ARTICLES_ENDPOINT . '/' . $article['id'])->withExpectedType(\Httpful\Mime::JSON); + + // Sends a PSR-7 request in an asynchronous way. + $client->sendAsyncRequest($request); + } + + $promise = $client->getPromise(); + + // Add behavior for when the promise is resolved or rejected. + /** @var \Httpful\Response[] $results */ + $results = []; + $promise->then(static function (\Httpful\Response $response, \Httpful\Request $request) use (&$results) { + $results[] = $response; + }); + + // Wait for the promise to be fulfilled or rejected. + $promise->wait(); + + static::assertTrue(\count($results) === 2); + } +} diff --git a/tests/Httpful/FactoryTest.php b/tests/Httpful/FactoryTest.php new file mode 100644 index 0000000..2a572c6 --- /dev/null +++ b/tests/Httpful/FactoryTest.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Httpful\tests; + +use Httpful\Factory; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; + +/** + * @internal + */ +final class FactoryTest extends TestCase +{ + public function testCreateRequest() + { + $factory = new Factory(); + $r = $factory->createRequest('POST', 'https://nyholm.tech'); + + static::assertEquals('POST', $r->getMethod()); + static::assertEquals('https://nyholm.tech', $r->getUri()->__toString()); + + $headers = $r->getHeaders(); + static::assertCount(1, $headers); // Including HOST + } + + public function testCreateResponse() + { + $factory = new Factory(); + $usual = $factory->createResponse(404); + static::assertEquals(404, $usual->getStatusCode()); + static::assertEquals('Not Found', $usual->getReasonPhrase()); + + $r = $factory->createResponse(217, 'Perfect'); + + static::assertEquals(217, $r->getStatusCode()); + static::assertEquals('Perfect', $r->getReasonPhrase()); + } + + public function testCreateStream() + { + $factory = new Factory(); + $stream = $factory->createStream('foobar'); + + static::assertInstanceOf(StreamInterface::class, $stream); + static::assertEquals('foobar', $stream->__toString()); + } + + public function testCreateUri() + { + $factory = new Factory(); + $uri = $factory->createUri('https://nyholm.tech/foo'); + + static::assertInstanceOf(UriInterface::class, $uri); + static::assertEquals('https://nyholm.tech/foo', $uri->__toString()); + } +} diff --git a/tests/Httpful/HttpfulTest.php b/tests/Httpful/HttpfulTest.php index 76859e0..430816c 100644 --- a/tests/Httpful/HttpfulTest.php +++ b/tests/Httpful/HttpfulTest.php @@ -1,612 +1,780 @@ <?php -/** - * Port over the original tests into a more traditional PHPUnit - * format. Still need to hook into a lightweight HTTP server to - * better test some things (e.g. obscure cURL settings). I've moved - * the old tests and node.js server to the tests/.legacy directory. - * - * @author Nate Good <me@nategood.com> - */ -namespace Httpful\Test; -require(dirname(dirname(dirname(__FILE__))) . '/bootstrap.php'); -\Httpful\Bootstrap::init(); +declare(strict_types=1); -use Httpful\Httpful; -use Httpful\Request; -use Httpful\Mime; +namespace Httpful\tests; + +use Httpful\Exception\NetworkErrorException; +use Httpful\Handlers\DefaultMimeHandler; +use Httpful\Handlers\JsonMimeHandler; +use Httpful\Handlers\XmlMimeHandler; +use Httpful\Headers; use Httpful\Http; +use Httpful\Mime; +use Httpful\Request; use Httpful\Response; -use Httpful\Handlers\JsonHandler; +use Httpful\Setup; +use PHPUnit\Framework\TestCase; -define('TEST_SERVER', WEB_SERVER_HOST . ':' . WEB_SERVER_PORT); +/** @noinspection PhpMultipleClassesDeclarationsInOneFile */ -class HttpfulTest extends \PHPUnit_Framework_TestCase +/** + * @internal + */ +final class HttpfulTest extends TestCase { - const TEST_SERVER = TEST_SERVER; - const TEST_URL = 'http://127.0.0.1:8008'; - const TEST_URL_400 = 'http://127.0.0.1:8008/400'; - - const SAMPLE_JSON_HEADER = -"HTTP/1.1 200 OK -Content-Type: application/json -Connection: keep-alive -Transfer-Encoding: chunked\r\n"; - const SAMPLE_JSON_RESPONSE = '{"key":"value","object":{"key":"value"},"array":[1,2,3,4]}'; const SAMPLE_CSV_HEADER = -"HTTP/1.1 200 OK + "HTTP/1.1 200 OK Content-Type: text/csv Connection: keep-alive Transfer-Encoding: chunked\r\n"; + const SAMPLE_CSV_RESPONSE = -"Key1,Key2 + 'Key1,Key2 Value1,Value2 -\"40.0\",\"Forty\""; - const SAMPLE_XML_RESPONSE = '<stdClass><arrayProp><array><k1><myClass><intProp>2</intProp></myClass></k1></array></arrayProp><stringProp>a string</stringProp><boolProp>TRUE</boolProp></stdClass>'; - const SAMPLE_XML_HEADER = -"HTTP/1.1 200 OK -Content-Type: application/xml +"40.0","Forty"'; + + const SAMPLE_HTML_HEADER = + "HTTP/1.1 200 OK +Content-Type: test/html Connection: keep-alive Transfer-Encoding: chunked\r\n"; - const SAMPLE_VENDOR_HEADER = -"HTTP/1.1 200 OK -Content-Type: application/vnd.nategood.message+xml + + // INFO: Travis-CI can't handle e.g. "10.255.255.1" or "http://www.google.com:81" + const SAMPLE_HTML_RESPONSE = '<html lang="en"><head><title>foo2a stringTRUE'; + + const SAMPLE_JSON_HEADER = + "HTTP/1.1 200 OK +Content-Type: application/json Connection: keep-alive Transfer-Encoding: chunked\r\n"; - const SAMPLE_VENDOR_TYPE = "application/vnd.nategood.message+xml"; + + const SAMPLE_JSON_RESPONSE = '{"key":"value","object":{"key":"value"},"array":[1,2,3,4]}'; + const SAMPLE_MULTI_HEADER = -"HTTP/1.1 200 OK + "HTTP/1.1 200 OK Content-Type: application/json Connection: keep-alive Transfer-Encoding: chunked X-My-Header:Value1 X-My-Header:Value2\r\n"; - function testInit() - { - $r = Request::init(); - // Did we get a 'Request' object? - $this->assertEquals('Httpful\Request', get_class($r)); - } + const SAMPLE_VENDOR_HEADER = + "HTTP/1.1 200 OK +Content-Type: application/vnd.nategood.message+xml +Connection: keep-alive +Transfer-Encoding: chunked\r\n"; - function testDetermineLength() - { - $r = Request::init(); - $this->assertEquals(1, $r->_determineLength('A')); - $this->assertEquals(2, $r->_determineLength('À')); - $this->assertEquals(2, $r->_determineLength('Ab')); - $this->assertEquals(3, $r->_determineLength('Àb')); - $this->assertEquals(6, $r->_determineLength('世界')); - } + const SAMPLE_VENDOR_TYPE = 'application/vnd.nategood.message+xml'; - function testMethods() - { - $valid_methods = array('get', 'post', 'delete', 'put', 'options', 'head'); - $url = 'http://example.com/'; - foreach ($valid_methods as $method) { - $r = call_user_func(array('Httpful\Request', $method), $url); - $this->assertEquals('Httpful\Request', get_class($r)); - $this->assertEquals(strtoupper($method), $r->method); - } - } + const SAMPLE_XML_HEADER = + "HTTP/1.1 200 OK +Content-Type: application/xml +Connection: keep-alive +Transfer-Encoding: chunked\r\n"; - function testDefaults() - { - // Our current defaults are as follows - $r = Request::init(); - $this->assertEquals(Http::GET, $r->method); - $this->assertFalse($r->strict_ssl); - } + const SAMPLE_XML_RESPONSE = '2a stringTRUE'; - function testShortMime() - { - // Valid short ones - $this->assertEquals(Mime::JSON, Mime::getFullMime('json')); - $this->assertEquals(Mime::XML, Mime::getFullMime('xml')); - $this->assertEquals(Mime::HTML, Mime::getFullMime('html')); - $this->assertEquals(Mime::CSV, Mime::getFullMime('csv')); - $this->assertEquals(Mime::UPLOAD, Mime::getFullMime('upload')); + const TEST_SERVER = TEST_SERVER; - // Valid long ones - $this->assertEquals(Mime::JSON, Mime::getFullMime(Mime::JSON)); - $this->assertEquals(Mime::XML, Mime::getFullMime(Mime::XML)); - $this->assertEquals(Mime::HTML, Mime::getFullMime(Mime::HTML)); - $this->assertEquals(Mime::CSV, Mime::getFullMime(Mime::CSV)); - $this->assertEquals(Mime::UPLOAD, Mime::getFullMime(Mime::UPLOAD)); + const TEST_URL = 'http://127.0.0.1:8008'; - // No false positives - $this->assertNotEquals(Mime::XML, Mime::getFullMime(Mime::HTML)); - $this->assertNotEquals(Mime::JSON, Mime::getFullMime(Mime::XML)); - $this->assertNotEquals(Mime::HTML, Mime::getFullMime(Mime::JSON)); - $this->assertNotEquals(Mime::XML, Mime::getFullMime(Mime::CSV)); - } + const TEST_URL_400 = 'http://127.0.0.1:8008/400'; - function testSettingStrictSsl() - { - $r = Request::init() - ->withStrictSsl(); + const TIMEOUT_URI = 'https://suckup.de/timeout.php'; - $this->assertTrue($r->strict_ssl); + public function testAccept() + { + $r = Request::get('http://example.com/') + ->withExpectedType(Mime::JSON); - $r = Request::init() - ->withoutStrictSsl(); + static::assertSame(Mime::JSON, $r->getExpectedType()); + $r->_curlPrep(); - $this->assertFalse($r->strict_ssl); + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('application/json', $r->getRawHeaders()); + } else { + static::assertContains('application/json', $r->getRawHeaders()); + } } - function testSendsAndExpectsType() + public function testAttach() { - $r = Request::init() - ->sendsAndExpectsType(Mime::JSON); - $this->assertEquals(Mime::JSON, $r->expected_type); - $this->assertEquals(Mime::JSON, $r->content_type); + $req = new Request(); + $testsPath = \realpath(__DIR__ . \DIRECTORY_SEPARATOR . '..'); + $filename = $testsPath . \DIRECTORY_SEPARATOR . '/static/test_image.jpg'; + $req = $req->withAttachment(['index' => $filename]); + $payload = $req->getPayload()['index']; - $r = Request::init() - ->sendsAndExpectsType('html'); - $this->assertEquals(Mime::HTML, $r->expected_type); - $this->assertEquals(Mime::HTML, $r->content_type); + static::assertInstanceOf(\CURLFile::class, $payload); + static::assertSame($req->getContentType(), Mime::UPLOAD); + static::assertSame($req->getSerializePayloadMethod(), Request::SERIALIZE_PAYLOAD_NEVER); + } - $r = Request::init() - ->sendsAndExpectsType('form'); - $this->assertEquals(Mime::FORM, $r->expected_type); - $this->assertEquals(Mime::FORM, $r->content_type); + public function testAuthSetup() + { + $username = 'nathan'; + $password = 'opensesame'; - $r = Request::init() - ->sendsAndExpectsType('application/x-www-form-urlencoded'); - $this->assertEquals(Mime::FORM, $r->expected_type); - $this->assertEquals(Mime::FORM, $r->content_type); + $r = Request::get('http://example.com/') + ->withBasicAuth($username, $password); - $r = Request::init() - ->sendsAndExpectsType(Mime::CSV); - $this->assertEquals(Mime::CSV, $r->expected_type); - $this->assertEquals(Mime::CSV, $r->content_type); + static::assertTrue($r->hasBasicAuth()); } - function testIni() + public function testBeforeSend() { - // Test setting defaults/templates - - // Create the template - $template = Request::init() - ->method(Http::POST) - ->withStrictSsl() - ->expectsType(Mime::HTML) - ->sendsType(Mime::FORM); - - Request::ini($template); + $invoked = false; + $changed = false; + $self = $this; - $r = Request::init(); + try { + Request::get('malformed://url') + ->beforeSend( + static function (Request $request) use (&$invoked, $self) { + $self::assertSame('malformed://url', $request->getUriString()); + $request->withUriFromString('malformed2://url', false); + $invoked = true; + } + ) + ->withErrorHandler( + static function ($error) { /* Be silent */ + } + ) + ->send(); + } catch (NetworkErrorException $e) { + static::assertNotSame(\strpos($e->getMessage(), 'malformed2'), false, \print_r($e->getMessage(), true)); + $changed = true; + } - $this->assertTrue($r->strict_ssl); - $this->assertEquals(Http::POST, $r->method); - $this->assertEquals(Mime::HTML, $r->expected_type); - $this->assertEquals(Mime::FORM, $r->content_type); + static::assertTrue($invoked); + static::assertTrue($changed); + } - // Test the default accessor as well - $this->assertTrue(Request::d('strict_ssl')); - $this->assertEquals(Http::POST, Request::d('method')); - $this->assertEquals(Mime::HTML, Request::d('expected_type')); - $this->assertEquals(Mime::FORM, Request::d('content_type')); + public function testCsvResponseParse() + { + $req = new Request(Http::GET, Mime::CSV); + $response = new Response(self::SAMPLE_CSV_RESPONSE, self::SAMPLE_CSV_HEADER, $req); - Request::resetIni(); + static::assertSame('Key1', $response->getRawBody()[0][0]); + static::assertSame('Value1', $response->getRawBody()[1][0]); + static::assertTrue(is_string($response->getRawBody()[2][0])); + static::assertSame('40.0', $response->getRawBody()[2][0]); } - function testAccept() + public function testCustomAccept() { + $accept = 'application/api-1.0+json'; $r = Request::get('http://example.com/') - ->expectsType(Mime::JSON); + ->withHeader('Accept', $accept); - $this->assertEquals(Mime::JSON, $r->expected_type); $r->_curlPrep(); - $this->assertContains('application/json', $r->raw_headers); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString($accept, $r->getRawHeaders()); + } else { + static::assertContains($accept, $r->getRawHeaders()); + } + static::assertSame($accept, $r->getHeaders()['Accept'][0]); } - function testCustomAccept() + public function testCustomHeaders() { $accept = 'application/api-1.0+json'; $r = Request::get('http://example.com/') - ->addHeader('Accept', $accept); + ->withHeaders( + [ + 'Accept' => $accept, + 'Foo' => 'Bar', + ] + ); $r->_curlPrep(); - $this->assertContains($accept, $r->raw_headers); - $this->assertEquals($accept, $r->headers['Accept']); + + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString($accept, $r->getRawHeaders()); + } else { + static::assertContains($accept, $r->getRawHeaders()); + } + + static::assertSame($accept, $r->getHeaders()['Accept'][0]); + static::assertSame('Bar', $r->getHeaders()['Foo'][0]); } - function testUserAgent() + public function testCustomHeader() { $r = Request::get('http://example.com/') - ->withUserAgent('ACME/1.2.3'); + ->withHeader('XTrivial', 'FooBar') + ->withPort(80); - $this->assertArrayHasKey('User-Agent', $r->headers); $r->_curlPrep(); - $this->assertContains('User-Agent: ACME/1.2.3', $r->raw_headers); - $this->assertNotContains('User-Agent: HttpFul/1.0', $r->raw_headers); - $r = Request::get('http://example.com/') - ->withUserAgent(''); + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('', $r->getRawHeaders()); + } else { + static::assertContains('', $r->getRawHeaders()); + } - $this->assertArrayHasKey('User-Agent', $r->headers); - $r->_curlPrep(); - $this->assertContains('User-Agent:', $r->raw_headers); - $this->assertNotContains('User-Agent: HttpFul/1.0', $r->raw_headers); + static::assertSame('FooBar', $r->getHeaders()['XTrivial'][0]); } - function testAuthSetup() + public function testCustomMimeRegistering() { - $username = 'nathan'; - $password = 'opensesame'; + // Register new mime type handler for "application/vnd.nategood.message+xml" + Setup::registerMimeHandler(self::SAMPLE_VENDOR_TYPE, new DemoDefaultMimeHandler()); - $r = Request::get('http://example.com/') - ->authenticateWith($username, $password); + static::assertTrue(Setup::hasParserRegistered(self::SAMPLE_VENDOR_TYPE)); - $this->assertEquals($username, $r->username); - $this->assertEquals($password, $r->password); - $this->assertTrue($r->hasBasicAuth()); + $request = new Request(Http::GET, self::SAMPLE_VENDOR_TYPE); + $response = new Response('Nathan', self::SAMPLE_VENDOR_HEADER, $request); + + static::assertSame(self::SAMPLE_VENDOR_TYPE, $response->getContentType()); + static::assertSame('custom parse', $response->getRawBody()); } - function testDigestAuthSetup() + public function testDefaults() + { + // Our current defaults are as follows + $r = new Request(); + static::assertSame(Http::GET, $r->getHttpMethod()); + static::assertFalse($r->isStrictSSL()); + } + + public function testDetectContentType() + { + $req = new Request(); + $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); + static::assertSame('application/json', $response->getHeaders()['Content-Type'][0]); + } + + public function testDigestAuthSetup() { $username = 'nathan'; $password = 'opensesame'; $r = Request::get('http://example.com/') - ->authenticateWithDigest($username, $password); + ->withDigestAuth($username, $password); - $this->assertEquals($username, $r->username); - $this->assertEquals($password, $r->password); - $this->assertTrue($r->hasDigestAuth()); + static::assertTrue($r->hasDigestAuth()); } - function testJsonResponseParse() + public function testEmptyResponseParse() { - $req = Request::init()->sendsAndExpects(Mime::JSON); - $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); + $req = (new Request())->withMimeType(Mime::JSON); + $response = new Response('', self::SAMPLE_JSON_HEADER, $req); + static::assertNull($response->getRawBody()); - $this->assertEquals("value", $response->body->key); - $this->assertEquals("value", $response->body->object->key); - $this->assertInternalType('array', $response->body->array); - $this->assertEquals(1, $response->body->array[0]); + $reqXml = (new Request())->withMimeType(Mime::XML); + $responseXml = new Response('', self::SAMPLE_XML_HEADER, $reqXml); + static::assertNull($responseXml->getRawBody()); } - function testXMLResponseParse() + public function testHTMLResponseParse() { - $req = Request::init()->sendsAndExpects(Mime::XML); - $response = new Response(self::SAMPLE_XML_RESPONSE, self::SAMPLE_XML_HEADER, $req); - $sxe = $response->body; - $this->assertEquals("object", gettype($sxe)); - $this->assertEquals("SimpleXMLElement", get_class($sxe)); - $bools = $sxe->xpath('/stdClass/boolProp'); - list( , $bool ) = each($bools); - $this->assertEquals("TRUE", (string) $bool); - $ints = $sxe->xpath('/stdClass/arrayProp/array/k1/myClass/intProp'); - list( , $int ) = each($ints); - $this->assertEquals("2", (string) $int); - $strings = $sxe->xpath('/stdClass/stringProp'); - list( , $string ) = each($strings); - $this->assertEquals("a string", (string) $string); + $req = (new Request())->withMimeType(Mime::HTML); + $response = new Response(self::SAMPLE_HTML_RESPONSE, self::SAMPLE_HTML_HEADER, $req); + /** @var \voku\helper\HtmlDomParser $dom */ + $dom = $response->getRawBody(); + static::assertSame('object', \gettype($dom)); + static::assertSame(\voku\helper\HtmlDomParser::class, \get_class($dom)); + $bools = $dom->find('boolProp'); + foreach ($bools as $bool) { + static::assertSame('TRUE', $bool->innerhtml); + } + $ints = $dom->find('intProp'); + foreach ($ints as $int) { + static::assertSame('2', $int->innerhtml); + } + $strings = $dom->find('stringProp'); + foreach ($strings as $string) { + static::assertSame('a string', (string) $string); + } } - function testCsvResponseParse() + public function testHasErrors() { - $req = Request::init()->sendsAndExpects(Mime::CSV); - $response = new Response(self::SAMPLE_CSV_RESPONSE, self::SAMPLE_CSV_HEADER, $req); + $req = new Request(Http::GET, Mime::JSON); + $response = new Response('', "HTTP/1.1 100 Continue\r\n", $req); + static::assertFalse($response->hasErrors()); + $response = new Response('', "HTTP/1.1 200 OK\r\n", $req); + static::assertFalse($response->hasErrors()); + $response = new Response('', "HTTP/1.1 300 Multiple Choices\r\n", $req); + static::assertFalse($response->hasErrors()); + $response = new Response('', "HTTP/1.1 400 Bad Request\r\n", $req); + static::assertTrue($response->hasErrors()); + $response = new Response('', "HTTP/1.1 500 Internal Server Error\r\n", $req); + static::assertTrue($response->hasErrors()); + } + + public function testHasProxyWithEnvironmentProxy() + { + \putenv('http_proxy=http://127.0.0.1:300/'); + $r = Request::get('some_other_url'); + static::assertTrue($r->hasProxy()); - $this->assertEquals("Key1", $response->body[0][0]); - $this->assertEquals("Value1", $response->body[1][0]); - $this->assertInternalType('string', $response->body[2][0]); - $this->assertEquals("40.0", $response->body[2][0]); + // reset + \putenv('http_proxy='); } - function testParsingContentTypeCharset() + public function testHasProxyWithProxy() { - $req = Request::init()->sendsAndExpects(Mime::JSON); - // $response = new Response(SAMPLE_JSON_RESPONSE, "", $req); - // // Check default content type of iso-8859-1 - $response = new Response(self::SAMPLE_JSON_RESPONSE, "HTTP/1.1 200 OK -Content-Type: text/plain; charset=utf-8\r\n", $req); - $this->assertInstanceOf('Httpful\Response\Headers', $response->headers); - $this->assertEquals($response->headers['Content-Type'], 'text/plain; charset=utf-8'); - $this->assertEquals($response->content_type, 'text/plain'); - $this->assertEquals($response->charset, 'utf-8'); + $r = Request::get('some_other_url'); + $r = $r->withProxy('proxy.com'); + static::assertTrue($r->hasProxy()); } - function testParsingContentTypeUpload() + public function testHasProxyWithoutProxy() { - $req = Request::init(); + $r = Request::get('someUrl'); + static::assertFalse($r->hasProxy()); + } - $req->sendsType(Mime::UPLOAD); - // $response = new Response(SAMPLE_JSON_RESPONSE, "", $req); - // // Check default content type of iso-8859-1 - $this->assertEquals($req->content_type, 'multipart/form-data'); + public function testHtmlSerializing() + { + $body = self::SAMPLE_HTML_RESPONSE; + $request = Request::post(self::TEST_URL, $body)->withMimeType(Mime::HTML)->_curlPrep(); + static::assertSame($body, $request->getSerializedPayload()); } - function testAttach() { - $req = Request::init(); - $testsPath = realpath(dirname(__FILE__) . DIRECTORY_SEPARATOR . '..'); - $filename = $testsPath . DIRECTORY_SEPARATOR . 'test_image.jpg'; - $req->attach(array('index' => $filename)); - $payload = $req->payload['index']; - // PHP 5.5 + will take advantage of CURLFile while previous - // versions just use the string syntax - if (is_string($payload)) { - $this->assertEquals($payload, '@' . $filename . ';type=image/jpeg'); - } else { - $this->assertInstanceOf('CURLFile', $payload); - } + public function testUseTemplate() + { + // Test setting defaults/templates - $this->assertEquals($req->content_type, Mime::UPLOAD); - $this->assertEquals($req->serialize_payload_method, Request::SERIALIZE_PAYLOAD_NEVER); - } + // Create the template + $template = (new Request()) + ->withMethod(Http::GET) + ->enableStrictSSL() + ->withExpectedType(Mime::PLAIN) + ->withContentType(Mime::PLAIN); - function testIsUpload() { - $req = Request::init(); + $r = new Request(null, null, $template); - $req->sendsType(Mime::UPLOAD); + static::assertTrue($r->isStrictSSL()); + static::assertSame(Http::GET, $r->getHttpMethod()); + static::assertSame(Mime::PLAIN, $r->getExpectedType()); + static::assertSame(Mime::PLAIN, $r->getContentType()); + } - $this->assertTrue($req->isUpload()); + /** + * init + */ + public function testInit() + { + $r = new Request(); + // Did we get a 'Request' object? + static::assertSame(Request::class, \get_class($r)); } - function testEmptyResponseParse() + public function testIsUpload() { - $req = Request::init()->sendsAndExpects(Mime::JSON); - $response = new Response("", self::SAMPLE_JSON_HEADER, $req); - $this->assertEquals(null, $response->body); + $req = (new Request())->withContentType(Mime::UPLOAD); - $reqXml = Request::init()->sendsAndExpects(Mime::XML); - $responseXml = new Response("", self::SAMPLE_XML_HEADER, $reqXml); - $this->assertEquals(null, $responseXml->body); + static::assertTrue($req->isUpload()); } - function testNoAutoParse() + public function testJsonResponseParse() { - $req = Request::init()->sendsAndExpects(Mime::JSON)->withoutAutoParsing(); + $req = (new Request())->withMimeType(Mime::JSON); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); - $this->assertInternalType('string', $response->body); - $req = Request::init()->sendsAndExpects(Mime::JSON)->withAutoParsing(); - $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); - $this->assertInternalType('object', $response->body); + + static::assertSame('value', $response->getRawBody()['key']); + static::assertSame('value', $response->getRawBody()['object']['key']); + static::assertTrue(is_array($response->getRawBody()['array'])); + static::assertSame(1, $response->getRawBody()['array'][0]); } - function testParseHeaders() + public function testMethods() { - $req = Request::init()->sendsAndExpects(Mime::JSON); - $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); - $this->assertEquals('application/json', $response->headers['Content-Type']); + $valid_methods = ['get', 'post', 'delete', 'put', 'options', 'head']; + $url = 'http://example.com/'; + foreach ($valid_methods as $method) { + $r = \call_user_func([Request::class, $method], $url); + static::assertSame(Request::class, \get_class($r)); + static::assertSame(\strtoupper($method), $r->getHttpMethod()); + } + } + + public function testMissingBodyContentType() + { + $body = 'A string'; + $request = Request::post(self::TEST_URL, $body)->_curlPrep(); + static::assertSame($body, $request->getSerializedPayload()); } - function testRawHeaders() + public function testMissingContentType() { - $req = Request::init()->sendsAndExpects(Mime::JSON); + // Parent type + $request = (new Request())->withMimeType(Mime::XML); + $response = new Response( + 'Nathan', + "HTTP/1.1 200 OK +Connection: keep-alive +Transfer-Encoding: chunked\r\n", + $request + ); + + static::assertSame('', $response->getContentType()); + } + + public function testNoAutoParse() + { + $req = (new Request())->withMimeType(Mime::JSON)->disableAutoParsing(); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); - $this->assertContains('Content-Type: application/json', $response->raw_headers); + static::assertTrue(is_string((string) $response->getBody())); + $req = (new Request())->withMimeType(Mime::JSON)->enableAutoParsing(); + $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); + static::assertTrue(is_array($response->getRawBody())); } - function testHasErrors() + public function testOverrideXmlHandler() { - $req = Request::init()->sendsAndExpects(Mime::JSON); - $response = new Response('', "HTTP/1.1 100 Continue\r\n", $req); - $this->assertFalse($response->hasErrors()); - $response = new Response('', "HTTP/1.1 200 OK\r\n", $req); - $this->assertFalse($response->hasErrors()); - $response = new Response('', "HTTP/1.1 300 Multiple Choices\r\n", $req); - $this->assertFalse($response->hasErrors()); - $response = new Response('', "HTTP/1.1 400 Bad Request\r\n", $req); - $this->assertTrue($response->hasErrors()); - $response = new Response('', "HTTP/1.1 500 Internal Server Error\r\n", $req); - $this->assertTrue($response->hasErrors()); + // Lazy test... + $prev = Setup::setupGlobalMimeType(Mime::XML); + static::assertInstanceOf(DefaultMimeHandler::class, $prev); + $conf = ['namespace' => 'http://example.com']; + Setup::registerMimeHandler(Mime::XML, new XmlMimeHandler($conf)); + $new = Setup::setupGlobalMimeType(Mime::XML); + static::assertNotSame($prev, $new); + Setup::reset(); } - function testWhenError() { - $caught = false; + public function testParams() + { + $r = Request::get('http://google.com'); + $r->_curlPrep(); + $r->_uriPrep(); + static::assertSame('http://google.com', $r->getUriString()); - try { - Request::get('malformed:url') - ->whenError(function($error) use(&$caught) { - $caught = true; - }) - ->timeoutIn(0.1) - ->send(); - } catch (\Httpful\Exception\ConnectionErrorException $e) {} + $r = Request::get('http://google.com?q=query'); + $r->_curlPrep(); + $r->_uriPrep(); + static::assertSame('http://google.com?q=query', $r->getUriString()); + + $r = Request::get('http://google.com')->withParam('a', 'b'); + $r->_curlPrep(); + $r->_uriPrep(); + static::assertSame('http://google.com?a=b', $r->getUriString()); + + $r = Request::get('http://google.com'); + $r->_curlPrep(); + $r->_uriPrep(); + static::assertSame('http://google.com', $r->getUriString()); + + $r = Request::get('http://google.com?a=b'); + $r = $r->withParam('c', 'd'); + $r->_curlPrep(); + $r->_uriPrep(); + static::assertSame('http://google.com?a=b&c=d', $r->getUriString()); + + $r = Request::get('http://google.com?a=b'); + $r = $r->withParam('', 'e'); + $r->_curlPrep(); + $r->_uriPrep(); + static::assertSame('http://google.com?a=b', $r->getUriString()); + + $r = Request::get('http://google.com?a=b'); + $r = $r->withParam('e', ''); + $r->_curlPrep(); + $r->_uriPrep(); + static::assertSame('http://google.com?a=b&e=', $r->getUriString()); - $this->assertTrue($caught); + $r = Request::get('http://google.com?a=b'); + $r = $r->withParam('0', '-'); + $r->_curlPrep(); + $r->_uriPrep(); + static::assertSame('http://google.com?a=b&0=-', $r->getUriString()); } - function testBeforeSend() { - $invoked = false; - $changed = false; - $self = $this; + public function testParentType() + { + // Parent type + $request = (new Request())->withMimeType(Mime::XML); + $response = new Response('Nathan', self::SAMPLE_VENDOR_HEADER, $request); - try { - Request::get('malformed://url') - ->beforeSend(function($request) use(&$invoked,$self) { - $self->assertEquals('malformed://url', $request->uri); - $self->assertEquals('A payload', $request->serialized_payload); - $request->uri('malformed2://url'); - $invoked = true; - }) - ->whenError(function($error) { /* Be silent */ }) - ->body('A payload') - ->send(); - } catch (\Httpful\Exception\ConnectionErrorException $e) { - $this->assertTrue(strpos($e->getMessage(), 'malformed2') !== false); - $changed = true; - } + static::assertSame('application/xml', $response->getParentType()); + static::assertSame(self::SAMPLE_VENDOR_TYPE, $response->getContentType()); + static::assertTrue($response->isMimeVendorSpecific()); - $this->assertTrue($invoked); - $this->assertTrue($changed); + // Make sure we still parsed as if it were plain old XML + static::assertSame('Nathan', (string) $response->getRawBody()->name); } - function test_parseCode() + public function testParseCode() { - $req = Request::init()->sendsAndExpects(Mime::JSON); + $req = (new Request())->withMimeType(Mime::JSON); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); - $code = $response->_parseCode("HTTP/1.1 406 Not Acceptable\r\n"); - $this->assertEquals(406, $code); + $code = $response->_getResponseCodeFromHeaderString("HTTP/1.1 406 Not Acceptable\r\n"); + static::assertSame(406, $code); } - function testToString() + public function testParseHeaders() { - $req = Request::init()->sendsAndExpects(Mime::JSON); + $req = (new Request())->withMimeType(Mime::JSON); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); - $this->assertEquals(self::SAMPLE_JSON_RESPONSE, (string)$response); + static::assertSame('application/json', $response->getHeaders()['Content-Type'][0]); } - function test_parseHeaders() + public function testParseHeaders2() { - $parse_headers = Response\Headers::fromString(self::SAMPLE_JSON_HEADER); - $this->assertCount(3, $parse_headers); - $this->assertEquals('application/json', $parse_headers['Content-Type']); - $this->assertTrue(isset($parse_headers['Connection'])); + $parse_headers = Headers::fromString(self::SAMPLE_JSON_HEADER); + static::assertCount(3, $parse_headers); + static::assertSame('application/json', $parse_headers['Content-Type'][0]); + static::assertTrue(isset($parse_headers['Connection'])); } - function testMultiHeaders() + public function testParseJSON() { - $req = Request::init(); - $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_MULTI_HEADER, $req); - $parse_headers = $response->_parseHeaders(self::SAMPLE_MULTI_HEADER); - $this->assertEquals('Value1,Value2', $parse_headers['X-My-Header']); + $handler = new JsonMimeHandler(); + + $bodies = [ + 'foo', + [], + ['foo', 'bar'], + null, + ]; + foreach ($bodies as $body) { + static::assertSame($body, $handler->parse((string) \json_encode($body))); + } + + try { + /** @noinspection OnlyWritesOnParameterInspection */ + /** @noinspection PhpUnusedLocalVariableInspection */ + $result = $handler->parse('invalid{json'); + } catch (\Httpful\Exception\JsonParseException $e) { + static::assertSame('Unable to parse response as JSON: ' . \json_last_error_msg() . ' | "invalid{json"', $e->getMessage()); + + return; + } + + static::fail('Expected an exception to be thrown due to invalid json'); } - function testDetectContentType() + public function testParsingContentTypeCharset() { - $req = Request::init(); - $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); - $this->assertEquals('application/json', $response->headers['Content-Type']); + $req = (new Request())->withMimeType(Mime::JSON); + $response = new Response( + self::SAMPLE_JSON_RESPONSE, + "HTTP/1.1 200 OK +Content-Type: text/plain; charset=utf-8\r\n", + $req + ); + static::assertSame($response->getHeaders()['Content-Type'][0], 'text/plain; charset=utf-8'); + static::assertSame($response->getContentType(), 'text/plain'); + static::assertSame($response->getCharset(), 'utf-8'); } - function testMissingBodyContentType() + public function testParsingContentTypeUpload() { - $body = 'A string'; - $request = Request::post(HttpfulTest::TEST_URL, $body)->_curlPrep(); - $this->assertEquals($body, $request->serialized_payload); + $req = (new Request())->withContentType(Mime::UPLOAD); + static::assertSame($req->getContentType(), 'multipart/form-data'); } - function testParentType() + public function testRawHeaders() { - // Parent type - $request = Request::init()->sendsAndExpects(Mime::XML); - $response = new Response('Nathan', self::SAMPLE_VENDOR_HEADER, $request); - - $this->assertEquals("application/xml", $response->parent_type); - $this->assertEquals(self::SAMPLE_VENDOR_TYPE, $response->content_type); - $this->assertTrue($response->is_mime_vendor_specific); + $req = (new Request())->withMimeType(Mime::JSON); + $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); - // Make sure we still parsed as if it were plain old XML - $this->assertEquals("Nathan", $response->body->name->__toString()); + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('Content-Type: application/json', $response->getRawHeaders()); + } else { + static::assertContains('Content-Type: application/json', $response->getRawHeaders()); + } } - function testMissingContentType() + public function testmimeType() { - // Parent type - $request = Request::init()->sendsAndExpects(Mime::XML); - $response = new Response('Nathan', -"HTTP/1.1 200 OK -Connection: keep-alive -Transfer-Encoding: chunked\r\n", $request); + $r = (new Request()) + ->withMimeType(Mime::JSON); + static::assertSame(Mime::JSON, $r->getExpectedType()); + static::assertSame(Mime::JSON, $r->getContentType()); + + $r = (new Request()) + ->withMimeType('html'); + static::assertSame(Mime::HTML, $r->getExpectedType()); + static::assertSame(Mime::HTML, $r->getContentType()); + + $r = (new Request()) + ->withMimeType('form'); + static::assertSame(Mime::FORM, $r->getExpectedType()); + static::assertSame(Mime::FORM, $r->getContentType()); - $this->assertEquals("", $response->content_type); + $r = (new Request()) + ->withMimeType('application/x-www-form-urlencoded'); + static::assertSame(Mime::FORM, $r->getExpectedType()); + static::assertSame(Mime::FORM, $r->getContentType()); + + $r = (new Request()) + ->withMimeType(Mime::CSV); + static::assertSame(Mime::CSV, $r->getExpectedType()); + static::assertSame(Mime::CSV, $r->getContentType()); } - function testCustomMimeRegistering() + public function testSettingStrictSsl() { - // Register new mime type handler for "application/vnd.nategood.message+xml" - Httpful::register(self::SAMPLE_VENDOR_TYPE, new DemoMimeHandler()); + $r = (new Request()) + ->enableStrictSSL(); - $this->assertTrue(Httpful::hasParserRegistered(self::SAMPLE_VENDOR_TYPE)); + static::assertTrue($r->isStrictSSL()); - $request = Request::init(); - $response = new Response('Nathan', self::SAMPLE_VENDOR_HEADER, $request); + $r = (new Request()) + ->disableStrictSSL(); - $this->assertEquals(self::SAMPLE_VENDOR_TYPE, $response->content_type); - $this->assertEquals('custom parse', $response->body); + static::assertFalse($r->isStrictSSL()); } - public function testShorthandMimeDefinition() + public function testShortMime() { - $r = Request::init()->expects('json'); - $this->assertEquals(Mime::JSON, $r->expected_type); + // Valid short ones + static::assertSame(Mime::JSON, Mime::getFullMime('json')); + static::assertSame(Mime::XML, Mime::getFullMime('xml')); + static::assertSame(Mime::HTML, Mime::getFullMime('html')); + static::assertSame(Mime::CSV, Mime::getFullMime('csv')); + static::assertSame(Mime::UPLOAD, Mime::getFullMime('upload')); + + // Valid long ones + static::assertSame(Mime::JSON, Mime::getFullMime(Mime::JSON)); + static::assertSame(Mime::XML, Mime::getFullMime(Mime::XML)); + static::assertSame(Mime::HTML, Mime::getFullMime(Mime::HTML)); + static::assertSame(Mime::CSV, Mime::getFullMime(Mime::CSV)); + static::assertSame(Mime::UPLOAD, Mime::getFullMime(Mime::UPLOAD)); - $r = Request::init()->expectsJson(); - $this->assertEquals(Mime::JSON, $r->expected_type); + // No false positives + static::assertNotSame(Mime::XML, Mime::getFullMime(Mime::HTML)); + static::assertNotSame(Mime::JSON, Mime::getFullMime(Mime::XML)); + static::assertNotSame(Mime::HTML, Mime::getFullMime(Mime::JSON)); + static::assertNotSame(Mime::XML, Mime::getFullMime(Mime::CSV)); } - public function testOverrideXmlHandler() + public function testShorthandMimeDefinition() { - // Lazy test... - $prev = \Httpful\Httpful::get(\Httpful\Mime::XML); - $this->assertEquals($prev, new \Httpful\Handlers\XmlHandler()); - $conf = array('namespace' => 'http://example.com'); - \Httpful\Httpful::register(\Httpful\Mime::XML, new \Httpful\Handlers\XmlHandler($conf)); - $new = \Httpful\Httpful::get(\Httpful\Mime::XML); - $this->assertNotEquals($prev, $new); + $r = (new Request())->withExpectedType('json'); + static::assertSame(Mime::JSON, $r->getExpectedType()); + + $r = (new Request())->expectsJson(); + static::assertSame(Mime::JSON, $r->getExpectedType()); } - public function testHasProxyWithoutProxy() + public function testTimeout() { - $r = Request::get('someUrl'); - $this->assertFalse($r->hasProxy()); + try { + (new Request()) + ->followRedirects(true) + ->withUriFromString(self::TIMEOUT_URI) + ->withTimeout(0.1) + ->send(); + } catch (NetworkErrorException $e) { + // static::assertTrue(is_resource($e->getCurlObject()->getCurl()), 'is_resource'); // php 8 + curl === false ? + static::assertTrue($e->wasTimeout(), 'wasTimeout'); + + return; + } + + static::assertFalse(true); } - public function testHasProxyWithProxy() + public function testToString() { - $r = Request::get('some_other_url'); - $r->useProxy('proxy.com'); - $this->assertTrue($r->hasProxy()); + $req = (new Request())->withMimeType(Mime::JSON); + $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); + static::assertSame(self::SAMPLE_JSON_RESPONSE, (string) $response); } - public function testHasProxyWithEnvironmentProxy() + public function testUserAgentGet() { - putenv('http_proxy=http://127.0.0.1:300/'); - $r = Request::get('some_other_url'); - $this->assertTrue($r->hasProxy()); - } + $r = Request::get('http://example.com/') + ->withUserAgent('ACME/1.2.3'); + static::assertArrayHasKey('User-Agent', $r->getHeaders()); + $r->_curlPrep(); - public function testParseJSON() - { - $handler = new JsonHandler(); + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('User-Agent: ACME/1.2.3', $r->getRawHeaders()); + static::assertStringNotContainsString('User-Agent: HttpFul/1.0', $r->getRawHeaders()); + } else { + static::assertContains('User-Agent: ACME/1.2.3', $r->getRawHeaders()); + static::assertNotContains('User-Agent: HttpFul/1.0', $r->getRawHeaders()); + } - $bodies = array( - 'foo', - array(), - array('foo', 'bar'), - null - ); - foreach ($bodies as $body) { - $this->assertEquals($body, $handler->parse(json_encode($body))); + $r = Request::get('http://example.com/') + ->withUserAgent(''); + + static::assertArrayHasKey('User-Agent', $r->getHeaders()); + $r->_curlPrep(); + if (\method_exists(__CLASS__, 'assertStringContainsString')) { + static::assertStringContainsString('User-Agent:', $r->getRawHeaders()); + static::assertStringNotContainsString('User-Agent: HttpFul/1.0', $r->getRawHeaders()); + } else { + static::assertContains('User-Agent:', $r->getRawHeaders()); + static::assertNotContains('User-Agent: HttpFul/1.0', $r->getRawHeaders()); } + } + + public function testWhenError() + { + $caught = false; try { - $result = $handler->parse('invalid{json'); - } catch(\Exception $e) { - $this->assertEquals('Unable to parse response as JSON', $e->getMessage()); - return; + /** @noinspection PhpUnusedParameterInspection */ + Request::get('malformed:url') + ->withErrorHandler( + static function ($error) use (&$caught) { + $caught = true; + } + ) + ->withTimeout(0.1) + ->send(); + } catch (NetworkErrorException $e) { + } + + static::assertTrue($caught); + } + + public function testXMLResponseParse() + { + $req = (new Request())->withMimeType(Mime::XML); + $response = new Response(self::SAMPLE_XML_RESPONSE, self::SAMPLE_XML_HEADER, $req); + $sxe = $response->getRawBody(); + static::assertSame('object', \gettype($sxe)); + static::assertSame(\SimpleXMLElement::class, \get_class($sxe)); + $bools = $sxe->xpath('/stdClass/boolProp'); + foreach ($bools as $bool) { + static::assertSame('TRUE', (string) $bool); } - $this->fail('Expected an exception to be thrown due to invalid json'); - } - - // /** - // * Skeleton for testing against the 5.4 baked in server - // */ - // public function testLocalServer() - // { - // if (!defined('WITHOUT_SERVER') || (defined('WITHOUT_SERVER') && !WITHOUT_SERVER)) { - // // PHP test server seems to always set content type to application/octet-stream - // // so force parsing as JSON here - // Httpful::register('application/octet-stream', new \Httpful\Handlers\JsonHandler()); - // $response = Request::get(TEST_SERVER . '/test.json') - // ->sendsAndExpects(MIME::JSON); - // $response->send(); - // $this->assertTrue(...); - // } - // } + $ints = $sxe->xpath('/stdClass/arrayProp/array/k1/myClass/intProp'); + foreach ($ints as $int) { + static::assertSame('2', (string) $int); + } + $strings = $sxe->xpath('/stdClass/stringProp'); + foreach ($strings as $string) { + static::assertSame('a string', (string) $string); + } + } + + public function testIssue7() + { + $factory = new \Httpful\Factory(); + $request = (new Request('GET'))->withBody($factory->createStream('abc')); + + static::assertSame('abc', (string) $request->getBody()); + } } -class DemoMimeHandler extends \Httpful\Handlers\MimeHandlerAdapter +/** @noinspection PhpMultipleClassesDeclarationsInOneFile */ + +/** + * Class DemoMimeHandler + */ +class DemoDefaultMimeHandler extends DefaultMimeHandler { + /** @noinspection PhpMissingParentCallCommonInspection */ + + /** + * @param string $body + * + * @return string + */ public function parse($body) { return 'custom parse'; } } - diff --git a/tests/Httpful/RequestTest.php b/tests/Httpful/RequestTest.php new file mode 100644 index 0000000..ae7cb4e --- /dev/null +++ b/tests/Httpful/RequestTest.php @@ -0,0 +1,284 @@ +withUriFromString('http://foo.com:8124/bar'); + static::assertSame('foo.com:8124', $r->getHeaderLine('host')); + } + + public function testAddsPortToHeaderAndReplacePreviousPort() + { + $r = new Request('GET', 'http://foo.com:8124/bar'); + $r = $r->withUri(new Uri('http://foo.com:8125/bar')); + static::assertSame('foo.com:8125', $r->getHeaderLine('host')); + } + + public function testAggregatesHeaders() + { + $r = (new Request('GET'))->withHeaders(['ZOO' => 'zoobar', 'zoo' => ['foobar', 'zoobar']]); + static::assertSame(['zoo' => ['zoobar', 'foobar', 'zoobar']], $r->getHeaders()); + static::assertSame('zoobar, foobar, zoobar', $r->getHeaderLine('zoo')); + } + + public function testBuildsRequestTarget() + { + $r1 = (new Request('GET'))->withUriFromString('http://foo.com/baz?bar=bam'); + static::assertSame('/baz?bar=bam', $r1->getRequestTarget()); + } + + public function testBuildsRequestTargetWithFalseyQuery() + { + $r1 = (new Request('GET'))->withUriFromString('http://foo.com/baz?0'); + static::assertSame('/baz?0', $r1->getRequestTarget()); + } + + public function testCanConstructWithBody() + { + $r = (new Request('GET'))->withUriFromString('/')->withBodyFromString('baz'); + static::assertInstanceOf(StreamInterface::class, $r->getBody()); + static::assertSame('a:1:{i:0;s:3:"baz";}', (string) $r->getBody()); + } + + public function testCanGetHeaderAsCsv() + { + $r = (new Request('GET'))->withUriFromString('http://foo.com/baz?bar=bam')->withHeader('Foo', ['a', 'b', 'c']); + static::assertSame('a, b, c', $r->getHeaderLine('Foo')); + static::assertSame('', $r->getHeaderLine('Bar')); + } + + public function testCanHaveHeaderWithEmptyValue() + { + $r = (new Request('GET'))->withUriFromString('https://example.com/'); + $r = $r->withHeader('Foo', ''); + static::assertSame([''], $r->getHeader('Foo')); + } + + public function testCannotHaveHeaderWithEmptyName() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Header name must be an RFC 7230 compatible string'); + $r = (new Request('GET'))->withUriFromString('https://example.com/'); + $r->withHeader('', 'Bar'); + } + + public function testFalseyBody() + { + $r = (new Request('GET'))->withUriFromString('/')->withBodyFromString('0'); + static::assertInstanceOf(StreamInterface::class, $r->getBody()); + static::assertSame('a:0:{}', (string) $r->getBody()); + } + + public function testCreateRequest() + { + $request = (new \Httpful\Factory())->createRequest( + \Httpful\Http::POST, + \sprintf('/api/%d/store/', 3), + \Httpful\Mime::JSON, + \json_encode(['foo' => 'bar']) + ); + + static::assertSame(\Httpful\Http::POST, $request->getMethod()); + static::assertSame('a:1:{i:0;s:13:"{"foo":"bar"}";}', (string) $request->getBody()); + } + + public function testGetInvalidURL() + { + $this->expectException(\Httpful\Exception\NetworkErrorException::class); + $this->expectExceptionMessage('Unable to connect'); + + // Silence the default logger via whenError override + Request::get('unavailable.url')->withErrorHandler( + static function ($error) { + } + )->send(); + } + + public function testGetRequestTarget() + { + $r = (new Request('GET'))->withUriFromString('https://nyholm.tech'); + static::assertSame('/', $r->getRequestTarget()); + + $r = (new Request('GET'))->withUriFromString('https://nyholm.tech/foo?bar=baz'); + static::assertSame('/foo?bar=baz', $r->getRequestTarget()); + + $r = (new Request('GET'))->withUriFromString('https://nyholm.tech?bar=baz'); + static::assertSame('/?bar=baz', $r->getRequestTarget()); + } + + public function testHostIsAddedFirst() + { + $r = (new Request('GET'))->withUriFromString('http://foo.com/baz?bar=bam')->withHeader('Foo', 'Bar'); + static::assertSame( + [ + 'Host' => ['foo.com'], + 'Foo' => ['Bar'], + ], + $r->getHeaders() + ); + } + + public function testHostIsNotOverwrittenWhenPreservingHost() + { + $r = (new Request('GET'))->withUriFromString('http://foo.com/baz?bar=bam')->withHeader('Host', 'a.com'); + static::assertSame(['Host' => ['a.com']], $r->getHeaders()); + $r2 = $r->withUri(new Uri('http://www.foo.com/bar'), true); + static::assertSame('a.com', $r2->getHeaderLine('Host')); + } + + public function testHostIsOverwrittenWhenPreservingHost() + { + $r = (new Request('GET'))->withUriFromString('http://foo.com/baz?bar=bam')->withHeader('Host', 'a.com'); + static::assertSame(['Host' => ['a.com']], $r->getHeaders()); + $r2 = $r->withUri(new Uri('http://www.foo.com/bar'), false); + static::assertSame('www.foo.com', $r2->getHeaderLine('Host')); + } + + public function testNullBody() + { + $r = (new Request('GET'))->withUriFromString('/'); + static::assertInstanceOf(StreamInterface::class, $r->getBody()); + static::assertNotNull($r->getBody()); + } + + public function testOverridesHostWithUri() + { + $r = (new Request('GET'))->withUriFromString('http://foo.com/baz?bar=bam'); + static::assertSame(['Host' => ['foo.com']], $r->getHeaders()); + $r2 = $r->withUri(new Uri('http://www.baz.com/bar')); + static::assertSame('www.baz.com', $r2->getHeaderLine('Host')); + } + + public function testRequestTargetDefaultsToSlash() + { + $r1 = (new Request('GET'))->withUriFromString(''); + static::assertSame('/', $r1->getRequestTarget()); + + $r2 = (new Request('GET'))->withUriFromString('*'); + static::assertSame('*', $r2->getRequestTarget()); + + $r3 = (new Request('GET'))->withUriFromString('http://foo.com/bar baz/'); + static::assertSame('/bar%20baz/', $r3->getRequestTarget()); + } + + public function testRequestTargetDoesNotAllowSpaces() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid request target provided; cannot contain whitespace'); + $r1 = new Request('GET', '/'); + $r1->withRequestTarget('/foo bar'); + } + + public function testRequestUriMayBeString() + { + $r = (new Request('GET'))->withUriFromString('/'); + static::assertSame('/', (string) $r->getUri()); + } + + public function testRequestUriMayBeUri() + { + $uri = new Uri('/'); + $r = (new Request('GET'))->withUri($uri); + static::assertSame($uri, $r->getUri()); + } + + public function testSameInstanceWhenSameUri() + { + $r1 = (new Request('GET'))->withUriFromString('http://foo.com'); + $r2 = $r1->withUri($r1->getUri()); + static::assertEquals($r1, $r2); + } + + public function testSupportNumericHeaders() + { + $r = (new Request('GET'))->withHeaders( + [ + 'Content-Length' => 200, + ] + ); + static::assertSame(['Content-Length' => ['200']], $r->getHeaders()); + static::assertSame('200', $r->getHeaderLine('Content-Length')); + } + + public function testUpdateHostFromUri() + { + $request = new Request('GET'); + $request = $request->withUri(new Uri('https://nyholm.tech')); + static::assertSame('nyholm.tech', $request->getHeaderLine('Host')); + + $request = (new Request('GET'))->withUriFromString('https://example.com/'); + static::assertSame('example.com', $request->getHeaderLine('Host')); + + $request = $request->withUri(new Uri('https://nyholm.tech')); + static::assertSame('nyholm.tech', $request->getHeaderLine('Host')); + + $request = new Request('GET'); + $request = $request->withUri(new Uri('https://nyholm.tech:8080')); + static::assertSame('nyholm.tech:8080', $request->getHeaderLine('Host')); + + $request = new Request('GET'); + $request = $request->withUri(new Uri('https://nyholm.tech:443')); + static::assertSame('nyholm.tech', $request->getHeaderLine('Host')); + + $request = new Request('GET'); + $request = $request->withUri(new Uri('https://nyholm.tech:8080')) + ->withPort(8081); + static::assertSame('nyholm.tech:8081', $request->getHeaderLine('Host')); + } + + public function testValidateRequestUri() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse URI: ///'); + (new Request('GET'))->withUriFromString('///'); + } + + public function testWithInvalidRequestTarget() + { + $r = new Request('GET', '/'); + $this->expectException(\InvalidArgumentException::class); + $r->withRequestTarget('foo bar'); + } + + public function testWithRequestTarget() + { + $r1 = (new Request('GET'))->withUriFromString('/'); + $r2 = $r1->withRequestTarget('*'); + static::assertSame('*', $r2->getRequestTarget()); + static::assertSame('/', $r1->getRequestTarget()); + } + + public function testWithUri() + { + $r1 = new Request('GET', '/'); + $u1 = $r1->getUriOrNull(); + $u2 = new Uri('http://www.example.com'); + $r2 = $r1->withUri($u2); + static::assertNotSame($r1, $r2); + static::assertSame($u2, $r2->getUri()); + static::assertSame($u1, $r1->getUriOrNull()); + + $r3 = (new Request('GET'))->withUriFromString('/'); + $u3 = $r3->getUri(); + $r4 = $r3->withUri($u3); + static::assertEquals($r3, $r4); + static::assertNotSame($r3, $r4); + + $u4 = new Uri('/'); + $r5 = $r3->withUri($u4); + static::assertNotSame($r3, $r5); + } +} diff --git a/tests/Httpful/ResponseTest.php b/tests/Httpful/ResponseTest.php new file mode 100644 index 0000000..1ccb578 --- /dev/null +++ b/tests/Httpful/ResponseTest.php @@ -0,0 +1,265 @@ +getStatusCode()); + static::assertSame('1.1', $r->getProtocolVersion()); + static::assertSame('OK', $r->getReasonPhrase()); + static::assertSame([], $r->getHeaders()); + static::assertInstanceOf(StreamInterface::class, $r->getBody()); + static::assertSame('', (string) $r->getBody()); + static::assertFalse($r->hasBody()); + } + + public function testCanConstructWithStatusCode() + { + $r = (new Response())->withStatus(404); + static::assertSame(404, $r->getStatusCode()); + static::assertSame('Not Found', $r->getReasonPhrase()); + } + + public function testCanConstructWithUndefinedStatusCode() + { + $r = (new Response())->withStatus(999); + static::assertSame(999, $r->getStatusCode()); + static::assertSame('', $r->getReasonPhrase()); + } + + public function testConstructorDoesNotReadStreamBody() + { + $body = $this->getMockBuilder(StreamInterface::class)->getMock(); + $body->expects(static::never()) + ->method('__toString'); + + $r = (new Response())->withBody($body); + static::assertSame($body, $r->getBody()); + } + + public function testStatusCanBeNumericString() + { + $r = (new Response())->withStatus(404); + $r2 = $r->withStatus('201'); + static::assertSame(404, $r->getStatusCode()); + static::assertSame('Not Found', $r->getReasonPhrase()); + static::assertSame(201, $r2->getStatusCode()); + static::assertSame('Created', $r2->getReasonPhrase()); + } + + public function testCanConstructWithHeaders() + { + $r = (new Response())->withHeaders(['Foo' => 'Bar']); + static::assertSame(['Foo' => ['Bar']], $r->getHeaders()); + static::assertSame('Bar', $r->getHeaderLine('Foo')); + static::assertSame(['Bar'], $r->getHeader('Foo')); + } + + public function testCanConstructWithHeadersAsArray() + { + $r = new Response('', ['Foo' => ['baz', 'bar']]); + static::assertSame(['Foo' => ['baz', 'bar']], $r->getHeaders()); + static::assertSame('baz, bar', $r->getHeaderLine('Foo')); + static::assertSame(['baz', 'bar'], $r->getHeader('Foo')); + } + + public function testCanConstructWithBody() + { + $r = new Response('baz'); + static::assertInstanceOf(StreamInterface::class, $r->getBody()); + static::assertSame('baz', (string) $r->getBody()); + } + + public function testNullBody() + { + $r = new Response(null); + static::assertInstanceOf(StreamInterface::class, $r->getBody()); + static::assertSame('', (string) $r->getBody()); + } + + public function testFalseyBody() + { + $r = new Response('0'); + static::assertInstanceOf(StreamInterface::class, $r->getBody()); + static::assertSame('0', (string) $r->getBody()); + } + + public function testCanConstructWithReason() + { + $r = (new Response())->withStatus(200, 'bar'); + static::assertSame('bar', $r->getReasonPhrase()); + + $r = (new Response())->withStatus(200, '0'); + static::assertSame('0', $r->getReasonPhrase(), 'Falsey reason works'); + } + + public function testCanConstructWithProtocolVersion() + { + $r = (new Response())->withProtocolVersion('1000'); + static::assertSame('1000', $r->getProtocolVersion()); + } + + public function testWithStatusCodeAndNoReason() + { + $r = (new Response())->withStatus(201); + static::assertSame(201, $r->getStatusCode()); + static::assertSame('Created', $r->getReasonPhrase()); + } + + public function testWithStatusCodeAndReason() + { + $r = (new Response())->withStatus(201, 'Foo'); + static::assertSame(201, $r->getStatusCode()); + static::assertSame('Foo', $r->getReasonPhrase()); + + $r = (new Response())->withStatus(201, '0'); + static::assertSame(201, $r->getStatusCode()); + static::assertSame('0', $r->getReasonPhrase(), 'Falsey reason works'); + } + + public function testWithProtocolVersion() + { + $r = (new Response())->withProtocolVersion('1000'); + static::assertSame('1000', $r->getProtocolVersion()); + } + + public function testSameInstanceWhenSameProtocol() + { + $r = new Response(); + static::assertEquals($r, $r->withProtocolVersion('1.1')); + } + + public function testWithBody() + { + $b = (new Factory())->createStream('0'); + $r = (new Response())->withBody($b); + static::assertInstanceOf(StreamInterface::class, $r->getBody()); + static::assertSame('0', (string) $r->getBody()); + } + + public function testSameInstanceWhenSameBody() + { + $r = new Response(); + $b = $r->getBody(); + static::assertEquals($r, $r->withBody($b)); + } + + public function testWithHeader() + { + $r = new Response(200, ['Foo' => 'Bar']); + $r2 = $r->withHeader('baZ', 'Bam'); + static::assertSame(['Foo' => ['Bar']], $r->getHeaders()); + static::assertSame(['Foo' => ['Bar'], 'baZ' => ['Bam']], $r2->getHeaders()); + static::assertSame('Bam', $r2->getHeaderLine('baz')); + static::assertSame(['Bam'], $r2->getHeader('baz')); + } + + public function testWithHeaderAsArray() + { + $r = new Response(200, ['Foo' => 'Bar']); + $r2 = $r->withHeader('baZ', ['Bam', 'Bar']); + static::assertSame(['Foo' => ['Bar']], $r->getHeaders()); + static::assertSame(['Foo' => ['Bar'], 'baZ' => ['Bam', 'Bar']], $r2->getHeaders()); + static::assertSame('Bam, Bar', $r2->getHeaderLine('baz')); + static::assertSame(['Bam', 'Bar'], $r2->getHeader('baz')); + } + + public function testWithHeaderReplacesDifferentCase() + { + $r = new Response(200, ['Foo' => 'Bar']); + $r2 = $r->withHeader('foO', 'Bam'); + static::assertSame(['Foo' => ['Bar']], $r->getHeaders()); + static::assertSame(['foO' => ['Bam']], $r2->getHeaders()); + static::assertSame('Bam', $r2->getHeaderLine('foo')); + static::assertSame(['Bam'], $r2->getHeader('foo')); + } + + public function testWithAddedHeader() + { + $r = new Response(200, ['Foo' => 'Bar']); + $r2 = $r->withAddedHeader('foO', 'Baz'); + static::assertSame(['Foo' => ['Bar']], $r->getHeaders()); + static::assertSame(['foO' => ['Bar', 'Baz']], $r2->getHeaders()); + static::assertSame('Bar, Baz', $r2->getHeaderLine('foo')); + static::assertSame(['Bar', 'Baz'], $r2->getHeader('foo')); + } + + public function testWithAddedHeaderAsArray() + { + $r = new Response(200, ['Foo' => 'Bar']); + $r2 = $r->withAddedHeader('foO', ['Baz', 'Bam']); + static::assertSame(['Foo' => ['Bar']], $r->getHeaders()); + static::assertSame(['foO' => ['Bar', 'Baz', 'Bam']], $r2->getHeaders()); + static::assertSame('Bar, Baz, Bam', $r2->getHeaderLine('foo')); + static::assertSame(['Bar', 'Baz', 'Bam'], $r2->getHeader('foo')); + } + + public function testWithAddedHeaderThatDoesNotExist() + { + $r = new Response(200, ['Foo' => 'Bar']); + $r2 = $r->withAddedHeader('nEw', 'Baz'); + static::assertSame(['Foo' => ['Bar']], $r->getHeaders()); + static::assertSame(['Foo' => ['Bar'], 'nEw' => ['Baz']], $r2->getHeaders()); + static::assertSame('Baz', $r2->getHeaderLine('new')); + static::assertSame(['Baz'], $r2->getHeader('new')); + } + + public function testWithoutHeaderThatExists() + { + $r = new Response(200, ['Foo' => 'Bar', 'Baz' => 'Bam']); + $r2 = $r->withoutHeader('foO'); + static::assertTrue($r->hasHeader('foo')); + static::assertSame(['Foo' => ['Bar'], 'Baz' => ['Bam']], $r->getHeaders()); + static::assertFalse($r2->hasHeader('foo')); + static::assertSame(['Baz' => ['Bam']], $r2->getHeaders()); + } + + public function testWithoutHeaderThatDoesNotExist() + { + $r = new Response(200, ['Baz' => 'Bam']); + $r2 = $r->withoutHeader('foO'); + static::assertEquals($r, $r2); + static::assertFalse($r2->hasHeader('foo')); + static::assertSame(['Baz' => ['Bam']], $r2->getHeaders()); + } + + public function testSameInstanceWhenRemovingMissingHeader() + { + $r = new Response(); + static::assertEquals($r, $r->withoutHeader('foo')); + } + + public function trimmedHeaderValues() + { + return [ + [new Response(200, ['OWS' => " \t \tFoo\t \t "])], + [(new Response())->withHeader('OWS', " \t \tFoo\t \t ")], + [(new Response())->withAddedHeader('OWS', " \t \tFoo\t \t ")], + ]; + } + + /** + * @dataProvider trimmedHeaderValues + * + * @param mixed $r + */ + public function testHeaderValuesAreTrimmed($r) + { + static::assertSame(['OWS' => ['Foo']], $r->getHeaders()); + static::assertSame('Foo', $r->getHeaderLine('OWS')); + static::assertSame(['Foo'], $r->getHeader('OWS')); + } +} diff --git a/tests/Httpful/ServerRequestTest.php b/tests/Httpful/ServerRequestTest.php new file mode 100644 index 0000000..502f888 --- /dev/null +++ b/tests/Httpful/ServerRequestTest.php @@ -0,0 +1,118 @@ + new UploadedFile('test', 123, \UPLOAD_ERR_OK), + ]; + + $request2 = $request1->withUploadedFiles($files); + + static::assertNotSame($request2, $request1); + static::assertSame([], $request1->getUploadedFiles()); + static::assertSame($files, $request2->getUploadedFiles()); + } + + public function testServerParams() + { + $params = ['name' => 'value']; + + $request = new ServerRequest('GET', null, null, $params); + static::assertSame($params, $request->getServerParams()); + } + + public function testCookieParams() + { + $request1 = (new ServerRequest('GET'))->withUriFromString('/'); + + $params = ['name' => 'value']; + + $request2 = $request1->withCookieParams($params); + + static::assertNotSame($request2, $request1); + static::assertEmpty($request1->getCookieParams()); + static::assertSame($params, $request2->getCookieParams()); + } + + public function testQueryParams() + { + $request1 = new ServerRequest('GET'); + + $params = ['name' => 'value']; + + $request2 = $request1->withQueryParams($params); + + static::assertNotSame($request2, $request1); + static::assertEmpty($request1->getQueryParams()); + static::assertSame($params, $request2->getQueryParams()); + } + + public function testParsedBody() + { + $request1 = new ServerRequest('GET'); + + $params = ['name' => 'value']; + + $request2 = $request1->withParsedBody($params); + + static::assertNotSame($request2, $request1); + static::assertEmpty($request1->getParsedBody()); + static::assertSame($params, $request2->getParsedBody()); + } + + public function testAttributes() + { + $request1 = new ServerRequest('GET'); + + $request2 = $request1->withAttribute('name', 'value'); + $request3 = $request2->withAttribute('other', 'otherValue'); + $request4 = $request3->withoutAttribute('other'); + $request5 = $request3->withoutAttribute('unknown'); + + static::assertNotSame($request2, $request1); + static::assertNotSame($request3, $request2); + static::assertNotSame($request4, $request3); + static::assertNotSame($request5, $request4); + + static::assertEmpty($request1->getAttributes()); + static::assertEmpty($request1->getAttribute('name')); + static::assertEquals( + 'something', + $request1->getAttribute('name', 'something'), + 'Should return the default value' + ); + + static::assertEquals('value', $request2->getAttribute('name')); + static::assertEquals(['name' => 'value'], $request2->getAttributes()); + static::assertEquals(['name' => 'value', 'other' => 'otherValue'], $request3->getAttributes()); + static::assertEquals(['name' => 'value'], $request4->getAttributes()); + } + + public function testNullAttribute() + { + $request = (new ServerRequest('GET'))->withAttribute('name', null); + + static::assertSame(['name' => null], $request->getAttributes()); + static::assertNull($request->getAttribute('name', 'different-default')); + + $requestWithoutAttribute = $request->withoutAttribute('name'); + + static::assertSame([], $requestWithoutAttribute->getAttributes()); + static::assertSame('different-default', $requestWithoutAttribute->getAttribute('name', 'different-default')); + } +} diff --git a/tests/Httpful/StreamTest.php b/tests/Httpful/StreamTest.php new file mode 100644 index 0000000..d019eb0 --- /dev/null +++ b/tests/Httpful/StreamTest.php @@ -0,0 +1,186 @@ + 'öäü bar']; + + $stream = Http::stream($array); + + static::assertSame($array, $stream->getContentsUnserialized()); + } + + public function testCanDetachStream() + { + $r = \fopen('php://temp', 'w+b'); + $stream = Stream::create($r); + $stream->write('foo'); + static::assertTrue($stream->isReadable()); + static::assertSame($r, $stream->detach()); + $stream->detach(); + static::assertFalse($stream->isReadable()); + static::assertFalse($stream->isWritable()); + static::assertFalse($stream->isSeekable()); + $throws = static function (callable $fn) use ($stream) { + try { + $fn($stream); + static::fail(); + } catch (\Exception $e) { + // Suppress the exception + } + }; + $throws( + static function (Stream $stream) { + $stream->read(10); + } + ); + $throws( + static function (Stream $stream) { + $stream->write('bar'); + } + ); + $throws( + static function (Stream $stream) { + $stream->seek(10); + } + ); + $throws( + static function (Stream $stream) { + $stream->tell(); + } + ); + $throws( + static function (Stream $stream) { + $stream->eof(); + } + ); + $throws( + static function (Stream $stream) { + $stream->getSize(); + } + ); + $throws( + static function (Stream $stream) { + $stream->getContents(); + } + ); + static::assertSame('', (string) $stream); + $stream->close(); + } + + public function testChecksEof() + { + $handle = \fopen('php://temp', 'w+b'); + \fwrite($handle, 'data'); + $stream = Stream::create($handle); + static::assertFalse($stream->eof()); + $stream->read(4); + static::assertTrue($stream->eof()); + $stream->close(); + } + + public function testCloseClearProperties() + { + $handle = \fopen('php://temp', 'r+b'); + $stream = new Stream($handle); + $stream->close(); + static::assertFalse($stream->isSeekable()); + static::assertFalse($stream->isReadable()); + static::assertFalse($stream->isWritable()); + static::assertNull($stream->getSize()); + static::assertEmpty($stream->getMetadata()); + } + + public function testConstructorInitializesProperties() + { + $handle = \fopen('php://temp', 'r+b'); + \fwrite($handle, 'data'); + $stream = Stream::create($handle); + static::assertTrue($stream->isReadable()); + static::assertTrue($stream->isWritable()); + static::assertTrue($stream->isSeekable()); + static::assertSame('php://temp', $stream->getMetadata('uri')); + static::assertTrue(is_array($stream->getMetadata())); + static::assertSame(4, $stream->getSize()); + static::assertFalse($stream->eof()); + $stream->close(); + } + + public function testConvertsToString() + { + $handle = \fopen('php://temp', 'w+b'); + \fwrite($handle, 'data'); + $stream = Stream::create($handle); + static::assertSame('data', (string) $stream); + static::assertSame('data', (string) $stream); + $stream->close(); + } + + public function testEnsuresSizeIsConsistent() + { + $h = \fopen('php://temp', 'w+b'); + static::assertSame(3, \fwrite($h, 'foo')); + $stream = Stream::create($h); + static::assertSame(3, $stream->getSize()); + static::assertSame(4, $stream->write('test')); + static::assertSame(7, $stream->getSize()); + static::assertSame(7, $stream->getSize()); + $stream->close(); + } + + public function testGetSize() + { + $size = \filesize(__FILE__); + $handle = \fopen(__FILE__, 'rb'); + $stream = Stream::create($handle); + static::assertSame($size, $stream->getSize()); + // Load from cache + static::assertSame($size, $stream->getSize()); + $stream->close(); + } + + public function testGetsContents() + { + $handle = \fopen('php://temp', 'w+b'); + \fwrite($handle, 'data'); + $stream = Stream::create($handle); + static::assertSame('', $stream->getContents()); + $stream->seek(0); + static::assertSame('data', $stream->getContents()); + static::assertSame('', $stream->getContents()); + } + + public function testProvidesStreamPosition() + { + $handle = \fopen('php://temp', 'w+b'); + $stream = Stream::create($handle); + static::assertSame(0, $stream->tell()); + $stream->write('foo'); + static::assertSame(3, $stream->tell()); + $stream->seek(1); + static::assertSame(1, $stream->tell()); + static::assertSame(\ftell($handle), $stream->tell()); + $stream->close(); + } + + public function testString() + { + $string = 'foo öäü bar'; + + $stream = Http::stream($string); + + static::assertSame($string, $stream->getContents()); + } +} diff --git a/tests/Httpful/UploadedFileTest.php b/tests/Httpful/UploadedFileTest.php new file mode 100644 index 0000000..d58d167 --- /dev/null +++ b/tests/Httpful/UploadedFileTest.php @@ -0,0 +1,306 @@ + [null], + 'true' => [true], + 'false' => [false], + 'int' => [1], + 'float' => [1.1], + 'array' => [['filename']], + 'object' => [(object) ['filename']], + ]; + } + + /** + * @dataProvider invalidStreams + * + * @param mixed $streamOrFile + */ + public function testRaisesExceptionOnInvalidStreamOrFile($streamOrFile) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid stream or file provided for UploadedFile'); + + new UploadedFile($streamOrFile, 0, \UPLOAD_ERR_OK); + } + + /** + * @return array + */ + public function invalidErrorStatuses(): array + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'float' => [1.1], + 'string' => ['1'], + 'array' => [[1]], + 'object' => [(object) [1]], + 'negative' => [-1], + 'too-big' => [9], + ]; + } + + /** + * @dataProvider invalidErrorStatuses + * + * @param mixed $status + */ + public function testRaisesExceptionOnInvalidErrorStatus($status) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('status'); + + new UploadedFile(\fopen('php://temp', 'wb+'), 0, $status); + } + + /** + * @return array + */ + public function invalidFilenamesAndMediaTypes(): array + { + return [ + 'true' => [true], + 'false' => [false], + 'int' => [1], + 'float' => [1.1], + 'array' => [['string']], + 'object' => [(object) ['string']], + ]; + } + + /** + * @dataProvider invalidFilenamesAndMediaTypes + * + * @param mixed $filename + */ + public function testRaisesExceptionOnInvalidClientFilename($filename) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('filename'); + + new UploadedFile(\fopen('php://temp', 'wb+'), 0, \UPLOAD_ERR_OK, $filename); + } + + /** + * @dataProvider invalidFilenamesAndMediaTypes + * + * @param mixed $mediaType + */ + public function testRaisesExceptionOnInvalidClientMediaType($mediaType) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('media type'); + + new UploadedFile(\fopen('php://temp', 'wb+'), 0, \UPLOAD_ERR_OK, 'foobar.baz', $mediaType); + } + + public function testGetStreamReturnsOriginalStreamObject() + { + $stream = Stream::create(''); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); + + static::assertSame($stream, $upload->getStream()); + } + + public function testGetStreamReturnsWrappedPhpStream() + { + $stream = \fopen('php://temp', 'wb+'); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); + $uploadStream = $upload->getStream()->detach(); + + static::assertSame($stream, $uploadStream); + } + + public function testGetStream() + { + $upload = new UploadedFile(__DIR__ . '/../static/foo.txt', 0, \UPLOAD_ERR_OK); + $stream = $upload->getStream(); + static::assertInstanceOf(StreamInterface::class, $stream); + static::assertEquals("Foobar\n", $stream->__toString()); + } + + public function testSuccessful() + { + $stream = Stream::create('Foo bar!'); + $upload = new UploadedFile($stream, $stream->getSize(), \UPLOAD_ERR_OK, 'filename.txt', 'text/plain'); + + static::assertEquals($stream->getSize(), $upload->getSize()); + static::assertEquals('filename.txt', $upload->getClientFilename()); + static::assertEquals('text/plain', $upload->getClientMediaType()); + + $to = \tempnam(\sys_get_temp_dir(), 'successful'); + $this->cleanup[] = $to; + $upload->moveTo($to); + static::assertFileExists($to); + static::assertEquals($stream->__toString(), \file_get_contents($to)); + } + + /** + * @return array + */ + public function invalidMovePaths(): array + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'int' => [1], + 'float' => [1.1], + 'empty' => [''], + 'array' => [['filename']], + 'object' => [(object) ['filename']], + ]; + } + + /** + * @dataProvider invalidMovePaths + * + * @param mixed $path + */ + public function testMoveRaisesExceptionForInvalidPath($path) + { + $stream = (new Factory())->createStream('Foo bar!'); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); + + $this->cleanup[] = $path; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('path'); + $upload->moveTo($path); + } + + public function testMoveCannotBeCalledMoreThanOnce() + { + $stream = (new Factory())->createStream('Foo bar!'); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); + + $this->cleanup[] = $to = \tempnam(\sys_get_temp_dir(), 'diac'); + $upload->moveTo($to); + static::assertFileExists($to); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('moved'); + $upload->moveTo($to); + } + + public function testCannotRetrieveStreamAfterMove() + { + $stream = (new Factory())->createStream('Foo bar!'); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); + + $this->cleanup[] = $to = \tempnam(\sys_get_temp_dir(), 'diac'); + $upload->moveTo($to); + static::assertFileExists($to); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('moved'); + /** @noinspection UnusedFunctionResultInspection */ + $upload->getStream(); + } + + /** + * @return array + */ + public function nonOkErrorStatus(): array + { + return [ + 'UPLOAD_ERR_INI_SIZE' => [\UPLOAD_ERR_INI_SIZE], + 'UPLOAD_ERR_FORM_SIZE' => [\UPLOAD_ERR_FORM_SIZE], + 'UPLOAD_ERR_PARTIAL' => [\UPLOAD_ERR_PARTIAL], + 'UPLOAD_ERR_NO_FILE' => [\UPLOAD_ERR_NO_FILE], + 'UPLOAD_ERR_NO_TMP_DIR' => [\UPLOAD_ERR_NO_TMP_DIR], + 'UPLOAD_ERR_CANT_WRITE' => [\UPLOAD_ERR_CANT_WRITE], + 'UPLOAD_ERR_EXTENSION' => [\UPLOAD_ERR_EXTENSION], + ]; + } + + /** + * @dataProvider nonOkErrorStatus + * + * @param mixed $status + */ + public function testConstructorDoesNotRaiseExceptionForInvalidStreamWhenErrorStatusPresent($status) + { + $uploadedFile = new UploadedFile('not ok', 0, $status); + static::assertSame($status, $uploadedFile->getError()); + } + + /** + * @dataProvider nonOkErrorStatus + * + * @param mixed $status + */ + public function testMoveToRaisesExceptionWhenErrorStatusPresent($status) + { + $uploadedFile = new UploadedFile('not ok', 0, $status); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('upload error'); + $uploadedFile->moveTo(__DIR__ . '/' . \uniqid('', true)); + } + + /** + * @dataProvider nonOkErrorStatus + * + * @param mixed $status + */ + public function testGetStreamRaisesExceptionWhenErrorStatusPresent($status) + { + $uploadedFile = new UploadedFile('not ok', 0, $status); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('upload error'); + /** @noinspection UnusedFunctionResultInspection */ + $uploadedFile->getStream(); + } + + public function testMoveToCreatesStreamIfOnlyAFilenameWasProvided() + { + $this->cleanup[] = $from = \tempnam(\sys_get_temp_dir(), 'copy_from'); + $this->cleanup[] = $to = \tempnam(\sys_get_temp_dir(), 'copy_to'); + + \copy(__FILE__, $from); + + $uploadedFile = new UploadedFile($from, 100, \UPLOAD_ERR_OK, \basename($from), 'text/plain'); + $uploadedFile->moveTo($to); + + static::assertFileEquals(__FILE__, $to); + } + + public function testCleanUp() + { + $this->cleanup[] = $from = \tempnam(\sys_get_temp_dir(), 'test'); + + // PhpUnit "tearDown" need void return but old PHP version do not support it, so here is a hack ... + foreach ($this->cleanup as $file) { + if (\is_string($file) && \file_exists($file)) { + static::assertTrue(\unlink($file)); + } + } + } +} diff --git a/tests/Httpful/UriTest.php b/tests/Httpful/UriTest.php new file mode 100644 index 0000000..df3f48f --- /dev/null +++ b/tests/Httpful/UriTest.php @@ -0,0 +1,488 @@ +getScheme()); + static::assertSame('user:pass@example.com:8080', $uri->getAuthority()); + static::assertSame('user:pass', $uri->getUserInfo()); + static::assertSame('example.com', $uri->getHost()); + static::assertSame(8080, $uri->getPort()); + static::assertSame('/path/123', $uri->getPath()); + static::assertSame('q=abc', $uri->getQuery()); + static::assertSame('test', $uri->getFragment()); + static::assertSame('https://user:pass@example.com:8080/path/123?q=abc#test', (string) $uri); + } + + public function testCanTransformAndRetrievePartsIndividually() + { + $uri = (new Uri()) + ->withScheme('https') + ->withUserInfo('user', 'pass') + ->withHost('example.com') + ->withPort(8080) + ->withPath('/path/123') + ->withQuery('q=abc') + ->withFragment('test'); + + static::assertSame('https', $uri->getScheme()); + static::assertSame('user:pass@example.com:8080', $uri->getAuthority()); + static::assertSame('user:pass', $uri->getUserInfo()); + static::assertSame('example.com', $uri->getHost()); + static::assertSame(8080, $uri->getPort()); + static::assertSame('/path/123', $uri->getPath()); + static::assertSame('q=abc', $uri->getQuery()); + static::assertSame('test', $uri->getFragment()); + static::assertSame('https://user:pass@example.com:8080/path/123?q=abc#test', (string) $uri); + } + + /** + * @dataProvider getValidUris + * + * @param array $input + */ + public function testValidUrisStayValid($input) + { + $uri = new Uri($input); + + static::assertSame($input, (string) $uri); + } + + public function getValidUris() + { + return [ + ['urn:path-rootless'], + ['urn:path:with:colon'], + ['urn:/path-absolute'], + ['urn:/'], + // only scheme with empty path + ['urn:'], + // only path + ['/'], + ['relative/'], + ['0'], + // same document reference + [''], + // network path without scheme + ['//example.org'], + ['//example.org/'], + ['//example.org?q#h'], + // only query + ['?q'], + ['?q=abc&foo=bar'], + // only fragment + ['#fragment'], + // dot segments are not removed automatically + ['./foo/../bar'], + ]; + } + + /** + * @dataProvider getInvalidUris + * + * @param mixed $invalidUri + */ + public function testInvalidUrisThrowException($invalidUri) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse URI'); + + new Uri($invalidUri); + } + + public function getInvalidUris() + { + return [ + // parse_url() requires the host component which makes sense for http(s) + // but not when the scheme is not known or different. So '//' or '///' is + // currently invalid as well but should not according to RFC 3986. + ['http://'], + ['urn://host:with:colon'], // host cannot contain ":" + ]; + } + + public function testPortMustBeValid() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid port: 100000. Must be between 1 and 65535'); + + (new Uri())->withPort(100000); + } + + public function testWithPortCannotBeNegative() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid port: -1. Must be between 1 and 65535'); + + (new Uri())->withPort(-1); + } + + public function testParseUriPortCannotBeZero() + { + $this->expectException(\InvalidArgumentException::class); + if (\voku\helper\Bootup::is_php('7.3')) { + $this->expectExceptionMessage('Invalid port: 0'); + } else { + $this->expectExceptionMessage('Unable to parse URI'); + } + + new Uri('//example.com:0'); + } + + public function testSchemeMustHaveCorrectType() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Scheme must be a string'); + + (new Uri())->withScheme([]); + } + + public function testHostMustHaveCorrectType() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Host must be a string'); + + (new Uri())->withHost([]); + } + + public function testPathMustHaveCorrectType() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Path must be a string'); + + (new Uri())->withPath([]); + } + + public function testQueryMustHaveCorrectType() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Query and fragment must be a string'); + + (new Uri())->withQuery([]); + } + + public function testFragmentMustHaveCorrectType() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Query and fragment must be a string'); + + (new Uri())->withFragment([]); + } + + public function testCanParseFalseyUriParts() + { + $uri = new Uri('0://0:0@0/0?0#0'); + + static::assertSame('0', $uri->getScheme()); + static::assertSame('0:0@0', $uri->getAuthority()); + static::assertSame('0:0', $uri->getUserInfo()); + static::assertSame('0', $uri->getHost()); + static::assertSame('/0', $uri->getPath()); + static::assertSame('0', $uri->getQuery()); + static::assertSame('0', $uri->getFragment()); + static::assertSame('0://0:0@0/0?0#0', (string) $uri); + } + + public function testCanConstructFalseyUriParts() + { + $uri = (new Uri()) + ->withScheme('0') + ->withUserInfo('0', '0') + ->withHost('0') + ->withPath('/0') + ->withQuery('0') + ->withFragment('0'); + + static::assertSame('0', $uri->getScheme()); + static::assertSame('0:0@0', $uri->getAuthority()); + static::assertSame('0:0', $uri->getUserInfo()); + static::assertSame('0', $uri->getHost()); + static::assertSame('/0', $uri->getPath()); + static::assertSame('0', $uri->getQuery()); + static::assertSame('0', $uri->getFragment()); + static::assertSame('0://0:0@0/0?0#0', (string) $uri); + } + + public function getResolveTestCases() + { + return [ + [self::RFC3986_BASE, 'g:h', 'g:h'], + [self::RFC3986_BASE, 'g', 'http://a/b/c/g'], + [self::RFC3986_BASE, './g', 'http://a/b/c/g'], + [self::RFC3986_BASE, 'g/', 'http://a/b/c/g/'], + [self::RFC3986_BASE, '/g', 'http://a/g'], + [self::RFC3986_BASE, '//g', 'http://g'], + [self::RFC3986_BASE, '?y', 'http://a/b/c/d;p?y'], + [self::RFC3986_BASE, 'g?y', 'http://a/b/c/g?y'], + [self::RFC3986_BASE, '#s', 'http://a/b/c/d;p?q#s'], + [self::RFC3986_BASE, 'g#s', 'http://a/b/c/g#s'], + [self::RFC3986_BASE, 'g?y#s', 'http://a/b/c/g?y#s'], + [self::RFC3986_BASE, ';x', 'http://a/b/c/;x'], + [self::RFC3986_BASE, 'g;x', 'http://a/b/c/g;x'], + [self::RFC3986_BASE, 'g;x?y#s', 'http://a/b/c/g;x?y#s'], + [self::RFC3986_BASE, '', self::RFC3986_BASE], + [self::RFC3986_BASE, '.', 'http://a/b/c/'], + [self::RFC3986_BASE, './', 'http://a/b/c/'], + [self::RFC3986_BASE, '..', 'http://a/b/'], + [self::RFC3986_BASE, '../', 'http://a/b/'], + [self::RFC3986_BASE, '../g', 'http://a/b/g'], + [self::RFC3986_BASE, '../..', 'http://a/'], + [self::RFC3986_BASE, '../../', 'http://a/'], + [self::RFC3986_BASE, '../../g', 'http://a/g'], + [self::RFC3986_BASE, '../../../g', 'http://a/g'], + [self::RFC3986_BASE, '../../../../g', 'http://a/g'], + [self::RFC3986_BASE, '/./g', 'http://a/g'], + [self::RFC3986_BASE, '/../g', 'http://a/g'], + [self::RFC3986_BASE, 'g.', 'http://a/b/c/g.'], + [self::RFC3986_BASE, '.g', 'http://a/b/c/.g'], + [self::RFC3986_BASE, 'g..', 'http://a/b/c/g..'], + [self::RFC3986_BASE, '..g', 'http://a/b/c/..g'], + [self::RFC3986_BASE, './../g', 'http://a/b/g'], + [self::RFC3986_BASE, 'foo////g', 'http://a/b/c/foo////g'], + [self::RFC3986_BASE, './g/.', 'http://a/b/c/g/'], + [self::RFC3986_BASE, 'g/./h', 'http://a/b/c/g/h'], + [self::RFC3986_BASE, 'g/../h', 'http://a/b/c/h'], + [self::RFC3986_BASE, 'g;x=1/./y', 'http://a/b/c/g;x=1/y'], + [self::RFC3986_BASE, 'g;x=1/../y', 'http://a/b/c/y'], + // dot-segments in the query or fragment + [self::RFC3986_BASE, 'g?y/./x', 'http://a/b/c/g?y/./x'], + [self::RFC3986_BASE, 'g?y/../x', 'http://a/b/c/g?y/../x'], + [self::RFC3986_BASE, 'g#s/./x', 'http://a/b/c/g#s/./x'], + [self::RFC3986_BASE, 'g#s/../x', 'http://a/b/c/g#s/../x'], + [self::RFC3986_BASE, 'g#s/../x', 'http://a/b/c/g#s/../x'], + [self::RFC3986_BASE, '?y#s', 'http://a/b/c/d;p?y#s'], + ['http://a/b/c/d;p?q#s', '?y', 'http://a/b/c/d;p?y'], + ['http://u@a/b/c/d;p?q', '.', 'http://u@a/b/c/'], + ['http://u:p@a/b/c/d;p?q', '.', 'http://u:p@a/b/c/'], + ['http://a/b/c/d/', 'e', 'http://a/b/c/d/e'], + ['urn:no-slash', 'e', 'urn:e'], + // falsey relative parts + [self::RFC3986_BASE, '//0', 'http://0'], + [self::RFC3986_BASE, '0', 'http://a/b/c/0'], + [self::RFC3986_BASE, '?0', 'http://a/b/c/d;p?0'], + [self::RFC3986_BASE, '#0', 'http://a/b/c/d;p?q#0'], + ]; + } + + public function testSchemeIsNormalizedToLowercase() + { + $uri = new Uri('HTTP://example.com'); + + static::assertSame('http', $uri->getScheme()); + static::assertSame('http://example.com', (string) $uri); + + $uri = (new Uri('//example.com'))->withScheme('HTTP'); + + static::assertSame('http', $uri->getScheme()); + static::assertSame('http://example.com', (string) $uri); + } + + public function testHostIsNormalizedToLowercase() + { + $uri = new Uri('//eXaMpLe.CoM'); + + static::assertSame('example.com', $uri->getHost()); + static::assertSame('//example.com', (string) $uri); + + $uri = (new Uri())->withHost('eXaMpLe.CoM'); + + static::assertSame('example.com', $uri->getHost()); + static::assertSame('//example.com', (string) $uri); + } + + public function testPortIsNullIfStandardPortForScheme() + { + // HTTPS standard port + $uri = new Uri('https://example.com:443'); + static::assertNull($uri->getPort()); + static::assertSame('example.com', $uri->getAuthority()); + + $uri = (new Uri('https://example.com'))->withPort(443); + static::assertNull($uri->getPort()); + static::assertSame('example.com', $uri->getAuthority()); + + // HTTP standard port + $uri = new Uri('http://example.com:80'); + static::assertNull($uri->getPort()); + static::assertSame('example.com', $uri->getAuthority()); + + $uri = (new Uri('http://example.com'))->withPort(80); + static::assertNull($uri->getPort()); + static::assertSame('example.com', $uri->getAuthority()); + } + + public function testPortIsReturnedIfSchemeUnknown() + { + $uri = (new Uri('//example.com'))->withPort(80); + + static::assertSame(80, $uri->getPort()); + static::assertSame('example.com:80', $uri->getAuthority()); + } + + public function testStandardPortIsNullIfSchemeChanges() + { + $uri = new Uri('http://example.com:443'); + static::assertSame('http', $uri->getScheme()); + static::assertSame(443, $uri->getPort()); + + $uri = $uri->withScheme('https'); + static::assertNull($uri->getPort()); + } + + public function testPortPassedAsStringIsCastedToInt() + { + $uri = (new Uri('//example.com'))->withPort('8080'); + + static::assertSame(8080, $uri->getPort(), 'Port is returned as integer'); + static::assertSame('example.com:8080', $uri->getAuthority()); + } + + public function testPortCanBeRemoved() + { + $uri = (new Uri('http://example.com:8080'))->withPort(null); + + static::assertNull($uri->getPort()); + static::assertSame('http://example.com', (string) $uri); + } + + public function testAuthorityWithUserInfoButWithoutHost() + { + $uri = (new Uri())->withUserInfo('user', 'pass'); + + static::assertSame('user:pass', $uri->getUserInfo()); + static::assertSame('', $uri->getAuthority()); + } + + public function uriComponentsEncodingProvider() + { + $unreserved = 'a-zA-Z0-9.-_~!$&\'()*+,;=:@'; + + return [ + // Percent encode spaces + ['/pa th?q=va lue#frag ment', '/pa%20th', 'q=va%20lue', 'frag%20ment', '/pa%20th?q=va%20lue#frag%20ment'], + // Percent encode multibyte + ['/€?€#€', '/%E2%82%AC', '%E2%82%AC', '%E2%82%AC', '/%E2%82%AC?%E2%82%AC#%E2%82%AC'], + // Don't encode something that's already encoded + ['/pa%20th?q=va%20lue#frag%20ment', '/pa%20th', 'q=va%20lue', 'frag%20ment', '/pa%20th?q=va%20lue#frag%20ment'], + // Percent encode invalid percent encodings + ['/pa%2-th?q=va%2-lue#frag%2-ment', '/pa%252-th', 'q=va%252-lue', 'frag%252-ment', '/pa%252-th?q=va%252-lue#frag%252-ment'], + // Don't encode path segments + ['/pa/th//two?q=va/lue#frag/ment', '/pa/th//two', 'q=va/lue', 'frag/ment', '/pa/th//two?q=va/lue#frag/ment'], + // Don't encode unreserved chars or sub-delimiters + ["/{$unreserved}?{$unreserved}#{$unreserved}", "/{$unreserved}", $unreserved, $unreserved, "/{$unreserved}?{$unreserved}#{$unreserved}"], + // Encoded unreserved chars are not decoded + ['/p%61th?q=v%61lue#fr%61gment', '/p%61th', 'q=v%61lue', 'fr%61gment', '/p%61th?q=v%61lue#fr%61gment'], + ]; + } + + /** + * @dataProvider uriComponentsEncodingProvider + * + * @param mixed $input + * @param mixed $path + * @param mixed $query + * @param mixed $fragment + * @param mixed $output + */ + public function testUriComponentsGetEncodedProperly($input, $path, $query, $fragment, $output) + { + $uri = new Uri($input); + static::assertSame($path, $uri->getPath()); + static::assertSame($query, $uri->getQuery()); + static::assertSame($fragment, $uri->getFragment()); + static::assertSame($output, (string) $uri); + } + + public function testWithPathEncodesProperly() + { + $uri = (new Uri())->withPath('/baz?#€/b%61r'); + // Query and fragment delimiters and multibyte chars are encoded. + static::assertSame('/baz%3F%23%E2%82%AC/b%61r', $uri->getPath()); + static::assertSame('/baz%3F%23%E2%82%AC/b%61r', (string) $uri); + } + + public function testWithQueryEncodesProperly() + { + $uri = (new Uri())->withQuery('?=#&€=/&b%61r'); + // A query starting with a "?" is valid and must not be magically removed. Otherwise it would be impossible to + // construct such an URI. Also the "?" and "/" does not need to be encoded in the query. + static::assertSame('?=%23&%E2%82%AC=/&b%61r', $uri->getQuery()); + static::assertSame('??=%23&%E2%82%AC=/&b%61r', (string) $uri); + } + + public function testWithFragmentEncodesProperly() + { + $uri = (new Uri())->withFragment('#€?/b%61r'); + // A fragment starting with a "#" is valid and must not be magically removed. Otherwise it would be impossible to + // construct such an URI. Also the "?" and "/" does not need to be encoded in the fragment. + static::assertSame('%23%E2%82%AC?/b%61r', $uri->getFragment()); + static::assertSame('#%23%E2%82%AC?/b%61r', (string) $uri); + } + + public function testAllowsForRelativeUri() + { + $uri = (new Uri())->withPath('foo'); + static::assertSame('foo', $uri->getPath()); + static::assertSame('foo', (string) $uri); + } + + public function testAddsSlashForRelativeUriStringWithHost() + { + // If the path is rootless and an authority is present, the path MUST + // be prefixed by "/". + $uri = (new Uri())->withPath('foo')->withHost('example.com'); + static::assertSame('/foo', $uri->getPath()); + // concatenating a relative path with a host doesn't work: "//example.comfoo" would be wrong + static::assertSame('//example.com/foo', (string) $uri); + } + + public function testRemoveExtraSlashesWithoutHost() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The path of a URI without an authority must not start with two slashes'); + + (new Uri())->withPath('//foo'); + } + + public function testDefaultReturnValuesOfGetters() + { + $uri = new Uri(); + + static::assertSame('', $uri->getScheme()); + static::assertSame('', $uri->getAuthority()); + static::assertSame('', $uri->getUserInfo()); + static::assertSame('', $uri->getHost()); + static::assertNull($uri->getPort()); + static::assertSame('', $uri->getPath()); + static::assertSame('', $uri->getQuery()); + static::assertSame('', $uri->getFragment()); + } + + public function testImmutability() + { + $uri = new Uri(); + + static::assertNotSame($uri, $uri->withScheme('https')); + static::assertNotSame($uri, $uri->withUserInfo('user', 'pass')); + static::assertNotSame($uri, $uri->withHost('example.com')); + static::assertNotSame($uri, $uri->withPort(8080)); + static::assertNotSame($uri, $uri->withPath('/path/123')); + static::assertNotSame($uri, $uri->withQuery('q=abc')); + static::assertNotSame($uri, $uri->withFragment('test')); + } +} diff --git a/tests/Httpful/requestTest.php b/tests/Httpful/requestTest.php deleted file mode 100644 index 3286765..0000000 --- a/tests/Httpful/requestTest.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ -namespace Httpful\Test; - -class requestTest extends \PHPUnit_Framework_TestCase -{ - - /** - * @author Nick Fox - * @expectedException Httpful\Exception\ConnectionErrorException - * @expectedExceptionMessage Unable to connect - */ - public function testGet_InvalidURL() - { - // Silence the default logger via whenError override - \Httpful\Request::get('unavailable.url')->whenError(function($error) {})->send(); - } - -} diff --git a/tests/bootstrap-server.php b/tests/bootstrap-server.php deleted file mode 100644 index 1c27bd9..0000000 --- a/tests/bootstrap-server.php +++ /dev/null @@ -1,42 +0,0 @@ -./server.log 2>&1 & echo $!', WEB_SERVER_HOST, WEB_SERVER_PORT, WEB_SERVER_DOCROOT); - - // Execute the command and store the process ID - $output = array(); - exec($command, $output, $exit_code); - - // sleep for a second to let server come up - sleep(1); - $pid = (int) $output[0]; - - // check server.log to see if it failed to start - $server_logs = file_get_contents("./server.log"); - if (strpos($server_logs, "Fail") !== false) { - // server failed to start for some reason - print "Failed to start server! Logs:" . PHP_EOL . PHP_EOL; - print_r($server_logs); - exit(1); - } - - echo sprintf('%s - Web server started on %s:%d with PID %d', date('r'), WEB_SERVER_HOST, WEB_SERVER_PORT, $pid) . PHP_EOL; - - register_shutdown_function(function() { - // cleanup after ourselves -- remove log file, shut down server - global $pid; - unlink("./server.log"); - posix_kill($pid, SIGKILL); - }); -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..3858b1b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,51 @@ + ' . $serverLogFile . ' 2>&1 & echo $!', WEB_SERVER_HOST, WEB_SERVER_PORT, WEB_SERVER_DOCROOT); + + // Execute the command and store the process ID + $output = []; + \exec($command, $output, $exit_code); + + // sleep for a second to let server come up + \usleep(500); + $pid = (int) $output[0]; + + // check server.log to see if it failed to start + $serverLogData = (string) \file_get_contents($serverLogFile); + if (\strpos($serverLogData, 'Fail') !== false) { + // server failed to start for some reason + echo 'Failed to start server! Logs:' . \PHP_EOL . \PHP_EOL; + /** @noinspection ForgottenDebugOutputInspection */ + \print_r($serverLogData); + exit(1); + } + + /** @noinspection PhpUndefinedConstantInspection */ + echo \sprintf('%s - Web server started on %s:%d with PID %d', \date('r'), WEB_SERVER_HOST, WEB_SERVER_PORT, $pid) . \PHP_EOL; + + \register_shutdown_function(static function () { + // cleanup after ourselves -- remove log file, shut down server + global $pid; + \unlink('./server.log'); + \posix_kill($pid, \SIGKILL); + }); +} + +\define('TEST_SERVER', WEB_SERVER_HOST . ':' . WEB_SERVER_PORT); diff --git a/tests/phpunit.xml b/tests/phpunit.xml deleted file mode 100644 index c2c9d44..0000000 --- a/tests/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - . - - - - - - - - - - - - - diff --git a/tests/static/foo.txt b/tests/static/foo.txt new file mode 100644 index 0000000..d0e3340 --- /dev/null +++ b/tests/static/foo.txt @@ -0,0 +1 @@ +Foobar diff --git a/tests/test_image.jpg b/tests/static/test_image.jpg similarity index 100% rename from tests/test_image.jpg rename to tests/static/test_image.jpg