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
+[](https://github.com/voku/httpful/actions)
+[](https://codecov.io/github/voku/httpful?branch=master)
+[](https://www.codacy.com/app/voku/httpful)
+[](https://packagist.org/packages/voku/httpful)
+[](https://packagist.org/packages/voku/httpful)
+[](https://packagist.org/packages/voku/httpful)
+[](https://www.paypal.me/moelleken)
+[](https://www.patreon.com/voku)
-[](http://travis-ci.org/nategood/httpful) [](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('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 @@
+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 @@
+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 @@
- */
-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 = '2a stringTRUE';
- 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 = '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