pax_global_header00006660000000000000000000000064146144241740014521gustar00rootroot0000000000000052 comment=ea02c8aedef70b9a453ce97978d9d7c413972f15 httpful-1.0.0/000077500000000000000000000000001461442417400132055ustar00rootroot00000000000000httpful-1.0.0/.github/000077500000000000000000000000001461442417400145455ustar00rootroot00000000000000httpful-1.0.0/.github/workflows/000077500000000000000000000000001461442417400166025ustar00rootroot00000000000000httpful-1.0.0/.github/workflows/ci.yml000066400000000000000000000027671461442417400177340ustar00rootroot00000000000000on: push: branches: - master pull_request: branches: - master defaults: run: shell: bash jobs: tests: runs-on: ubuntu-latest strategy: fail-fast: false matrix: php: - "8.1" - "8.2" - "8.3" 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.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: | php vendor/bin/phpunit -c tests/phpunit.xml httpful-1.0.0/.gitignore000066400000000000000000000001561461442417400151770ustar00rootroot00000000000000.DS_Store composer.lock vendor downloads .idea/* .phpunit.result.cache .phpunit.cache /rector.php /.vs resultshttpful-1.0.0/.travis.yml000066400000000000000000000001651461442417400153200ustar00rootroot00000000000000language: php php: - 7.2 - 7.3 - 7.4 matrix: fast_finish: true script: - phpunit -c ./tests/phpunit.xml httpful-1.0.0/LICENSE.txt000066400000000000000000000020561461442417400150330ustar00rootroot00000000000000Copyright (c) 2012 Nate Good Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.httpful-1.0.0/README.md000066400000000000000000000226521461442417400144730ustar00rootroot00000000000000# Httpful Httpful is a simple Http Client library for PHP 8.0+. 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. Features - Readable HTTP Method Support (GET, PUT, POST, DELETE, HEAD, PATCH and OPTIONS) - Custom Headers - Automatic "Smart" Parsing - Automatic Payload Serialization - Basic Auth - Client Side Certificate Auth - Request "Templates" # 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. ```php // Make a request to the GitHub API with a custom // header of "X-Trvial-Header: Just as a demo". $url = "https://api.github.com/users/nategood"; $response = \Httpful\Request::get($url) ->expectsJson() ->withXTrivialHeader('Just as a demo') ->send(); echo "{$response->body->name} joined GitHub on " . date('M jS', strtotime($response->body->created_at)) ."\n"; ``` # Installation ## 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. # 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 ## 1.0.0 - SECURITY [Make certificate validation the default](https://huntr.com/bounties/8d59c089-92f1-4b73-90f8-54968a70e2fb). This is a potentially breaking change and as a result, bumping major version number in line with semver. Validation can still be skipped but must be explicitly skipped via withoutStrictSSL. - REFACTOR [PR #305](https://github.com/nategood/httpful/pull/305) Remove deprecated functionality pre PHP 8.1 - REFACTOR Partially from [PR #282](https://github.com/nategood/httpful/pull/282) Add CI support for running tests now that Travis is gone. ## 0.3.2 - REFACTOR [PR #276](https://github.com/nategood/httpful/pull/276) Add properly subclassed, more descriptive Exceptions for JSON parse errors ## 0.3.1 - FIX [PR #286](https://github.com/nategood/httpful/pull/286) Fixed header case sensitivity ## 0.3.0 - REFACTOR Dropped support for dead versions of PHP. Updated the PHPUnit tests. ## 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 httpful-1.0.0/bootstrap.php000066400000000000000000000001241461442417400157300ustar00rootroot00000000000000setStub($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; //passthru("git add $phar_path", $return_code); //exit_unless($return_code === 0, "Unable to add download files to git."); //echo "[ OK ]\n"; echo "\nBuild completed successfully.\n\n"; httpful-1.0.0/composer.json000066400000000000000000000011451461442417400157300ustar00rootroot00000000000000{ "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": "1.0.0", "authors": [ { "name": "Nate Good", "email": "me@nategood.com", "homepage": "http://nategood.com" } ], "require": { "php": ">=8.1", "ext-curl": "*" }, "autoload": { "psr-0": { "Httpful": "src/" } }, "require-dev": { "phpunit/phpunit": "^10.5" } } httpful-1.0.0/examples/000077500000000000000000000000001461442417400150235ustar00rootroot00000000000000httpful-1.0.0/examples/freebase.php000066400000000000000000000006541461442417400173150ustar00rootroot00000000000000expectsJson() ->sendIt(); echo 'The Dead Weather has ' . count($response->body->result->album) . " albums.\n";httpful-1.0.0/examples/github.php000066400000000000000000000004631461442417400170210ustar00rootroot00000000000000send(); echo "{$request->body->name} joined GitHub on " . date('M jS', strtotime($request->body->{'created-at'})) ."\n";httpful-1.0.0/examples/override.php000066400000000000000000000023221461442417400173520ustar00rootroot00000000000000 'http://example.com'); \Httpful\Httpful::register(\Httpful\Mime::XML, new \Httpful\Handlers\XmlHandler($conf)); // We can also add the parsers with our own... class SimpleCsvHandler extends \Httpful\Handlers\MimeHandlerAdapter { /** * Takes a response body, and turns it into * a two dimensional array. * * @param string $body * @return mixed */ public function parse($body) { return str_getcsv($body); } /** * Takes a two dimensional array and turns it * into a serialized string to include as the * body of a request * * @param mixed $payload * @return string */ public function serialize($payload) { $serialized = ''; foreach ($payload as $line) { $serialized .= '"' . implode('","', $line) . '"' . "\n"; } return $serialized; } } \Httpful\Httpful::register('text/csv', new SimpleCsvHandler());httpful-1.0.0/examples/showclix.php000066400000000000000000000014011461442417400173700ustar00rootroot00000000000000expectsType('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";httpful-1.0.0/src/000077500000000000000000000000001461442417400137745ustar00rootroot00000000000000httpful-1.0.0/src/Httpful/000077500000000000000000000000001461442417400154225ustar00rootroot00000000000000httpful-1.0.0/src/Httpful/Bootstrap.php000066400000000000000000000046761461442417400201250ustar00rootroot00000000000000 */ class Bootstrap { public const DIR_GLUE = DIRECTORY_SEPARATOR; public const NS_GLUE = '\\'; public static $registered = false; /** * Register the autoloader and any other setup needed */ public static function init() { spl_autoload_register(['\\' . \Httpful\Bootstrap::class, 'autoload']); self::registerHandlers(); } /** * The autoload magic (PSR-0 style) * * @param string $classname */ public static function autoload($classname) { self::_autoload(dirname(__FILE__,2), $classname); } /** * Register the autoloader and any other setup needed */ public static function pharInit() { spl_autoload_register(['\\' . \Httpful\Bootstrap::class, '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 = [ \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; } } httpful-1.0.0/src/Httpful/Exception/000077500000000000000000000000001461442417400173605ustar00rootroot00000000000000httpful-1.0.0/src/Httpful/Exception/ConnectionErrorException.php000066400000000000000000000014201461442417400250560ustar00rootroot00000000000000curlErrorNumber; } /** * @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; } }httpful-1.0.0/src/Httpful/Exception/JsonParseException.php000066400000000000000000000001241461442417400236510ustar00rootroot00000000000000 */ namespace Httpful\Handlers; class CsvHandler extends MimeHandlerAdapter { /** * @param string $body * @return mixed * @throws \Exception */ public function parse($body) { if (empty($body)) return null; $parsed = []; $fp = fopen('data://text/plain;base64,' . base64_encode($body), 'r'); while (($r = fgetcsv($fp)) !== FALSE) { $parsed[] = $r; } if ($parsed === []) throw new \Exception("Unable to parse response as CSV"); return $parsed; } /** * @param mixed $payload * @return string */ public function serialize($payload): string { $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; } } httpful-1.0.0/src/Httpful/Handlers/FormHandler.php000066400000000000000000000010511461442417400220710ustar00rootroot00000000000000 */ namespace Httpful\Handlers; class FormHandler extends MimeHandlerAdapter { /** * @param string $body * @return mixed */ public function parse($body) { $parsed = []; parse_str($body, $parsed); return $parsed; } /** * @param mixed $payload * @return string */ public function serialize($payload): string { return http_build_query($payload, null, '&'); } }httpful-1.0.0/src/Httpful/Handlers/JsonHandler.php000066400000000000000000000017731461442417400221120ustar00rootroot00000000000000 */ namespace Httpful\Handlers; use Httpful\Exception\JsonParseException; class JsonHandler extends MimeHandlerAdapter { private $decode_as_array = false; public function init(array $args) { $this->decode_as_array = (bool) ($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 JsonParseException('Unable to parse response as JSON: ' . json_last_error_msg()); return $parsed; } /** * @param mixed $payload * @return string */ public function serialize($payload): string { return json_encode($payload); } } httpful-1.0.0/src/Httpful/Handlers/MimeHandlerAdapter.php000066400000000000000000000023211461442417400233570ustar00rootroot00000000000000init($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): string { return (string) $payload; } protected function stripBom($body): string { if ( substr($body,0,3) === "\xef\xbb\xbf" ) // UTF-8 $body = substr($body,3); elseif ( substr($body,0,4) === "\xff\xfe\x00\x00" || substr($body,0,4) === "\x00\x00\xfe\xff" ) // UTF-32 $body = substr($body,4); elseif ( substr($body,0,2) === "\xff\xfe" || substr($body,0,2) === "\xfe\xff" ) // UTF-16 $body = substr($body,2); return $body; } }httpful-1.0.0/src/Httpful/Handlers/README.md000066400000000000000000000026571461442417400204530ustar00rootroot00000000000000# Handlers Handlers are simple classes that are used to parse response bodies and serialize request payloads. All Handlers must extend the `MimeHandlerAdapter` class and implement two methods: `serialize($payload)` and `parse($response)`. Let's build a very basic Handler to register for the `text/csv` mime type. */ namespace Httpful\Handlers; class XHtmlHandler extends MimeHandlerAdapter { // @todo add html specific parsing // see DomDocument::load http://docs.php.net/manual/en/domdocument.loadhtml.php }httpful-1.0.0/src/Httpful/Handlers/XmlHandler.php000066400000000000000000000104531461442417400217340ustar00rootroot00000000000000 * @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 = []) { $this->namespace = $conf['namespace'] ?? ''; $this->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): string { [$_, $dom] = $this->_future_serializeAsXml($payload); return $dom->saveXml(); } /** * @param mixed $payload * @return string * @author Ted Zellers */ public function serialize_clean($payload): string { $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): array { 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); } elseif (is_array($value)) { $arrNode = $dom->createElement('array'); $node->appendChild($arrNode); $this->_future_serializeArrayAsXml($value, $arrNode, $dom); } elseif (is_bool($value)) { $node->appendChild($dom->createTextNode($value?'TRUE':'FALSE')); } else { $node->appendChild($dom->createTextNode($value)); } return [$node, $dom]; } /** * @author Zack Douglas */ private function _future_serializeArrayAsXml($value, &$parent, &$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]; } /** * @author Zack Douglas */ private function _future_serializeObjectAsXml($value, &$parent, &$dom): array { $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 [$parent, $dom]; } }httpful-1.0.0/src/Httpful/Http.php000066400000000000000000000041461461442417400170570ustar00rootroot00000000000000 */ class Http { public const HEAD = 'HEAD'; public const GET = 'GET'; public const POST = 'POST'; public const PUT = 'PUT'; public const DELETE = 'DELETE'; public const PATCH = 'PATCH'; public const OPTIONS = 'OPTIONS'; public const TRACE = 'TRACE'; /** * @return array of HTTP method strings */ public static function safeMethods(): array { return [self::HEAD, self::GET, self::OPTIONS, self::TRACE]; } /** * @param string HTTP method * @return bool */ public static function isSafeMethod($method): bool { return in_array($method, self::safeMethods()); } /** * @param string HTTP method * @return bool */ public static function isUnsafeMethod($method): bool { return !in_array($method, self::safeMethods()); } /** * @return array list of (always) idempotent HTTP methods */ public static function idempotentMethods(): array { // Though it is possible to be idempotent, POST // is not guarunteed to be, and more often than // not, it is not. return [self::HEAD, self::GET, self::PUT, self::DELETE, self::OPTIONS, self::TRACE, self::PATCH]; } /** * @param string HTTP method * @return bool */ public static function isIdempotent($method): bool { return in_array($method, self::safeidempotentMethodsMethods()); } /** * @param string HTTP method * @return bool */ public static function isNotIdempotent($method): bool { return !in_array($method, self::idempotentMethods()); } /** * @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 * * @return array of HTTP method strings */ public static function canHaveBody(): array { return [self::POST, self::PUT, self::PATCH, self::OPTIONS]; } }httpful-1.0.0/src/Httpful/Httpful.php000066400000000000000000000022121461442417400175560ustar00rootroot00000000000000 */ class Mime { public const JSON = 'application/json'; public const XML = 'application/xml'; public const XHTML = 'application/html+xml'; public const FORM = 'application/x-www-form-urlencoded'; public const UPLOAD = 'multipart/form-data'; public const PLAIN = 'text/plain'; public const JS = 'text/javascript'; public const HTML = 'text/html'; public const YAML = 'application/x-yaml'; public const CSV = 'text/csv'; /** * Map short name for a mime type * to a full proper mime type */ public 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(string $short_name): string { return self::$mimes[$short_name] ?? $short_name; } /** * @param string $short_name * @return bool */ public static function supportsMimeType(string $short_name): bool { return array_key_exists($short_name, self::$mimes); } } httpful-1.0.0/src/Httpful/Proxy.php000066400000000000000000000004541461442417400172570ustar00rootroot00000000000000 * * @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 { // Option constants public const SERIALIZE_PAYLOAD_NEVER = 0; public const SERIALIZE_PAYLOAD_ALWAYS = 1; public const SERIALIZE_PAYLOAD_SMART = 2; public const MAX_REDIRECTS_DEFAULT = 25; public $uri, $method = Http::GET, $headers = [], $raw_headers = '', $strict_ssl = true, $content_type, $expected_type, $additional_curl_opts = [], $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 = [], $timeout = null, $client_cert = null, $client_key = null, $client_passphrase = null, $client_encoding = null; // Options // private $_options = array( // 'serialize_payload_method' => self::SERIALIZE_PAYLOAD_SMART // 'auto_parse' => true // ); // Curl Handle public $_ch, $_debug; // Template Request object private static $_template; /** * 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 */ protected function __construct($attrs = null) { if (!is_array($attrs)) return; foreach ($attrs as $attr => $value) { if (property_exists($this, $attr)) $this->$attr = $value; } } // Defaults Management /** * 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 */ public static function ini(Request $template) { self::$_template = clone $template; } /** * Reset the default template back to the * library defaults. */ public static function resetIni() { self::_initializeDefaults(); } /** * 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 */ public static function d($attr) { return isset($attr) ? self::$_template->$attr : self::$_template; } // Accessors /** * @return bool does the request have a timeout? */ public function hasTimeout(): bool { return $this->timeout !== null; } /** * @return bool has the internal curl request been initialized? */ public function hasBeenInitialized(): bool { return $this->_ch !== null; } /** * @return bool Is this request setup for basic auth? */ public function hasBasicAuth(): bool { return $this->password !== null && $this->username !== null; } /** * @return bool Is this request setup for digest auth? */ public function hasDigestAuth(): bool { return $this->password !== null && $this->username !== null && $this->additional_curl_opts[CURLOPT_HTTPAUTH] == CURLAUTH_DIGEST; } /** * Specify a HTTP timeout * @param float|int $timeout seconds to timeout the HTTP call * @return Request */ public function timeout($timeout) { $this->timeout = $timeout; return $this; } // alias timeout public function timeoutIn($seconds) { return $this->timeout($seconds); } /** * 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 */ public function followRedirects($follow = true) { $this->max_redirects = $follow === true ? self::MAX_REDIRECTS_DEFAULT : max(0, $follow); $this->follow_redirects = (bool) $follow; return $this; } /** * @see Request::followRedirects() * @return Request */ public function doNotFollowRedirects() { return $this->followRedirects(false); } /** * Actually send off the request, and parse the response * @return Response with parsed results * @throws ConnectionErrorException when unable to parse or communicate w server */ public function send() { if (!$this->hasBeenInitialized()) $this->_curlPrep(); $result = curl_exec($this->_ch); $response = $this->buildResponse($result); curl_close($this->_ch); unset($this->_ch); return $response; } public function sendIt() { return $this->send(); } // Setters /** * @param string $uri * @return Request */ public function uri($uri) { $this->uri = $uri; return $this; } /** * User Basic Auth. * Only use when over SSL/TSL/HTTPS. * @param string $username * @param string $password * @return Request */ 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) { return $this->basicAuth($username, $password); } // @alias of ntlmAuth public function authenticateWithNTLM($username, $password) { return $this->ntlmAuth($username, $password); } public function ntlmAuth($username, $password) { $this->addOnCurlOption(CURLOPT_HTTPAUTH, CURLAUTH_NTLM); return $this->basicAuth($username, $password); } /** * User Digest Auth. * @param string $username * @param string $password * @return Request */ public function digestAuth($username, $password) { $this->addOnCurlOption(CURLOPT_HTTPAUTH, CURLAUTH_DIGEST); return $this->basicAuth($username, $password); } // @alias of digestAuth public function authenticateWithDigest($username, $password) { return $this->digestAuth($username, $password); } /** * @return bool is this request setup for client side cert? */ public function hasClientSideCert(): bool { return $this->client_cert !== null && $this->client_key !==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 */ public function clientSideCert($cert, $key, $passphrase = null, $encoding = 'PEM') { $this->client_cert = $cert; $this->client_key = $key; $this->client_passphrase = $passphrase; $this->client_encoding = $encoding; return $this; } // @alias of basicAuth public function authenticateWithCert($cert, $key, $passphrase = null, $encoding = 'PEM') { return $this->clientSideCert($cert, $key, $passphrase, $encoding); } /** * 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; } /** * 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 */ public function mime($mime) { if (empty($mime)) return $this; $this->content_type = $this->expected_type = Mime::getFullMime($mime); if ($this->isUpload()) { $this->neverSerializePayload(); } return $this; } // @alias of mime public function sendsAndExpectsType($mime) { return $this->mime($mime); } // @alias of mime public function sendsAndExpects($mime) { return $this->mime($mime); } /** * 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 */ public function method($method) { if (empty($method)) return $this; $this->method = $method; return $this; } /** * @param string $mime * @return Request */ public function expects(?string $mime) { if (empty($mime)) return $this; $this->expected_type = Mime::getFullMime($mime); return $this; } // @alias of expects public function expectsType(?string $mime) { return $this->expects($mime); } 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; } } } $this->sendsType(Mime::UPLOAD); return $this; } /** * @param string $mime * @return Request */ 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) { return $this->contentType($mime); } /** * Do we strictly enforce SSL verification? * @param bool $strict * @return Request */ public function strictSSL($strict) { $this->strict_ssl = $strict; return $this; } public function withoutStrictSSL() { return $this->strictSSL(false); } public function withStrictSSL() { return $this->strictSSL(true); } /** * 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 */ public function useProxy($proxy_host, $proxy_port = 80, $auth_type = null, $auth_username = null, $auth_password = null, $proxy_type = Proxy::HTTP) { $this->addOnCurlOption(CURLOPT_PROXY, "{$proxy_host}:{$proxy_port}"); $this->addOnCurlOption(CURLOPT_PROXYTYPE, $proxy_type); if (in_array($auth_type, [CURLAUTH_BASIC,CURLAUTH_NTLM])) { $this->addOnCurlOption(CURLOPT_PROXYAUTH, $auth_type) ->addOnCurlOption(CURLOPT_PROXYUSERPWD, "{$auth_username}:{$auth_password}"); } return $this; } /** * Shortcut for useProxy to configure SOCKS 4 proxy * @see Request::useProxy * @return Request */ public function useSocks4Proxy($proxy_host, $proxy_port = 80, $auth_type = null, $auth_username = null, $auth_password = null) { return $this->useProxy($proxy_host, $proxy_port, $auth_type, $auth_username, $auth_password, Proxy::SOCKS4); } /** * Shortcut for useProxy to configure SOCKS 5 proxy * @see Request::useProxy * @return Request */ public function useSocks5Proxy($proxy_host, $proxy_port = 80, $auth_type = null, $auth_username = null, $auth_password = null) { return $this->useProxy($proxy_host, $proxy_port, $auth_type, $auth_username, $auth_password, Proxy::SOCKS5); } /** * @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"); } /** * 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 */ public function serializePayload($mode) { $this->serialize_payload_method = $mode; return $this; } /** * @see Request::serializePayload() * @return Request */ public function neverSerializePayload() { return $this->serializePayload(self::SERIALIZE_PAYLOAD_NEVER); } /** * This method is the default behavior * @see Request::serializePayload() * @return Request */ public function smartSerializePayload() { return $this->serializePayload(self::SERIALIZE_PAYLOAD_SMART); } /** * @see Request::serializePayload() * @return Request */ public function alwaysSerializePayload() { return $this->serializePayload(self::SERIALIZE_PAYLOAD_ALWAYS); } /** * Add an additional header to the request * Can also use the cleaner syntax of * $Request->withMyHeaderName($my_value); * @see Request::__call() * * @param string $header_name * @param string $value * @return Request */ public function addHeader($header_name, $value) { $this->headers[$header_name] = $value; return $this; } /** * 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 */ public function addHeaders(array $headers) { foreach ($headers as $header => $value) { $this->addHeader($header, $value); } return $this; } /** * @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 */ public function autoParse($auto_parse = true) { $this->auto_parse = $auto_parse; return $this; } /** * @see Request::autoParse() * @return Request */ public function withoutAutoParsing() { return $this->autoParse(false); } /** * @see Request::autoParse() * @return Request */ public function withAutoParsing() { return $this->autoParse(true); } /** * 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 */ public function parseWith(\Closure $callback) { $this->parse_callback = $callback; return $this; } /** * @see Request::parseResponsesWith() * @param \Closure $callback * @return Request */ public function parseResponsesWith(\Closure $callback) { return $this->parseWith($callback); } /** * Callback called to handle HTTP errors. When nothing is set, defaults * to logging via `error_log` * @param \Closure $callback (string $error) * @return Request */ public function whenError(\Closure $callback) { $this->error_callback = $callback; return $this; } /** * Callback invoked after payload has been serialized but before * the request has been built. * @param \Closure $callback (Request $request) * @return Request */ public function beforeSend(\Closure $callback) { $this->send_callback = $callback; return $this; } /** * 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 \Closure $callback takes one argument, $payload, * which is the payload that we'll be * @return Request */ public function registerPayloadSerializer($mime, \Closure $callback) { $this->payload_serializers[Mime::getFullMime($mime)] = $callback; return $this; } /** * @see Request::registerPayloadSerializer() * @param \Closure $callback * @return Request */ public function serializePayloadWith(\Closure $callback) { return $this->registerPayloadSerializer('*', $callback); } /** * 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 */ public function __call($method, $args) { // 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"); // } } // This method also adds the custom header support as described in the // method comments if ($args === []) 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; } // Internal Functions /** * 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. */ private static function _initializeDefaults() { // 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(['method' => Http::GET]); // This is more like it... self::$_template ->withoutStrictSSL(); } /** * Set the defaults on a newly instantiated object * Doesn't copy variables prefixed with _ * @return Request */ private function _setDefaults() { if (!isset(self::$_template)) self::_initializeDefaults(); foreach (self::$_template as $k=>$v) { if ($k[0] != '_') $this->$k = $v; } return $this; } private function _error($error) { // 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 ($this->error_callback !== null) { $this->error_callback->__invoke($error); } else { error_log($error); } } /** * 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 * @return Request */ public static function init($method = null, $mime = null) { // Setup our handlers, can call it here as it's idempotent Bootstrap::init(); // Setup the default template if need be if (!isset(self::$_template)) self::_initializeDefaults(); $request = new Request(); return $request ->_setDefaults() ->method($method) ->sendsType($mime) ->expectsType($mime); } /** * 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 */ public function _curlPrep() { // Check for required stuff if ($this->uri === null) throw new \Exception('Attempting to send a request before defining a URI endpoint.'); if ($this->payload !== null) { $this->serialized_payload = $this->_serializePayload($this->payload); } if ($this->send_callback !== null) { call_user_func($this->send_callback, $this); } $ch = curl_init($this->uri); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method); if ($this->method === Http::HEAD) { curl_setopt($ch, CURLOPT_NOBODY, true); } if ($this->hasBasicAuth()) { curl_setopt($ch, CURLOPT_USERPWD, $this->username . ':' . $this->password); } 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 ($this->hasTimeout()) { if (defined('CURLOPT_TIMEOUT_MS')) { curl_setopt($ch, CURLOPT_TIMEOUT_MS, $this->timeout * 1000); } else { curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); } } if ($this->follow_redirects) { curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_MAXREDIRS, $this->max_redirects); } 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); // https://github.com/nategood/httpful/issues/84 // set Content-Length to the size of the payload if present if ($this->payload !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $this->serialized_payload); if (!$this->isUpload()) { $this->headers['Content-Length'] = $this->_determineLength($this->serialized_payload); } } $headers = []; // https://github.com/nategood/httpful/issues/37 // Except header removes any HTTP 1.1 Continue from response headers $headers[] = 'Expect:'; if (!isset($this->headers['User-Agent'])) { $headers[] = $this->buildUserAgent(); } $headers[] = "Content-Type: {$this->content_type}"; // 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;'; if (!empty($this->expected_type)) { $accept .= "q=0.9, {$this->expected_type}"; } $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; } foreach ($this->headers as $header => $value) { $headers[] = "$header: $value"; } $url = \parse_url($this->uri); $path = ($url['path'] ?? '/').(isset($url['query']) ? '?'.$url['query'] : ''); $this->raw_headers = "{$this->method} $path HTTP/1.1\r\n"; $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); if ($this->_debug) { curl_setopt($ch, CURLOPT_VERBOSE, true); } curl_setopt($ch, CURLOPT_HEADER, 1); // 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); } $this->_ch = $ch; return $this; } /** * @param string $str payload * @return int length of payload in bytes */ public function _determineLength($str) { if (function_exists('mb_strlen')) { return mb_strlen($str, '8bit'); } else { return strlen($str); } } /** * @return bool */ public function isUpload(): bool { return Mime::UPLOAD == $this->content_type; } /** * @return string */ public function buildUserAgent(): string { $user_agent = 'User-Agent: Httpful/' . Httpful::VERSION . ' (cURL/'; $curl = \curl_version(); if (isset($curl['version'])) { $user_agent .= $curl['version']; } else { $user_agent .= '?.?.?'; } $user_agent .= ' PHP/'. PHP_VERSION . ' (' . PHP_OS . ')'; 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']}"; } if (isset($_SERVER['TERM_PROGRAM_VERSION'])) { $user_agent .= "/{$_SERVER['TERM_PROGRAM_VERSION']}"; } } if (isset($_SERVER['HTTP_USER_AGENT'])) { $user_agent .= " {$_SERVER['HTTP_USER_AGENT']}"; } return $user_agent . ')'; } /** * Takes a curl result and generates a Response from it * @return Response */ public function buildResponse($result) { if ($result === false) { if (($curlErrorNumber = curl_errno($this->_ch)) !== 0) { $curlErrorString = curl_error($this->_ch); $this->_error($curlErrorString); $exception = new ConnectionErrorException('Unable to connect to "'.$this->uri.'": ' . $curlErrorNumber . ' ' . $curlErrorString); $exception->setCurlErrorNumber($curlErrorNumber) ->setCurlErrorString($curlErrorString); throw $exception; } $this->_error('Unable to connect to "'.$this->uri.'".'); throw new ConnectionErrorException('Unable to connect to "'.$this->uri.'".'); } $info = curl_getinfo($this->_ch); // 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); } $response = explode("\r\n\r\n", $result, 2 + $info['redirect_count']); $body = array_pop($response); $headers = array_pop($response); return new Response($body, $headers, $this, $info); } /** * 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 */ public function addOnCurlOption($curlopt, $curloptval) { $this->additional_curl_opts[$curlopt] = $curloptval; 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. * * Added in support for custom payload serializers. * The serialize_payload_method stuff still holds true though. * @see Request::registerPayloadSerializer() * * @param mixed $payload * @return string */ private function _serializePayload($payload) { 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; // 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); } return Httpful::get($this->content_type)->serialize($payload); } /** * HTTP Method Get * @param string $uri optional uri to use * @param string $mime expected * @return Request */ public static function get($uri, $mime = null) { return self::init(Http::GET)->uri($uri)->mime($mime); } /** * 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(); } /** * 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 */ public static function post($uri, $payload = null, $mime = null) { return self::init(Http::POST)->uri($uri)->body($payload, $mime); } /** * 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); } /** * 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 */ public static function patch($uri, $payload = null, $mime = null) { return self::init(Http::PATCH)->uri($uri)->body($payload, $mime); } /** * HTTP Method Delete * @param string $uri optional uri to use * @return Request */ public static function delete($uri, $mime = null) { return self::init(Http::DELETE)->uri($uri)->mime($mime); } /** * HTTP Method Head * @param string $uri optional uri to use * @return Request */ public static function head($uri) { return self::init(Http::HEAD)->uri($uri); } /** * HTTP Method Options * @param string $uri optional uri to use * @return Request */ public static function options($uri) { return self::init(Http::OPTIONS)->uri($uri); } } httpful-1.0.0/src/Httpful/Response.php000066400000000000000000000127501461442417400177360ustar00rootroot00000000000000 */ class Response { public $body, $raw_body, $headers, $raw_headers, $request, $code = 0, $content_type, $parent_type, $charset = null, $meta_data, $is_mime_vendor_specific = false, $is_mime_personal = false; private $parsers; /** * @param string $body * @param string $headers * @param Request $request * @param array $meta_data */ public function __construct(string $body, string $headers, Request $request, array $meta_data = []) { $this->request = $request; $this->raw_headers = $headers; $this->raw_body = $body; $this->meta_data = $meta_data; $this->code = $this->_parseCode($headers); $this->headers = Response\Headers::fromString($headers); $this->_interpretHeaders(); $this->body = $this->_parse($body); } /** * Status Code Definitions * * Informational 1xx * Successful 2xx * Redirection 3xx * Client Error 4xx * Server Error 5xx * * http://pretty-rfc.herokuapp.com/RFC2616#status.codes * * @return bool Did we receive a 4xx or 5xx? */ public function hasErrors(): bool { return $this->code >= 400; } /** * @return bool */ public function hasBody(): bool { return !empty($this->body); } /** * 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 mixed (array|string|object) the response parse accordingly */ public function _parse(string $body) { // 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); } /** * Parse text headers from response into * array of key value pairs * @param string $headers raw headers * @return array parse headers */ public function _parseHeaders(string $headers): array { return Response\Headers::fromString($headers)->toArray(); } public function _parseCode(string $headers): int { $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"); } return (int) $parts[1]; } /** * After we've parse the headers, let's clean things * up a bit and treat some headers specially */ public function _interpretHeaders() { // Parse the Content-Type and charset $content_type = $this->headers['Content-Type'] ?? ''; $content_type = explode(';', $content_type); $this->content_type = $content_type[0]; if (count($content_type) == 2 && strpos($content_type[1], '=') !== false) { [$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 ($this->charset === null ) { $this->charset = substr($this->content_type, 5) === 'text/' ? 'iso-8859-1' : 'utf-8'; } // Is vendor type? Is personal type? if (strpos($this->content_type, '/') !== false) { [$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.'; } // Parent type (e.g. xml for application/vnd.github.message+xml) $this->parent_type = $this->content_type; if (strpos($this->content_type, '+') !== false) { [$vendor, $this->parent_type] = explode('+', $this->content_type, 2); $this->parent_type = Mime::getFullMime($this->parent_type); } } /** * @return string */ public function __toString(): string { return $this->raw_body; } } httpful-1.0.0/src/Httpful/Response/000077500000000000000000000000001461442417400172205ustar00rootroot00000000000000httpful-1.0.0/src/Httpful/Response/Headers.php000066400000000000000000000051431461442417400213070ustar00rootroot00000000000000headers = $headers; } /** * @param string $string * @return Headers */ public static function fromString($string) { $headers = preg_split("/(\r|\n)+/", $string, -1, \PREG_SPLIT_NO_EMPTY); $parse_headers = []; $headersCount = count($headers); for ($i = 1; $i < $headersCount; $i++) { [$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 */ public function offsetExists($offset): bool { return $this->getCaseInsensitive($offset) !== null; } /** * @param mixed $offset * @return mixed */ #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->getCaseInsensitive($offset); } /** * @param string $offset * @param string $value * @throws \Exception * @return never */ #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { throw new \Exception("Headers are read-only."); } /** * @param string $offset * @throws \Exception * @return never */ #[\ReturnTypeWillChange] public function offsetUnset($offset) { throw new \Exception("Headers are read-only."); } /** * @return int */ public function count(): int { return count($this->headers); } /** * @return array */ public function toArray() { return $this->headers; } private function getCaseInsensitive(string $key) { foreach ($this->headers as $header => $value) { if (strtolower($key) === strtolower($header)) { return $value; } } return null; } } httpful-1.0.0/tests/000077500000000000000000000000001461442417400143475ustar00rootroot00000000000000httpful-1.0.0/tests/Httpful/000077500000000000000000000000001461442417400157755ustar00rootroot00000000000000httpful-1.0.0/tests/Httpful/HttpfulTest.php000066400000000000000000000546131461442417400210050ustar00rootroot00000000000000 */ namespace Httpful\Test; require(dirname(__FILE__, 3) . '/bootstrap.php'); \Httpful\Bootstrap::init(); use Httpful\Httpful; use Httpful\Request; use Httpful\Mime; use Httpful\Http; use Httpful\Response; use Httpful\Handlers\JsonHandler; define('TEST_SERVER', \WEB_SERVER_HOST . ':' . \WEB_SERVER_PORT); class HttpfulTest extends \PHPUnit\Framework\TestCase { public const TEST_SERVER = TEST_SERVER; public const TEST_URL = 'http://127.0.0.1:8008'; public const TEST_URL_400 = 'http://127.0.0.1:8008/400'; public const SAMPLE_JSON_HEADER = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: keep-alive\r\nTransfer-Encoding: chunked\r\n"; public const SAMPLE_JSON_HEADER_LOWERCASE = "HTTP/2 200\r\ndate: Tue, 07 Jan 2020 09:11:21 GMT\r\ncontent-type: application/json\r\ncontent-length: 513\r\naccess-control-allow-origin: *\r\naccess-control-allow-methods: GET, POST, PUT, PATCH, DELETE\r\naccess-control-allow-headers: Authorization, Content-Type, Accept-Encoding, Cache-Control, DNT\r\ncache-control: private, must-revalidate\r\n"; public const SAMPLE_JSON_RESPONSE = '{"key":"value","object":{"key":"value"},"array":[1,2,3,4]}'; public const SAMPLE_CSV_HEADER = "HTTP/1.1 200 OK\r\nContent-Type: text/csvConnection: keep-alive\r\nTransfer-Encoding: chunked\r\n"; public const SAMPLE_CSV_RESPONSE = "Key1,Key2 Value1,Value2 \"40.0\",\"Forty\""; public const SAMPLE_XML_RESPONSE = '2a stringTRUE'; public const SAMPLE_XML_HEADER = "HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nConnection: keep-alive\r\nTransfer-Encoding: chunked\r\n"; public const SAMPLE_VENDOR_HEADER = "HTTP/1.1 200 OK\r\nContent-Type: application/vnd.nategood.message+xml\r\nConnection: keep-alive\r\nTransfer-Encoding: chunked\r\n"; public const SAMPLE_VENDOR_TYPE = "application/vnd.nategood.message+xml"; public const SAMPLE_MULTI_HEADER = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: keep-alive\r\nTransfer-Encoding: chunked\r\nX-My-Header:Value1\r\nX-My-Header:Value2\r\n"; function testInit() { $r = Request::init(); // Did we get a 'Request' object? $this->assertEquals(\Httpful\Request::class, get_class($r)); } 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('世界')); } function testMethods() { $valid_methods = ['get', 'post', 'delete', 'put', 'options', 'head']; $url = 'http://example.com/'; foreach ($valid_methods as $method) { $r = call_user_func([\Httpful\Request::class, $method], $url); $this->assertEquals(\Httpful\Request::class, get_class($r)); $this->assertEquals(strtoupper($method), $r->method); } } function testDefaults() { // Our current defaults are as follows $r = Request::init(); $this->assertEquals(Http::GET, $r->method); $this->assertFalse($r->strict_ssl); } 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')); // 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)); // 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)); } function testSettingStrictSsl() { $r = Request::init() ->withStrictSsl(); $this->assertTrue($r->strict_ssl); $r = Request::init() ->withoutStrictSsl(); $this->assertFalse($r->strict_ssl); } function testSendsAndExpectsType() { $r = Request::init() ->sendsAndExpectsType(Mime::JSON); $this->assertEquals(Mime::JSON, $r->expected_type); $this->assertEquals(Mime::JSON, $r->content_type); $r = Request::init() ->sendsAndExpectsType('html'); $this->assertEquals(Mime::HTML, $r->expected_type); $this->assertEquals(Mime::HTML, $r->content_type); $r = Request::init() ->sendsAndExpectsType('form'); $this->assertEquals(Mime::FORM, $r->expected_type); $this->assertEquals(Mime::FORM, $r->content_type); $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::init() ->sendsAndExpectsType(Mime::CSV); $this->assertEquals(Mime::CSV, $r->expected_type); $this->assertEquals(Mime::CSV, $r->content_type); } function testIni() { // Test setting defaults/templates // Create the template $template = Request::init() ->method(Http::POST) ->withStrictSsl() ->expectsType(Mime::HTML) ->sendsType(Mime::FORM); Request::ini($template); $r = Request::init(); $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); // 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')); Request::resetIni(); } function testAccept() { $r = Request::get('http://example.com/') ->expectsType(Mime::JSON); $this->assertEquals(Mime::JSON, $r->expected_type); $r->_curlPrep(); $this->assertStringContainsString('application/json', $r->raw_headers); } function testCustomAccept() { $accept = 'application/api-1.0+json'; $r = Request::get('http://example.com/') ->addHeader('Accept', $accept); $r->_curlPrep(); $this->assertStringContainsString($accept, $r->raw_headers); $this->assertEquals($accept, $r->headers['Accept']); } function testUserAgent() { $r = Request::get('http://example.com/') ->withUserAgent('ACME/1.2.3'); $this->assertArrayHasKey('User-Agent', $r->headers); $r->_curlPrep(); $this->assertStringContainsString('User-Agent: ACME/1.2.3', $r->raw_headers); $this->assertStringNotContainsString('User-Agent: HttpFul/1.0', $r->raw_headers); $r = Request::get('http://example.com/') ->withUserAgent(''); $this->assertArrayHasKey('User-Agent', $r->headers); $r->_curlPrep(); $this->assertStringContainsString('User-Agent:', $r->raw_headers); $this->assertStringNotContainsString('User-Agent: HttpFul/1.0', $r->raw_headers); } function testAuthSetup() { $username = 'nathan'; $password = 'opensesame'; $r = Request::get('http://example.com/') ->authenticateWith($username, $password); $this->assertEquals($username, $r->username); $this->assertEquals($password, $r->password); $this->assertTrue($r->hasBasicAuth()); } function testDigestAuthSetup() { $username = 'nathan'; $password = 'opensesame'; $r = Request::get('http://example.com/') ->authenticateWithDigest($username, $password); $this->assertEquals($username, $r->username); $this->assertEquals($password, $r->password); $this->assertTrue($r->hasDigestAuth()); } function testJsonResponseParse() { $req = Request::init()->sendsAndExpects(Mime::JSON); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); $this->assertEquals("value", $response->body->key); $this->assertEquals("value", $response->body->object->key); $this->assertIsArray( $response->body->array); $this->assertEquals(1, $response->body->array[0]); } function testJsonResponseParseLowercaseHeaders() { $req = Request::init(); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER_LOWERCASE, $req); $this->assertEquals("value", $response->body->key); $this->assertEquals("value", $response->body->object->key); $this->assertIsArray( $response->body->array); $this->assertEquals(1, $response->body->array[0]); } function testXMLResponseParse() { $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); $bool = array_shift($bools); $this->assertEquals("TRUE", (string) $bool); $ints = $sxe->xpath('/stdClass/arrayProp/array/k1/myClass/intProp'); // list( , $int ) = each($ints); $int = array_shift($ints); $this->assertEquals("2", (string) $int); $strings = $sxe->xpath('/stdClass/stringProp'); // list( , $string ) = each($strings); $string = array_shift($strings); $this->assertEquals("a string", (string) $string); } function testCsvResponseParse() { $req = Request::init()->sendsAndExpects(Mime::CSV); $response = new Response(self::SAMPLE_CSV_RESPONSE, self::SAMPLE_CSV_HEADER, $req); $this->assertEquals("Key1", $response->body[0][0]); $this->assertEquals("Value1", $response->body[1][0]); $this->assertIsString( $response->body[2][0]); $this->assertEquals("40.0", $response->body[2][0]); } function testParsingContentTypeCharset() { $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'); } function testParsingContentTypeUpload() { $req = Request::init(); $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'); } function testAttach() { $req = Request::init(); $testsPath = realpath(__DIR__ . DIRECTORY_SEPARATOR . '..'); $filename = $testsPath . DIRECTORY_SEPARATOR . 'test_image.jpg'; $req->attach(['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); } $this->assertEquals($req->content_type, Mime::UPLOAD); $this->assertEquals($req->serialize_payload_method, Request::SERIALIZE_PAYLOAD_NEVER); } function testIsUpload() { $req = Request::init(); $req->sendsType(Mime::UPLOAD); $this->assertTrue($req->isUpload()); } function testEmptyResponseParse() { $req = Request::init()->sendsAndExpects(Mime::JSON); $response = new Response("", self::SAMPLE_JSON_HEADER, $req); $this->assertEquals(null, $response->body); $reqXml = Request::init()->sendsAndExpects(Mime::XML); $responseXml = new Response("", self::SAMPLE_XML_HEADER, $reqXml); $this->assertEquals(null, $responseXml->body); } function testNoAutoParse() { $req = Request::init()->sendsAndExpects(Mime::JSON)->withoutAutoParsing(); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); $this->assertIsString( $response->body); $req = Request::init()->sendsAndExpects(Mime::JSON)->withAutoParsing(); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); $this->assertIsObject($response->body); } function testParseHeaders() { $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']); } function testRawHeaders() { $req = Request::init()->sendsAndExpects(Mime::JSON); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); $this->assertStringContainsString('Content-Type: application/json', $response->raw_headers); } function testHasErrors() { $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()); } function testWhenError() { $caught = false; try { Request::get('malformed:url') ->whenError(function($error) use(&$caught) { $caught = true; }) ->timeoutIn(0.1) ->send(); } catch (\Httpful\Exception\ConnectionErrorException $e) {} $this->assertTrue($caught); } function testBeforeSend() { $invoked = false; $changed = false; $self = $this; 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; } $this->assertTrue($invoked); $this->assertTrue($changed); } function test_parseCode() { $req = Request::init()->sendsAndExpects(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); } function testToString() { $req = Request::init()->sendsAndExpects(Mime::JSON); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); $this->assertEquals(self::SAMPLE_JSON_RESPONSE, (string)$response); } function test_parseHeaders() { $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'])); } function testMultiHeaders() { $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']); } function testDetectContentType() { $req = Request::init(); $response = new Response(self::SAMPLE_JSON_RESPONSE, self::SAMPLE_JSON_HEADER, $req); $this->assertEquals('application/json', $response->headers['Content-Type']); } function testMissingBodyContentType() { $body = 'A string'; $request = Request::post(HttpfulTest::TEST_URL, $body)->_curlPrep(); $this->assertEquals($body, $request->serialized_payload); } function testParentType() { // 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); // Make sure we still parsed as if it were plain old XML $this->assertEquals("Nathan", $response->body->name->__toString()); } function testMissingContentType() { // 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); $this->assertEquals("", $response->content_type); } function testCustomMimeRegistering() { // Register new mime type handler for "application/vnd.nategood.message+xml" Httpful::register(self::SAMPLE_VENDOR_TYPE, new DemoMimeHandler()); $this->assertTrue(Httpful::hasParserRegistered(self::SAMPLE_VENDOR_TYPE)); $request = Request::init(); $response = new Response('Nathan', self::SAMPLE_VENDOR_HEADER, $request); $this->assertEquals(self::SAMPLE_VENDOR_TYPE, $response->content_type); $this->assertEquals('custom parse', $response->body); } public function testShorthandMimeDefinition() { $r = Request::init()->expects('json'); $this->assertEquals(Mime::JSON, $r->expected_type); $r = Request::init()->expectsJson(); $this->assertEquals(Mime::JSON, $r->expected_type); } public function testOverrideXmlHandler() { // Lazy test... $prev = \Httpful\Httpful::get(\Httpful\Mime::XML); $this->assertEquals($prev, new \Httpful\Handlers\XmlHandler()); $conf = ['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); } public function testHasProxyWithoutProxy() { $r = Request::get('someUrl'); $this->assertFalse($r->hasProxy()); } public function testHasProxyWithProxy() { $r = Request::get('some_other_url'); $r->useProxy('proxy.com'); $this->assertTrue($r->hasProxy()); } public function testHasProxyWithEnvironmentProxy() { putenv('http_proxy=http://127.0.0.1:300/'); $r = Request::get('some_other_url'); $this->assertTrue($r->hasProxy()); } public function testParseJSON() { $handler = new JsonHandler(); $bodies = [ 'foo', [], ['foo', 'bar'], null ]; foreach ($bodies as $body) { $this->assertEquals($body, $handler->parse(json_encode($body))); } try { $result = $handler->parse('invalid{json'); } catch (\Httpful\Exception\JsonParseException $e) { $this->assertEquals('Unable to parse response as JSON: ' . json_last_error_msg(), $e->getMessage()); return; } $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(...); // } // } } class DemoMimeHandler extends \Httpful\Handlers\MimeHandlerAdapter { public function parse($body) { return 'custom parse'; } } httpful-1.0.0/tests/Httpful/requestTest.php000066400000000000000000000011171461442417400210360ustar00rootroot00000000000000 */ namespace Httpful\Test; class requestTest extends \PHPUnit\Framework\TestCase { /** * @author Nick Fox */ public function testGet_InvalidURL() { // Silence the default logger via whenError override $caught = false; try { \Httpful\Request::get('unavailable.url')->whenError(function($error) {})->send(); } catch (\Httpful\Exception\ConnectionErrorException $e) { $caught = true; } $this->assertTrue($caught); } } httpful-1.0.0/tests/bootstrap-server.php000066400000000000000000000024611461442417400204040ustar00rootroot00000000000000./server.log 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 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() use ($pid) { // cleanup after ourselves -- remove log file, shut down server unlink("./server.log"); posix_kill($pid, \SIGKILL); }); } httpful-1.0.0/tests/phpunit.xml000066400000000000000000000014371461442417400165650ustar00rootroot00000000000000 . ../src httpful-1.0.0/tests/static/000077500000000000000000000000001461442417400156365ustar00rootroot00000000000000httpful-1.0.0/tests/static/test.json000066400000000000000000000000351461442417400175060ustar00rootroot00000000000000{"foo": "bar", "baz": false} httpful-1.0.0/tests/test_image.jpg000066400000000000000000001466721461442417400172120ustar00rootroot00000000000000JFIF;CREATOR: gd-jpeg v1.0 (using IJG JPEG v62), quality = 70 C    #%$""!&+7/&)4)!"0A149;>>>%.DIC;C  ;("(;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?ޓ8.F3YX1 Cu;ޣT-+n™ґۓMVcM㹥$1#@3O 뚒u#~kM̹w=;\0 v r?Z GTKb% g>P9`V\!3r Úlp䓓W}GaUmt''4]3NrsqM6NOJMGD?$Q~|tӳ 7h,âJ$=hFtѼt)8oƝiot?*1h(RAGZi`N{SaTgf&#AJi) 1!N G'Nkq0CGAlyr'4G1Ϸ[{a4bX3OB^ƚ%ŷjH/pΧA>>`#LܯʉrF=G4b1ہSq|BnIcUVFxCTa9 GX*&֛{Kbɝŏa%\}\׷Q ޖY-VeC|#̠oj$ܾdc'$297r݌9LSnG^ٵӎ]ѳFbRn5 7 :&6AkR[ Et4IR8K&yJW# z($ FT-w =EImUmb[㶛 s<ɛ8?<Ps3vjޟonF:~f4 pOe+&z\$}4bʣF_VnSElGys9ۑ6K{SiVIBy- 1YY%PAⰔхIZCu_(SnOZH Ww7-0#N7'HĺM-nsD"w3<Fpj@hݲ<86V*GZ˳\wvd͗oW-EUfAvuZ1_)9EW!V_UX҈GG1(lS2$QcsxOlVH܃n= ‘z N᝹ĐJ:Rhi\vA(#EtsJwPE 4*78)N#Xjli=3MN *Ql%).H ; W3n4`n&9c[8>G=}(rN=j1}M4oQ,yۅD*i\GQ[AKNx|֬y GcJQp.b ׽FeO2>=(BCqY3>$zП)4$ 3ۥ+8-P9JFqR6#jF.O$vD ԶAiEwe}u̷j?Ȥ#vB`N4gvNkOOǑZNsUrOGr&e@ILqڭ["anOj!UFIꈩNTی o;sZ[# *I )T|Aҏ,nȧr8MV<$hJxlL58v;Wީ02:Ľ\⺋+8-UsqɬO6;֫fjvS=1]E8 Cީi1P8$e1Or#`7qܕ. Mc4i* S,VoJYAe6jcy \nun74=IEEN$&XHwʶt"FT+ vC =9[i=2E#[tf43$Hiz\,!J޸Y8_,@;U)hFݟ*܎fc0`)5ֺ=N(;}뜍\nf~ni)h,OŠ^%j(6j*U8QN/Vg5rzT APzu3 pw5*X ~+WE=ǭ5WT(bE dS©=Mo9;sOF8(%OJ~8-SA.)qHlBaCROqR㊍Cv=EJš 9AA4$[CopVۜ*.VP#gzѠ/jC<9wB%{qU$m_^.UYn`.UrONqKl**'b t$RQnZ!sC S_@G9ݍiehp{͠mpyVv2~d~ˡu,ߨ>PIthL%[d (AYcU0[dJ = 4矔UC)j\O4aެ@9隆D<+D1qh-WLZEoNyp$c\I"* ;җbn9<=i֍nd7чT+&Gc2ڍ-,q/>b͹M6u ҏ2VvD Nv+MfگWAb-R=9?JҬmPB(fs@vƀjkk[-XrCsVtkF\azטdzrIu.U/`Zl,_lEıp)lt_)'уV-V{g̅#<)r#[vՀ@fF-A>P#eDq zU*jp塹_r2Mͼs<1>U%u=KH4 "m/70orYSNKm:YnzJE9bK[}l=1U.c-^nڰcVy2ܜ-Nc*E2@c+pO_VO Ir.o w +YyU]$jq3MKi|hzzVdq1qB^GbPS"FDzE+'=F)c3֣dj&/x՚`n$jOՋ a/QUq2~Bh[@Fv77VrOrҷbIջ$ci#v(r=iӸ"r2:UGޙ⮮aRJ-"Ep껌uC1^\Ed< @Q%8IiZ[y}B)vTw9'ʞgS@lI$bLњњc0PIEN*[%"* w2EX[RPҿ*fh+=Mtf;p'WihQFNKm*tRzי}'HmmfwEV,2sk_"&pYxV 31I0+ϝ F*«wR-菵v\`ZYȟd]~5[mb L6~^M y?NGXSé_~}.m[m r|'-'b8%rd'\"ha?"|W5(X)_qWVM;;ЕKNK]D4XBy=ϽTkK^$\F޹ŃI2+ʳl{~%Z%&a64 hw:MOQO8b`P҅L~t{ɮv42^Y܂KƶWOJpڨ#|[GjOƮ8Z?QQJy$pګdqsYs$%I d!Ayoq,rnqUo|r"$(0X7.ƋwR#9)DK>xYZ灠FZ6ifF.fӫ \u#RZZ71 ZwJ}ݙ=ꫵv|Ĺbڋ$g;Egh撊Ot^qR_]GW<4vGl/,AW6NsQr#9fV!jVO݀2T~Lcd´H D-Q8)2h$)0F0iGZnh +㿕.iI\ɣ43@5qPX9CeSQmM9UvLN9K9#JW)r)0Ԋ3wTw{j`>M_Bͼ1G;I[͹US+ F}+W-.' #XʠzAY~*H;@EY5w$F27@A8$/Qa[8ҐG84Xw\C_ۉ@/(;xJʍvsҖG'ټdHd;UQ!h9jqaAG$9wv"miЊt-a&Fxh`ES)k#{Nhi3LQP7b8 Uzg;qBA^ բH2O'X4Jv ;+s/'eΛqv@//0_\`FtQHY9 >kF1]jTUv5~[@|3srcR|2ќiA0 KGw5ռGCpxZ&h*H:kW_y֩CG7M4Sܰ cTMG \cɮQؾCeE\!U@j7ֳ[wfa'TY]W 5g\hQ"8 {Osr\,3t]|g@|!\pMc޲s%]Āռq5d}D*9at[Jɽr9L{ߴ {@Hv+18ҽL%INs<&ؙw.F)%Oj f@5O1LdEҼJ׊I>=Rc=Q:&М[)kUR#g٫J`?JƎzgTAuyFGLUk\ƊrMkE;mP;d<8/V!ϧIӯSR1i&ihL@O֌H4@[ޛ'GJ?AbaG8.]ݺhΗAHZhb[#Ԋ~lVc8 @ -"иcZ"#>بna8A #T Ç'ῂ5U,hԎ,IMcZc -*9=Ep)3̇TF~Vp7ҳgW/ZU@8ELW"yQuT!;'H(d##`%FSUu /6yQI‘OzSK'$`ʜԢ@cP 13?+ʐ8N'4 *B7؆U̜}*Dsr=ܣֆVR`sҝPiE0$R)zT7`XcҖ+gb6I2H9$"m*Zw&STޙJ>IVIFh@ )1}G uE AWX4L]C N >ͥIbzknSm:LNȢfQ\A-<⤛{~!Opֽwui;q udW}v5ϹX}6Kem&%M&@\h^6gOMz'EXBsm RM &{]Ann!m8S>7 NYLyI<_mt_w¿jY/nX0ؚ >gixvS$z9eIxkac9W>kKܷz`dTُ}qR3+=_+0ZsxȧuUcԚ? ӹRֶ2ߋw-mBX}˕o#2"X~^uNw>ukD1=Mc\ӣ>d*+0=S5< 9oQ ǂu}^OELiާSVGʚ?q3[&5Y.J'{ApjrNǃ9#~G\XF~^՞+g7%u'RiKFϊt/"}7}P8ںA4FH(sڼ^/?v\n#ӽu#౎}2 rsk.0u9Uu?Ty'H7W|BԬ*E`#܅lyHkU2XK]9N''#[a w烚/O֏x5\1[eܒn zSAgU{іϨ;՟Av5{VTM,)\w}( 3KaJ78GZ\u֛hGԗjr89hV#֦!TYR1 LK6KD;뫵($ WD.Y[b5dzxk(]̑xOXR6 ϰkV5ߑVg[VDF"jyp8TQRz5@\c 88V#i85!b֫a2),g?+ִU*Z<:Z䥉qMI5JLfZVX8 do ?1'y־Xyg`LޖXʿSo[^x],㹱,+zZԭHQt5i&ӹF,=AdzWc\@ ԊUv CnԛOӤDb]\zM_Wв 3nI:ƍx֗7WC*ae=Fsۚ+k>-Y)$2sԭ$[|E#.濆;y: +Vwz\^*$[v(ڪ}p;+_fBH%)8^t֞$5tѣ-JqkQlF;?nKnnFAT/cj3'B+65լ:5e 3\ONktF[ZP9jɈ[6 $ Wem?JEYK>Cq`3x$47K$I6*ӧ[9p嫥̛)-"s%¢qj[KڤpJm b$فI&9Z)"-럭2aM׈Gvn0ǵh9M%){Y3n;jD~Da9|eAW?3OdqNTfw ֌Ib5)yʞ++a5ymFsM1-y&V7GAQ2Ohr}֌r/sQMj ګ}NI]A)Aro >y#w:+P3E+۩\at1ӊֲbܞVBԶq9\us7l%* uq 8$]ʲόǵ2G&C8歱դC8.O)jq<ޚO5% ۓ'Ҟp&s`U\&"$OqR=6$zJNM F,ۢrEkZ˹֩`tFitelNX z&Q'[S@{ғɻ$fM&i3Jøϵt 3GUߌԹjؘzFzhrl(4I3@ Fi3@Ƞ zl =ڇ8EJ X/t9tɮ'pV}nYER2ac-8 [Oa|˒X{~vKǿ2vc t~a@4p*Mas]kkLsU*AHleI$g=21\Sppr1G*#ql\Z2@9& ,$p6v>yE愭} ̪rȪOŸXG$|{m3D ְewam AJ]5hz4+)/{CgX˂@>;U6 ޻"tc9iC_oZ2>w^jZwG^2JP^ONj1! SD3F*`==+эY}4ۻZ[I3cU iKasF_8W*ݫdž-`IG2 f,ZԕtɞkuZN^gHmژ¬1*@qiyW=bYJKn͠LGZF NYnn>cµmc"Oʸr>\itud\ӧ=-ky~'}D|S.4}"5&^8ߎEoI-GKQ=kFdsynXʊӏZndF5p}kiAEucl4Rku)I,i5ĎAÛȖH޳ c(\mZXLd?wt[Q A~#p8*=3zdv*| X(W*Qg '?2D\ < < 7@3KN9ӳKy穯.n<UyEIP=(]JMm (Ue ]$n^0pi[: kISN7L*|ϛKxuGTy).rqMJI@ '֬{rcEHac;M\b!*97BrTԞjİO.+ @?¬^@_rNJK S\۵½"K&@7ߍ1OZc}a9 5sE?'mw.J{ZC䪓;sp)$01o~εu-[-|-qjRMj"$ a)jub_Rk^hd+մԞJKr>|]nrm*ty"`*XȶpmNƖ&C6Cxk+JS1j0$swi[iw2]fkVf5*{uE{7rVgocΡ=s=YFvpYEd~;h]BuY5COXVGo-2g|j9ŞvB8LWw LXQF#Zs*K"gi7 >UnΓqT]uz֓}k9Qᵤv? DkSG-_Wdċ%m|ysvboO(>$ػZ\ʾHo嶑1 85].}&S[WckN{$J5x*\`WsJ$%4o!mjH̜Y1nW#rx䟭wJ7d }?wFާұBl&!@ 5wGVi&tC#A,JAʾ;¶`jϒ3h+Do;ncdc8uzȸ;Z~<٧FxR= nZiQK`nu5u< ƺ0[K}3ަrPzF- [vK "*Əuv@8y6UEE.3"CNӭRܥ/%'T̮E&{{dBWbxy #uQkY;mԕhhV=1\6*rrnzR5&C~4\9tu9Uieo)8͎瑚YͩaҒШ;;m<y;Zc9;9ivA'd}g "~bޝ) xĦN3WޙSN֞t.9ZW 14زt4K@/QsFi4cހ]9i:g/Ev-IR`bu!a]A49v;4hWL~4qEvhznh &M&i8& ROz_Ɓ&-=Mњ/Ji T Pm)G4ӊ{v4`3Gbtюh܊Uax'OH rq%{EJp=nkiKfq}Mry(LQQgT/ib&v.sJIWeݝۉϩnڍ^IX d4v.%})eXDH iVLnǧm- 7F~&AelXEK[`"yK8,yel ~uxV×Vmr  GkSM]OtV]5r=;ռ < %Qar܎H9ѵ{;A6@|:sZC8B9L|UƔuM}^jJ2+_Y.(!ܱ\2oى'xX`?uZ}J_8m4-P,ûx_ &a+Yyi$!7|=ON~[-IuEN n?:e&y&xOoo^'D*% f}NƘu' }GZ  .>Ìn}ƣKyҢR` @"ɮxy`*:U2?$C8*vYl˩NU6X)ĽXL8B4J(( |԰\2?B}FTv5G)pDZ&vr(4i Fd\u*6@ڹZh\h,uG'58C95D&'3rF#tRSJZU=)hx5d6 ASPzqR_AKSwFnjTlRp2i 8H<Ԫq\ S0Qz`/=1HR 曂1@G= cJF`;;qU,9H:9;(&U1IQeb3Үu iw&Pt(I98ݎ;n°Zn6n"%az_Pj72xM2G3ɜ3ҵ`UV_#iц10i:&U<-tv!]4bh JOc˦T0 Ӭf%895j"9L\;Vg,hCX*Ut&umel؃W;A|A"d{a1$x*iR\uZn6&im  G+U9HuPZq2"T*F=j#hP&bGƲMkx`.1^EV)AVe*AաodcE 'f W7,OĒǸrdNm.Xd >᳿c{Jݍ-?y qUJ 8-IT!!G)+$Ҋz/u=6 3QVA2J1` 9?ZG]O~f8 c8#?S趣K;EVv5rAs.}ny`2ljhw]rъ]I] Ϳ$[vܖOu(g^kv#vq]yzs N}i.9"@GZ))nWlR94ji59+:Ҽ ޞ5%3 1ⵕXX.>AE \;q\Rθ'7%:^%$(ҷ.C/͞o3[^i\2# N9ꦶ)C3YƌeMOdg:.ٸUrך #c# CtO.2y{x$kiYs$}9?TPn6u5jm$ץA[2J2Mu SW ة?2o3VCFj* 5g|{qޕMh"-.NjuHaVIEȗKz/lv/ue?u\ڕ6Hr蹕9+6+钇O]oh{'7ގ)=\ԯ{vAGNzBDG9)9tn#7|-m4b2a,Rz{BNzh2xclbubn;Q< VYC)`yi*.̰X8iqE7zSs jʑ!ҝK/[P5STJr34!LoSRB})YXa[8{Uwy)'NAɦ$?xT((;JZ ۓ9]sr6Y;TG4;VHxQ 8Z)Hr*.jdxXe1(z篩!c<x={Ӏ۹v4TnFz&>b}GCHfod;P-sÚLZ))u޴ oe86@hAv$g q*W ~'~u"I1]ƓcZU`75lj #˰9(,+<\ )x埍1[L0/ l0Nv:~v\lT_@1^j&}z(Ww8?j:h lN:Y2{C,% pqkIgB*mf6Ѽ)$6$#wIqlge'1JI1nyyWpJkc=1>lx!##Q2M^jA5 bm]R>v8f*ota- g>~xv Ȩ,ElJҐ*IԜ~iZbN6).@vn4 `nq]ItA`ƮKt} @8y(vvm9b:T46DK LW!.cLcgEQY ԋ3~OL U!lmuJ`RW꣭oѭ\@(S'?7+c]3$ ;6Mc=&e|ox_O<#H!@w3A?`v,w"qz'C☵kG8ɬko"-RT}ֲڨ݌eQn ''z78cHCepϭtP1bK"\wR'~hvWIJlIQ1SdvAϠx;XՐ=p?:KN[[՟ p#]iNv#Qy yZ"i|0a]ޔ~Uus.1Tq-)q%zzUʊxv\[X'$֕.bxe8Kp6?G`rJ.y,H5sn]ًF;9uW*{Oʙb\g/CF5 2K}?c Ҹaƹ9g{wEBHiҫ#WszzE QrI!)ڸ9䚱4vwVR2A%TqD#c 0V֕a)7.M\(9JiFiV#Q9S$ǁOVe~sSCn,Q]g)#Zpi#šiΕ2|8Io$3墕 ikn;Dh"{y#ZCsTxXƜznb= enYDYXz=0 z$0ďʐ8ұfd#DtQ!?Ң2tᱷq71y: f9u3[Gj>g!0H?\ _k$J//'=+RZEnįo#y70^VgRFB$F{Yr ZZ"VPq 8PU߃ǽ/]o,sJꋴJ'x#I.C$0@O8ä7rpQHFGU?Eogo&Pp"[C5gnI=3VXqzH.9tz.VgEwOu6'ڝYEw~ߧZ)}@,P)%Zeډ n݄\u9PMG ЭY6O2_mB+xF:LI` }+sE+SӠ,N[T|["ߓٳ $,8U]cl=>"q߲ ^ h"\ެڏBB.0gҖB"+]TzV#Z} SZQJ$Fryt4)jNn.-ϝ0z{@7 :B_NXI $#RikR "i0^$ j |'EK/ E]XM+F":jZS{ j7l)+)nqG;S\#"X*̍Blud\&8֣|EmxntŜ` |mnELgy8)!-ڮ"Dx_JF$N;wi(%EdtȚn,xdKOL+yt[ʐ A2n1O wF뚊I5Kŕ}>VRz_3"PZdlr=W׵ktEqw7SӰha4揺+IZ< ѕ#ϻ4R 掔q"⤍ԀrԘpZd=1G*i&sǥ[N&tU Bޏ4Q>^=O57]Alv*MPvd?Ȫǣ 3tu?gI_UU ?Oz [hF_5j)I(T'O&AΪoJeSGl*$ղ29$@g:I ں*Xrb'+)A'.L[\V?0995v6[8>yEe$^~\u>{Z).+{Ix. z<K1iF!O(`){s*M=|ԭD'9=Tm^m.;n>qw %1iЅ0aQEkI9)9ǽrC2Y 1k1jiWw?ufFU=:cM$ьW Vo,5V(DB ϭXq$ 0V.Y.}3J)<=+s¶i7z璠0uTlvoNmIbwڥv2yd4܊ǂ#ZiA/2?;@-N+6C6R9ۜcVx\UxB泝ԗc[K1yF$#85kzh};cj#=9G,2F -oᴼiW Y:dh+bgducmw*iaTW[\=Z5{aI^^^G%̒6{Sjܨ:elu RʻP9O~&2Dh 䂠s1ZꠣNATFj9zCqy2[#K$6"f=̆;hN`1^O8)te^W9degCPo4mz92ozܴiLgi6#WM9+ H-}M*yۄBhF#Y+t$/$eGMQnT¯i.hؾYWv5QF/dP3 vӚA#=j*ę.`F5"լf ؝ )n-WVhgw}E ל,˻ @j⬈S ~JlQܔ!lqFx858`ǡ# ܫu LMiefSI)FEL{K,8*mK^4Ӛ8 EU+nbNҴ~-x&}KM. HP9e6er bZE{KPVtTws -*z7&y&.X LF10T+f;W,.= ՛ (-otB;Vt77p}Ej3O`dY8ecȮy꯹ [uLl0pt[N;M2d`0Z3&ɓ~yMҩ(yJj]-0fi.]ZVwrrXi[ '@L~kv=kM&&NSt-(9?ʡ~r{|I :Z [NWr7'GBr@U +pB\8E(T vњʟZmt-n[>DSbZ{A>2(3k#ݰc u>[Z*T9kXkl[l#\rւSУFjɝ)JTR }1OZ+Z4KIt{o.%TW}=k6?x*@DAu-ȕ ehS3Ҹ"B&nHoԧ%%d_5_d9&yFe{, :zRYj1'dh:AH亗kc'J0)R)Ic:7CʯtEgk $YPꚛ:TBl6N#ֻE漧EOhrE9bẁk-G%m~xDZ.a˞=qZ+۽U9%GiW+9)ҋE5wȳ תgQ5Y|=; #pq,F}0:ڴSZۮۣΪTZђ$dd27UaRxqˈe9ӷ3QwWv x5ɣ%ts]cE#yWx1D`xnFU$kYe-pVl$xڎ6-.)l9~fkqo''nTr=kxݘ2sY*[feT޹3ֹ+%rvva5x5@&`Fz5\YSV.vn$JhęXpi9*rNz:NmcL4[(Mwkp%PNEVѴ;ŸhXyյek`ֱ4m QJIɽA\Զbx%~${zƧp%0s-j"E9mBZODJb}z11(nW=k{-"r$zU H9Ypx"\ Z* HֳӮ!pTmpzr8* zG3 یE$q@!̌H*<5X{6ɚYHTVfjZNv7OIƄF̙9ʜTW%K--bRS[5㩪n씮BhB+F lǜ_MSpq f̮_-it q X`-keԤܸb#NX|ig렄gKPp?OgM('_j2;| >犒;֪s0knTJp^2+$}3zk| EgC.'J8g4{J\c)\4 a͵(ȷ&i1 [#*kuRyrO{)Y4ۆQvۜ^COݾ;YNُra"0);7)99O}½C|r\b8rc|J \]kv67q u6ES[^cd򆑎6Nλ+Tucffe}ǁJ޼49[i|1qq.u/B8 ~$i7̶=dgR-ThRm[L6ai #z1@d$}r+R6Zxٮ&8z[n0CF~b3nw]=ծC'1N2,6FڵP:\ƭ5)iLK)9#YO{,bI51 jڞfcU1KIj{ xdAi5|LZI,!hxؗfxyP9Y60:5}J7Se?WrZu5Ǘ)%AJ㨔]B4CWWitflX}8IYb[V<稨Xm gO݇-$iQ.€[ !\r_Röq4#z]1Uhª`}+9jGIS8 8ۏ5h> a MJw +XkrǰZcjsP+ bKFtG/R2u ܲ.nF̓GVJsں/f~Y0Q;Xr[KQC0lgC j_iTfKI2kxƭ'BS,x'$$Ԉp+շSCI㩪ֵn xVcI57Y-'jQ@ (@)@wrM4FFk1hwz)gnmoJײ\hڪڑןi34tbvG'y ?gH?xZS%<\β~z޷35<9dnu$f\g'^hrq3mKpIJUߴ,Tpъ I<\^ufcQ^k|G??渭FXНo$q~aܦmR!Ost]&'#rG }b\ΪT`k8fT@ CJ[bnrH`HҒbc(*Zo|,rOz5;b&[wzռ=wVP)Mv-&eZa ӧKy>*KPF9R2)]Z)eba(3])Gb{^g-I-%L6:5nI Ew:"vBOاtd+Fp :[5$ӳ>(RSVWYyҪ C2;=?Um\w ^zGo,鸢󯈚T!BFQUW +v# Bb~NNR5ۿzR5.kYd @*o&~o.%BE~j觍g/[Dm+S[E8Aq3pp&U5 &HK@˂z"OI-.}+|d` `C7#0@prwJr2nS7QIt_٬дucqBq#J^5ǺY,C:GɑNktơ. 1yt\nU[+5$a5͔P-FsPq\E ̀H?eOKKK|ĜҺN:7$䶳:qO-hB,xa~I,$evQ1~Y<7`n `rJ--mm1Ϟ~aņt-fToT#jKc>oT ұa/bp鑐+ Z\%DN=-`-$WmNjѓz"q2\O(⨉Ʉ&b@%ƒ ۟= RK9{͓ m94\z皯rWʉji?hG#풼Ny =jT5'Gm%֭EqgěOuN*\SJCNcaN9#2Ǘ'j8]Viwd: NU.m Rܶ*?AWBV` uIDVi*#20:׶w{( 7TyhPIv65~Meݟ# &lZΩ "sȫZnѠdyeL'QQH4ʒa2aGG ~xڴouw$YZB̻6}WGJkam $N@'v*a,vMߙMb-9ia&}3PI+m 8bVeO_ƺa5vcRpItt*Ԣ#' knT9uW?S@2՘t,>lpH7 >|8IueZiGkuZ+$9W⦒v$=X[*h9t&5,nX#SOd?'ڙz.GWi[?w%SS]$O ' jOk@ s?RAɦ/.夽;qqW!rXŭ@ꧨ(gܹx[ݞ5~氈M1Vbg[驯kV^¯eƯjl2|@tH"H#yirUz~-\Έ;a 3]{?_\$I:T=wПQ]_b;"<_-2EjnG>9~w;ٓ|.࣮ c!#k~&7b@2(l!8p+Efg7Ё}w5ۘ}A馜 I>qk+4pێG7]LMwDF!7p9G\47V~6"?<8UcA~{G*Xc֧)< {G5FG@1A#ךw q1u ǚ rB3'uְfk$y4TRIKB*##5zK] '?mta9Rg%HNsK ~Mҗ PsJiP* d)pzˆd bG 5Ŭ o F9-}wvz،B'8f/|e;ll\Kөؔ~{kF m8> XC{WKOȽ  `ۑMxxU9@gbK ċD`M=TAER;sZn7cU5mM$vZwmu8좜(i0%*x^~}=<1w (L\80s5aW>m9'MմdypBaRMFf_j:vl6&•sNlA)gu-5[Ri$W|6qyky,$ JjvVJ?cFNls(J?*+7F3gf9m",yhr@?@mR['4&qI\}K)2BxkQy# 5Gi{>OJ Y+j<xޢ1QmW3( ړfc)i>mZDG`*A OًyxYYRrRb .;Huj+xZڥAk[^ln$\aNDVr}0gԷ3-ضNoqE܊G2K1b{K+1V!F09Vp&=t;hpVqvop@!x?*M>O*;Cjk,Ou^!#Vj|JG:ġr"R}5By*k{f UGͫϛm+Ejk7mX40PJ6$RxKtRn"F9gy0XQB~[Iy. bU+S<~UNjRhƜׯm Il+$c[v 5&s0Hw޼ziבϵSd6hjH1\ߏ4[x9]͠$FpC (eqc+B)c%1j*3HkYDЭ';O3p42V5Fd:I4+yI`iUAC +kў5/T|=&=V VnNӠ)#*@e6юt%ԵT6"}I6[z1N{v4׳].[Icg"IT܃zRI+n=:Pn6ʯ4*wx2 Jl֪eed Eu~mk;#|ϱrӚ=>H_=v4كíK ڡHTx@n}OY sE$SEP9ޓ\EnґC(,vṘ3fjӨި(Xݻ1۸= |:x^TFrA~V2kDݡeWB_ղ3\3GU҃[K{{Mȵ }9m4h(WW- \c?SN{_2kf]+c -m?($?[{7N:>yV^c37pI\B{ֺdLZ t8r>2;|?8uT*0/8y: &in-F=1\ 2E][xGjPx[m}{3{2IX2^㴚 4ۣHެ?;PF(?NQR5sGaR{˶qpIrƫ$BMƠu{WK].Hv2iCϔƉog,sb9sM)NdN)<?TOdvƇu#@Jeӱ7)zQ"4,m9CiAVwGΣ䝹m$bRveQ)Dԕ)ߡ,+ڜ$y=dyb22ŜY{+#8=\+R5& }*!l˃YNiw3..YޕB0BBI#ҳY -,r)W(%Dgv+9ⳞSSҴ[_jIU' ij:?(,g ;P7 =j%FM/a?4W^(`.3ykCцD63#Hj+ͅ<޴4KI/<6)$Ud%(ڂ>c1pߚ([ AZoVD$ ݧ7mϥjJ49lVGpQКbR ZvjŽL7^jv^ IPA#\R~;I8JHAllXwn3X٭asŗg BTuQmhʡX@már@NGdo08ȃXLE(l3ֵ!]BPw<4%Me1'=zcʣuS}q>v6n8m.V5 9ֱFq+Vee[;M0GP\ߐ*/9zvj۠ڀXҤ PurBvkn+IhOVPd~:ҥr.(@ +s[bF1 VS2GI&TBsk9IMEuuw, HxNNk/ZZ\0LuL~m[:щb:~S_{mWY`"7^Yu +iJ.sof#?\9L/tf\m\Zv.vXVo] zݫo׾*=Nsv=ٞd i:MޣL|vRfޘW_blPr<녩hؤ9~-)@9p^֭$>Y zҫLF$WIZ ^%+{I4?2#向 #V[VUfeTep3~usu >%o5tfI5*Tjp+#iM^.6T[\{ZEaь2f\pzg,-4mf\uQQܯء*E(8*N7&MIPFKX?@e:>|dW[8V6?v>\Ւ/>R (4zR(Ԁ⤴Jo4l he#C Sz+6E HGZ^# E$I7\l]'~b;+NA&[zԺwoE:?ά MbC7|֝f,tFW- gȫе+uhdMz kL,2ﴻ9G+6]uäj;+f *}kkSİ,2ڥک򦋀ֳ9vm r#k ֹ,T>\ri1ltDMs'@yol"q|f+Rr3T%,4!nZFӌfҕETDʒISO+roʾgBF]Kƍ^8c=.<7͍J{$>ԭ\ROlsٹcW$o!}.Ba+wj.ie.1>⬒tfbk1읤v$w;e4uOKF1k7‘Ͻz.ޱ-\WZ&_&y9⸨ج=+VMxٴQgOFi{߂tk]GSԄ{FUU?$@ itۀ. Pұ˜KqutȸףzKZ-$ oSXVpw$(=n3 l]j9nP!?B*aYY*ib\YNdapsS&"\1!Q5=u$g# v첋TǡYw0(ib3*q\U+խL}sgִ&:PvHJu2x GirE0hLꖊ9⯢+us'M#H<Qmϡ?ʹGN5 BJnճ N~|q\tMh^]&t*N}+ `@l n W!Bc+2.5hTR$EBzvD0Ell;llayr;I!9Nn1YE7#1lv~mtٰYK G9eb ]ͷ#=j8^V3$dqt>+KCy?rfٍ~rp ZmA 8RbTUr'&死EVA#,Qm`V-ӳ=+NᲄrzPEɌ $^涼:sƿvI{om :bcҖAi'/^|iȠ&P=OD`L16q#>q8Tr [ \i8NIk ɉO> y؆,cό3nʻ#ݬiw9?U{w9`xrnֵOG8Ҳg\ږh|1 3)+ [;]^( ю5 4Ҳk9b~\Ue('orXK`iSH੬JG]M+Xۅ<|-"4R`AEt62 -VdLc  X-7>\Kq-6CIp1敎z\Ԍ>#zo5h#N)eTS%FOܕMY4S0xavX_ot@|;dUB53k}Ѓ?g;C(F ޱƧ;d U'٤+ϩ%JM$R*WE9o(88'Ұ[ %u*d'=}MC5:|5*)$>R֦Bc`斁n(4B3@z3M-鴠4*)5Ip84az+t9N"vrqx+sUCᾔGjBJڵe7iob-.:~}?J Y = DH- 2G*GpEsƞ,/#o&6Q哟z/gbls>1TX]FT7GG&c>[-v3XYU5\Tr_t3v9bfbx1b.rzCܓkY~R2Nғ" GUn5ݱAN+6g+3F0GJգOfrƮx`!;Yz0;.t$m}iN)!Qt_O{s,$}֟\Ȗn(N"2_*hpJ8K#.gv&˸fN-*J#t G3֯4xaXD$/i?\鱐83֯H6n%fm,ICy"`6Nf@=*dJwz b*%]JqcQ¨$sBm R$\nQj6 ؜([)N;=GκnpA3Vn1<&7bOOr}v@qhUur6Me6B/fSIWPyt!iS_ҮKx@IsV"[xM>R<_O=]ХJ=^$t>G-+A^R Ā[OxHa[hS(Oji/CZbijrMdۊ[5=HF2dZ]LcjΐXZSAȹW|Ă>.%ơ!ӵiԉI-?nOIuUvr,\1>q%Z>IXH<˚`AZIkιy"{A[ڬ8\3SZ"W[VGN;֌JVi#ǧ?j!҃xМ\idl%rj9p"5 ȟ{<\k+kSXH[2Q{HdǕ#zT5Cl14xJ\LCils,B 9nx[hb0E!=o݅iA+B,@1ֲMJ+HOoJdC"82mm4`P{BxL9XD)Fyt 34c5myA֬"0}jis2ؾkJ"ΦIZJ ݽNY먽 @V-~&/sQIߩZ] [60=2HC\ur?CY uIY $)!dǛUק-j, ѳ=çK$h,y7`qVjęfhq3wgJ3I@_ŠJ?-( V"=갩T%SV@z"yg=$lwR>F~^MUFVe$j[kvO<|q@=smpKCV^Au!Ůfw\(_Abh9SNk[P/FzkBk+6|Wd_¹;1vѱ23^$VO<36b>^h1f\ ƒf!22p5ai>tsN;TpG>s=3Nީ x$ɷ>3xΧm\=+EQ jt|(s[#ǵIHs08;ZGnQyR1VHФKsɩcIM>n^)%Z!,Iq[W'x0Wo@A9m]#~{ոmB$LuxIp (L=}k!w /nYce;/s3=+zIXn{K;ߴgRZrM@}FW~Ɵ;[=xm>[1&Iv+2CMB$VPV49U'<.^*rszOmG${IiCl|KdU pxJl1‘$Qqu(e,N3UQ -F[gB܎[*tV>gҒ1J^Q(hȤ>RRh( 8gҦpxG5&EhaZV|2AYI3X\|"cҪ6IRN jRRI9&cV#zN#H]+_DⲚH"m*W4r|ugՍ34 = uX7kG6it? Ҽa5u,r!BAմ#fҹ(i%'qmr UcTl$ZI.UU7Ze͝avnB:Ui:0WI%$`sj v}+SSJa 1Ʋn]iٞtծH j%'[W! +ozA99-ܤE8:QwhA޷.zi9 `6{ IZ);:V~J W8%(ήJ*\"ڿwA w&&=B ڙ?zNESv5 MgR knCvcX^sTf^KYw`ӊڜa朦rRfSnH$vRqމk;v늤wkwUsUV$[ F+r\ItK;HH",{W<2 8eOl(HWP/PN%J) q#Ny4,mBswցTgQ%EXp#ڻsZMdgSCѢܠ6ܩ2 j,F*K?6R,׺\@Ёj-k3.)c eZ46cg<+m_JQtHLMUlA}7rF\ڳ)M zWlb%_{ :`ћk7_6:h]nV=?z)DEn.,#u3FrJ΃Zr^@GLv5uER55v20_OҤ ebX9Y'yې@+IV2VF m ZxEji\LNĞ'uLt!kJ̚Oaڬ_7tUH{8.f&`c=zՆ󥉦-eTs&n*An\*Xfk]{۠/ʌ'4˰̰*+ErrU} iKKs&01 $KX<h~AvXڦn<~r1ǥS<`XKzJ]M%$՗BqОE#=>QI,lȌz}!+$i-wQdd:UM#sI1ֶ9Eibhʂܹ0 l\3z m{z4"CjOs欺5RPU=2Ƣ='TY15u}zOiϥs.KʚRJiJN2I :gr+Fr yaE#F ObOe}Zwգ%I[uT_O+o+[xÆT8UC,K1w (] QE3E袀 (S Ҝ(RzTCVv@&sHc#,py]Ty ݚ!IWZuU5r}5^X܉By26=w r:|lFtVSk*y_Չte9f%F sZabxJ:4lX/&euQ9{@3skBJtۋql .߹3:NwGe4VGfw`@8 &{"7=+HkR$/z51D# j7ȋ᷑j– PH< ~Z%k{_s¯,{bj+$Db}M]ywՅ}qq<تZ 삥$[?@S ׌UP0ݞ6VF1t%< uRPTLˣETvb/\My/8/dan S6M\0u1ڮ C*SSHWù PcUcBBd:bi13Rq^:8oK63N+ 'ڄqSzʭ0iJ;jJu-ƭ{#<Lt})2zVb֤B Sclj7bhRZA3Hh E%.h4R֗9ZwJ>05d,7J*M)jZilՇ_cĺp#1H=kRXf}+|7O[WIF5=^kN1-؎D;~5w {ks$rBCj 33QmG*z^u+[+h JO#M)%p8zQ7M[le>IbE,U{[i4!\̧#L89\hJNꥈ#ȝccƦ7(i>9h] >3k V4Ž703fupi>k$I3|dtcϙ\ꛎG2zq_CU.̂qPBFn I`po_b'a61GO!'*a ͡b2F7Tx$*~Y-}&2Z|{wQF"ʃyRϥh3iJKlUj]F$Db左)&%&fZI<:^GL* ҡ'Qޮ5,"T2P\I[1cɫ0vpx&qB-+25V'<-Ug>Ъ3(lU QhE <[vIpH#t|vk(I'-JQhe?Lx$e܎jߙ$nʶzVbԮFs/> O=D\oS Q%4NH<:T2Ʀ2z&C+O5DNvT.g<읏