<?php
namespace Illuminate\Http\Client;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\HandlerStack;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Support\Traits\Macroable;
use Symfony\Component\VarDumper\VarDumper;
class PendingRequest
{
use Macroable;
/**
* The factory instance.
*
* @var \Illuminate\Http\Client\Factory|null
*/
protected $factory;
/**
* The base URL for the request.
*
* @var string
*/
protected $baseUrl = '';
/**
* The request body format.
*
* @var string
*/
protected $bodyFormat;
/**
* The raw body for the request.
*
* @var string
*/
protected $pendingBody;
/**
* The pending files for the request.
*
* @var array
*/
protected $pendingFiles = [];
/**
* The request cookies.
*
* @var array
*/
protected $cookies;
/**
* The transfer stats for the request.
*
* \GuzzleHttp\TransferStats
*/
protected $transferStats;
/**
* The request options.
*
* @var array
*/
protected $options = [];
/**
* The number of times to try the request.
*
* @var int
*/
protected $tries = 1;
/**
* The number of milliseconds to wait between retries.
*
* @var int
*/
protected $retryDelay = 100;
/**
* The callbacks that should execute before the request is sent.
*
* @var \Illuminate\Support\Collection
*/
protected $beforeSendingCallbacks;
/**
* The stub callables that will handle requests.
*
* @var \Illuminate\Support\Collection|null
*/
protected $stubCallbacks;
/**
* The middleware callables added by users that will handle requests.
*
* @var \Illuminate\Support\Collection
*/
protected $middleware;
/**
* Create a new HTTP Client instance.
*
* @param \Illuminate\Http\Client\Factory|null $factory
* @return void
*/
public function __construct(Factory $factory = null)
{
$this->factory = $factory;
$this->middleware = new Collection;
$this->asJson();
$this->options = [
'http_errors' => false,
];
$this->beforeSendingCallbacks = collect([function (Request $request, array $options) {
$this->cookies = $options['cookies'];
}]);
}
/**
* Set the base URL for the pending request.
*
* @param string $url
* @return $this
*/
public function baseUrl(string $url)
{
$this->baseUrl = $url;
return $this;
}
/**
* Attach a raw body to the request.
*
* @param resource|string $content
* @param string $contentType
* @return $this
*/
public function withBody($content, $contentType)
{
$this->bodyFormat('body');
$this->pendingBody = $content;
$this->contentType($contentType);
return $this;
}
/**
* Indicate the request contains JSON.
*
* @return $this
*/
public function asJson()
{
return $this->bodyFormat('json')->contentType('application/json');
}
/**
* Indicate the request contains form parameters.
*
* @return $this
*/
public function asForm()
{
return $this->bodyFormat('form_params')->contentType('application/x-www-form-urlencoded');
}
/**
* Attach a file to the request.
*
* @param string|array $name
* @param string $contents
* @param string|null $filename
* @param array $headers
* @return $this
*/
public function attach($name, $contents = '', $filename = null, array $headers = [])
{
if (is_array($name)) {
foreach ($name as $file) {
$this->attach(...$file);
}
return $this;
}
$this->asMultipart();
$this->pendingFiles[] = array_filter([
'name' => $name,
'contents' => $contents,
'headers' => $headers,
'filename' => $filename,
]);
return $this;
}
/**
* Indicate the request is a multi-part form request.
*
* @return $this
*/
public function asMultipart()
{
return $this->bodyFormat('multipart');
}
/**
* Specify the body format of the request.
*
* @param string $format
* @return $this
*/
public function bodyFormat(string $format)
{
return tap($this, function ($request) use ($format) {
$this->bodyFormat = $format;
});
}
/**
* Specify the request's content type.
*
* @param string $contentType
* @return $this
*/
public function contentType(string $contentType)
{
return $this->withHeaders(['Content-Type' => $contentType]);
}
/**
* Indicate that JSON should be returned by the server.
*
* @return $this
*/
public function acceptJson()
{
return $this->accept('application/json');
}
/**
* Indicate the type of content that should be returned by the server.
*
* @param string $contentType
* @return $this
*/
public function accept($contentType)
{
return $this->withHeaders(['Accept' => $contentType]);
}
/**
* Add the given headers to the request.
*
* @param array $headers
* @return $this
*/
public function withHeaders(array $headers)
{
return tap($this, function ($request) use ($headers) {
return $this->options = array_merge_recursive($this->options, [
'headers' => $headers,
]);
});
}
/**
* Specify the basic authentication username and password for the request.
*
* @param string $username
* @param string $password
* @return $this
*/
public function withBasicAuth(string $username, string $password)
{
return tap($this, function ($request) use ($username, $password) {
return $this->options['auth'] = [$username, $password];
});
}
/**
* Specify the digest authentication username and password for the request.
*
* @param string $username
* @param string $password
* @return $this
*/
public function withDigestAuth($username, $password)
{
return tap($this, function ($request) use ($username, $password) {
return $this->options['auth'] = [$username, $password, 'digest'];
});
}
/**
* Specify an authorization token for the request.
*
* @param string $token
* @param string $type
* @return $this
*/
public function withToken($token, $type = 'Bearer')
{
return tap($this, function ($request) use ($token, $type) {
return $this->options['headers']['Authorization'] = trim($type.' '.$token);
});
}
/**
* Specify the user agent for the request.
*
* @param string $userAgent
* @return $this
*/
public function withUserAgent($userAgent)
{
return $this->withHeaders(['User-Agent' => $userAgent]);
}
/**
* Specify the cookies that should be included with the request.
*
* @param array $cookies
* @param string $domain
* @return $this
*/
public function withCookies(array $cookies, string $domain)
{
return tap($this, function ($request) use ($cookies, $domain) {
return $this->options = array_merge_recursive($this->options, [
'cookies' => CookieJar::fromArray($cookies, $domain),
]);
});
}
/**
* Indicate that redirects should not be followed.
*
* @return $this
*/
public function withoutRedirecting()
{
return tap($this, function ($request) {
return $this->options['allow_redirects'] = false;
});
}
/**
* Indicate that TLS certificates should not be verified.
*
* @return $this
*/
public function withoutVerifying()
{
return tap($this, function ($request) {
return $this->options['verify'] = false;
});
}
/**
* Specify the path where the body of the response should be stored.
*
* @param string|resource $to
* @return $this
*/
public function sink($to)
{
return tap($this, function ($request) use ($to) {
return $this->options['sink'] = $to;
});
}
/**
* Specify the timeout (in seconds) for the request.
*
* @param int $seconds
* @return $this
*/
public function timeout(int $seconds)
{
return tap($this, function () use ($seconds) {
$this->options['timeout'] = $seconds;
});
}
/**
* Specify the number of times the request should be attempted.
*
* @param int $times
* @param int $sleep
* @return $this
*/
public function retry(int $times, int $sleep = 0)
{
$this->tries = $times;
$this->retryDelay = $sleep;
return $this;
}
/**
* Merge new options into the client.
*
* @param array $options
* @return $this
*/
public function withOptions(array $options)
{
return tap($this, function ($request) use ($options) {
return $this->options = array_merge_recursive($this->options, $options);
});
}
/**
* Add new middleware the client handler stack.
*
* @param callable $middleware
* @return $this
*/
public function withMiddleware(callable $middleware)
{
$this->middleware->push($middleware);
return $this;
}
/**
* Add a new "before sending" callback to the request.
*
* @param callable $callback
* @return $this
*/
public function beforeSending($callback)
{
return tap($this, function () use ($callback) {
$this->beforeSendingCallbacks[] = $callback;
});
}
/**
* Dump the request before sending.
*
* @return $this
*/
public function dump()
{
$values = func_get_args();
return $this->beforeSending(function (Request $request, array $options) use ($values) {
foreach (array_merge($values, [$request, $options]) as $value) {
VarDumper::dump($value);
}
});
}
/**
* Dump the request before sending and end the script.
*
* @return $this
*/
public function dd()
{
$values = func_get_args();
return $this->beforeSending(function (Request $request, array $options) use ($values) {
foreach (array_merge($values, [$request, $options]) as $value) {
VarDumper::dump($value);
}
exit(1);
});
}
/**
* Issue a GET request to the given URL.
*
* @param string $url
* @param array|string|null $query
* @return \Illuminate\Http\Client\Response
*/
public function get(string $url, $query = null)
{
return $this->send('GET', $url, [
'query' => $query,
]);
}
/**
* Issue a HEAD request to the given URL.
*
* @param string $url
* @param array|string|null $query
* @return \Illuminate\Http\Client\Response
*/
public function head(string $url, $query = null)
{
return $this->send('HEAD', $url, [
'query' => $query,
]);
}
/**
* Issue a POST request to the given URL.
*
* @param string $url
* @param array $data
* @return \Illuminate\Http\Client\Response
*/
public function post(string $url, array $data = [])
{
return $this->send('POST', $url, [
$this->bodyFormat => $data,
]);
}
/**
* Issue a PATCH request to the given URL.
*
* @param string $url
* @param array $data
* @return \Illuminate\Http\Client\Response
*/
public function patch($url, $data = [])
{
return $this->send('PATCH', $url, [
$this->bodyFormat => $data,
]);
}
/**
* Issue a PUT request to the given URL.
*
* @param string $url
* @param array $data
* @return \Illuminate\Http\Client\Response
*/
public function put($url, $data = [])
{
return $this->send('PUT', $url, [
$this->bodyFormat => $data,
]);
}
/**
* Issue a DELETE request to the given URL.
*
* @param string $url
* @param array $data
* @return \Illuminate\Http\Client\Response
*/
public function delete($url, $data = [])
{
return $this->send('DELETE', $url, empty($data) ? [] : [
$this->bodyFormat => $data,
]);
}
/**
* Send the request to the given URL.
*
* @param string $method
* @param string $url
* @param array $options
* @return \Illuminate\Http\Client\Response
*
* @throws \Exception
*/
public function send(string $method, string $url, array $options = [])
{
$url = ltrim(rtrim($this->baseUrl, '/').'/'.ltrim($url, '/'), '/');
if (isset($options[$this->bodyFormat])) {
if ($this->bodyFormat === 'multipart') {
$options[$this->bodyFormat] = $this->parseMultipartBodyFormat($options[$this->bodyFormat]);
} elseif ($this->bodyFormat === 'body') {
$options[$this->bodyFormat] = $this->pendingBody;
}
if (is_array($options[$this->bodyFormat])) {
$options[$this->bodyFormat] = array_merge(
$options[$this->bodyFormat], $this->pendingFiles
);
}
}
[$this->pendingBody, $this->pendingFiles] = [null, []];
return retry($this->tries ?? 1, function () use ($method, $url, $options) {
try {
$laravelData = $this->parseRequestData($method, $url, $options);
return tap(new Response($this->buildClient()->request($method, $url, $this->mergeOptions([
'laravel_data' => $laravelData,
'on_stats' => function ($transferStats) {
$this->transferStats = $transferStats;
},
], $options))), function ($response) {
$response->cookies = $this->cookies;
$response->transferStats = $this->transferStats;
if ($this->tries > 1 && ! $response->successful()) {
$response->throw();
}
});
} catch (ConnectException $e) {
throw new ConnectionException($e->getMessage(), 0, $e);
}
}, $this->retryDelay ?? 100);
}
/**
* Parse multi-part form data.
*
* @param array $data
* @return array|array[]
*/
protected function parseMultipartBodyFormat(array $data)
{
return collect($data)->map(function ($value, $key) {
return is_array($value) ? $value : ['name' => $key, 'contents' => $value];
})->values()->all();
}
/**
* Get the request data as an array so that we can attach it to the request for convenient assertions.
*
* @param string $method
* @param string $url
* @param array $options
* @return array
*/
protected function parseRequestData($method, $url, array $options)
{
$laravelData = $options[$this->bodyFormat] ?? $options['query'] ?? [];
$urlString = Str::of($url);
if (empty($laravelData) && $method === 'GET' && $urlString->contains('?')) {
$laravelData = (string) $urlString->after('?');
}
if (is_string($laravelData)) {
parse_str($laravelData, $parsedData);
$laravelData = is_array($parsedData) ? $parsedData : [];
}
return $laravelData;
}
/**
* Build the Guzzle client.
*
* @return \GuzzleHttp\Client
*/
public function buildClient()
{
return new Client([
'handler' => $this->buildHandlerStack(),
'cookies' => true,
]);
}
/**
* Build the before sending handler stack.
*
* @return \GuzzleHttp\HandlerStack
*/
public function buildHandlerStack()
{
return tap(HandlerStack::create(), function ($stack) {
$stack->push($this->buildBeforeSendingHandler());
$stack->push($this->buildRecorderHandler());
$stack->push($this->buildStubHandler());
$this->middleware->each(function ($middleware) use ($stack) {
$stack->push($middleware);
});
});
}
/**
* Build the before sending handler.
*
* @return \Closure
*/
public function buildBeforeSendingHandler()
{
return function ($handler) {
return function ($request, $options) use ($handler) {
return $handler($this->runBeforeSendingCallbacks($request, $options), $options);
};
};
}
/**
* Build the recorder handler.
*
* @return \Closure
*/
public function buildRecorderHandler()
{
return function ($handler) {
return function ($request, $options) use ($handler) {
$promise = $handler($this->runBeforeSendingCallbacks($request, $options), $options);
return $promise->then(function ($response) use ($request, $options) {
optional($this->factory)->recordRequestResponsePair(
(new Request($request))->withData($options['laravel_data']),
new Response($response)
);
return $response;
});
};
};
}
/**
* Build the stub handler.
*
* @return \Closure
*/
public function buildStubHandler()
{
return function ($handler) {
return function ($request, $options) use ($handler) {
$response = ($this->stubCallbacks ?? collect())
->map
->__invoke((new Request($request))->withData($options['laravel_data']), $options)
->filter()
->first();
if (is_null($response)) {
return $handler($request, $options);
}
$response = is_array($response) ? Factory::response($response) : $response;
$sink = $options['sink'] ?? null;
if ($sink) {
$response->then($this->sinkStubHandler($sink));
}
return $response;
};
};
}
/**
* Get the sink stub handler callback.
*
* @param string $sink
* @return \Closure
*/
protected function sinkStubHandler($sink)
{
return function ($response) use ($sink) {
$body = $response->getBody()->getContents();
if (is_string($sink)) {
file_put_contents($sink, $body);
return;
}
fwrite($sink, $body);
rewind($sink);
};
}
/**
* Execute the "before sending" callbacks.
*
* @param \GuzzleHttp\Psr7\RequestInterface $request
* @param array $options
* @return \Closure
*/
public function runBeforeSendingCallbacks($request, array $options)
{
return tap($request, function ($request) use ($options) {
$this->beforeSendingCallbacks->each->__invoke(
(new Request($request))->withData($options['laravel_data']),
$options
);
});
}
/**
* Merge the given options with the current request options.
*
* @param array $options
* @return array
*/
public function mergeOptions(...$options)
{
return array_merge_recursive($this->options, ...$options);
}
/**
* Register a stub callable that will intercept requests and be able to return stub responses.
*
* @param callable $callback
* @return $this
*/
public function stub($callback)
{
$this->stubCallbacks = collect($callback);
return $this;
}
}