*/ class Request { // Option constants const SERIALIZE_PAYLOAD_NEVER = 0; const SERIALIZE_PAYLOAD_ALWAYS = 1; const SERIALIZE_PAYLOAD_SMART = 2; const MAX_REDIRECTS_DEFAULT = 25; public $uri, $method = Http::GET, $headers = [], $raw_headers = '', $strict_ssl = false, $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, $follow_redirects = false, $max_redirects = self::MAX_REDIRECTS_DEFAULT, $payload_serializers = []; // 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 private 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 */ private function __construct($attrs = null) { if (!is_array($attrs)) { return; } foreach ($attrs as $attr => $value) { $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 * @return mixed default value * @param string|null $attr Name of attribute (e.g. mime, headers) * if null just return the whole template object; */ 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() { return isset($this->timeout); } /** * @return bool has the internal curl request been initialized? */ public function hasBeenInitialized() { return isset($this->_ch); } /** * @return bool Is this request setup for basic auth? */ public function hasBasicAuth() { return isset($this->password) && isset($this->username); } /** * @return bool Is this request setup for digest auth? */ public function hasDigestAuth() { return isset($this->password) && isset($this->username) && $this->additional_curl_opts[CURLOPT_HTTPAUTH] == CURLAUTH_DIGEST; } /** * Specify a HTTP timeout * @return Request $this * @param |int $timeout seconds to timeout the HTTP call */ 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 * @return Request $this * @param bool|int $follow follow or not to follow or maximal number of redirects */ public function followRedirects($follow = true) { $this->max_redirects = $follow === true ? self::MAX_REDIRECTS_DEFAULT : max(0, $follow); $this->follow_redirects = (bool) $follow; return $this; } /** * @return Request $this * @see Request::followRedirects() */ public function doNotFollowRedirects() { return $this->followRedirects(false); } /** * Actually send off the request, and parse the response * @return string|associative array of 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); if ($result === false) { $this->_error(curl_error($this->_ch)); throw new ConnectionErrorException('Unable to connect.'); } $info = curl_getinfo($this->_ch); $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); } public function sendIt() { return $this->send(); } // Setters /** * @return Request this * @param string $uri */ public function uri($uri) { $this->uri = $uri; return $this; } /** * User Basic Auth. * Only use when over SSL/TSL/HTTPS. * @return Request this * @param string $username * @param string $password */ 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); } /** * User Digest Auth. * @return Request this * @param string $username * @param string $password */ 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 is this request setup for client side cert? */ public function hasClientSideCert() { return isset($this->client_cert) && isset($this->client_key); } /** * Use Client Side Cert Authentication * @return Request $this * @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 */ 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 * @return Request this * @param mixed $payload * @param string $mimeType */ 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 * @return Request this * @param string $mime mime type to use for content type and expected return type */ public function mime($mime) { if (empty($mime)) { return $this; } $this->content_type = $this->expected_type = Mime::getFullMime($mime); 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. * @return Request this * @param string $method */ public function method($method) { if (empty($method)) { return $this; } $this->method = $method; return $this; } /** * @return Request this * @param string $mime */ public function expects($mime) { if (empty($mime)) { return $this; } $this->expected_type = Mime::getFullMime($mime); return $this; } // @alias of expects public function expectsType($mime) { return $this->expects($mime); } /** * @return Request this * @param string $mime */ public function contentType($mime) { if (empty($mime)) { return $this; } $this->content_type = Mime::getFullMime($mime); 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? * @return Request this * @param bool $strict */ 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 * @return Request this * @param string $proxy_host Hostname or address of the proxy * @param number $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 */ public function useProxy($proxy_host, $proxy_port = 80, $auth_type = null, $auth_username = null, $auth_password = null) { $this->addOnCurlOption(CURLOPT_PROXY, "{$proxy_host}:{$proxy_port}"); if (in_array($auth_type, [CURLAUTH_BASIC,CURLAUTH_NTLM])) { $this->addOnCurlOption(CURLOPT_PROXYAUTH, $auth_type) ->addOnCurlOption(CURLOPT_PROXYUSERPWD, "{$auth_username}:{$auth_password}"); } return $this; } /** * @return is this request setup for using proxy? */ public function hasProxy() { return is_string($this->additional_curl_opts[CURLOPT_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. * @return Request $this * @param int $mode */ 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() * * @return Request this * @param string $header_name * @param string $value */ 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. * @return Response $this * @param array $headers */ public function addHeaders(array $headers) { foreach ($headers as $header => $value) { $this->addHeader($header, $value); } return $this; } /** * @return Request * @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. */ 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. * @return Request this * @param \Closure $callback Takes the raw body of * the http response and returns a mixed */ public function parseWith(\Closure $callback) { $this->parse_callback = $callback; return $this; } /** * @see Request::parseResponsesWith() * @return Request $this * @param \Closure $callback */ public function parseResponsesWith(\Closure $callback) { return $this->parseWith($callback); } /** * 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. * * @return Request $this * @param string $mime mime type we're registering * @param Closure $callback takes one argument, $payload, * which is the payload that we'll be */ public function registerPayloadSerializer($mime, \Closure $callback) { $this->payload_serializers[Mime::getFullMime($mime)] = $callback; return $this; } /** * @see Request::registerPayloadSerializer() * @return Request $this * @param Closure $callback */ public function serializePayloadWith(\Closure $callback) { return $this->regregisterPayloadSerializer('*', $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. * @return Request this * @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 */ 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 (count($args) === 0) { return; } // Strip the sugar. If it leads with "with", strip. // This is okay because: No defined HTTP headers begin with with, // and if you are defining a custom header, the standard is to prefix it // with an "X-", so that should take care of any collisions. if (substr($method, 0, 4) === 'with') { $method = substr($method, 4); } // Precede upper case letters with dashes, uppercase the first letter of method $header = ucwords(implode('-', preg_split('/([A-Z][^A-Z]*)/', $method, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY))); $this->addHeader($header, $args[0]); return $this; } // 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 this */ 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) { // Default actions write to error log // TODO add in support for various Loggers 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. * @return Request * @param string $method Http Method * @param string $mime Mime Type to Use */ 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 $this; */ public function _curlPrep() { // Check for required stuff if (!isset($this->uri)) { throw new \Exception('Attempting to send a request before defining a URI endpoint.'); } $ch = curl_init($this->uri); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method); 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()) { 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); 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 (isset($this->payload)) { $this->serialized_payload = $this->_serializePayload($this->payload); curl_setopt($ch, CURLOPT_POSTFIELDS, $this->serialized_payload); $this->headers['Content-Length'] = strlen($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->headers['Content-Length'] = 0; } foreach ($this->headers as $header => $value) { $headers[] = "$header: $value"; } $url = \parse_url($this->uri); $path = (isset($url['path']) ? $url['path'] : '/').(isset($url['query']) ? '?'.$url['query'] : ''); $this->raw_headers = "{$this->method} $path HTTP/1.1\r\n"; $host = (isset($url['host']) ? $url['host'] : 'localhost').(isset($url['port']) ? ':'.$url['port'] : ''); $this->raw_headers .= "Host: $host\r\n"; $this->raw_headers .= \implode("\r\n", $headers); $this->raw_headers .= "\r\n"; curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 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; } public function buildUserAgent() { $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']}"; } $user_agent .= ')'; return $user_agent; } /** * Semi-reluctantly added this as a way to add in curl opts * that are not otherwise accessible from the rest of the API. * @return Request $this * @param string $curlopt * @param mixed $curloptval */ 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() * * @return string * @param mixed $payload */ 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 * @return Request * @param string $uri optional uri to use * @param string $mime expected */ 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 * @return Response * @param string $uri optional uri to use * @param string $mime expected */ public static function getQuick($uri, $mime = null) { return self::get($uri, $mime)->send(); } /** * HTTP Method Post * @return Request * @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 */ public static function post($uri, $payload = null, $mime = null) { return self::init(Http::POST)->uri($uri)->body($payload, $mime); } /** * HTTP Method Put * @return Request * @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 */ public static function put($uri, $payload = null, $mime = null) { return self::init(Http::PUT)->uri($uri)->body($payload, $mime); } /** * HTTP Method Patch * @return Request * @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 */ public static function patch($uri, $payload = null, $mime = null) { return self::init(Http::PATCH)->uri($uri)->body($payload, $mime); } /** * HTTP Method Delete * @return Request * @param string $uri optional uri to use */ public static function delete($uri, $mime = null) { return self::init(Http::DELETE)->uri($uri)->mime($mime); } /** * HTTP Method Head * @return Request * @param string $uri optional uri to use */ public static function head($uri) { return self::init(Http::HEAD)->uri($uri); } /** * HTTP Method Options * @return Request * @param string $uri optional uri to use */ public static function options($uri) { return self::init(Http::OPTIONS)->uri($uri); } }