Skip to content

Commit

Permalink
$stripe->rawRequest (#1486)
Browse files Browse the repository at this point in the history
* Refactor CurlClient

* Public interface

* Test

* Better tests

* Better test

* Nicer types

* Add README.md entry

* encoding -> api_mode, support preview syntax

* Fix additional headers

* Generate preview version and formatting

* default to preview version if api mode is preview

* stripe_context

* Use setMethods, extract preview tests into separate file

* actually add preview tests

* fix tests

* fix tests

* remove old error handling

* feedback round 1

* assertEquals

* lint

* don't include stripe_version in preview default opts

* LINT

---------

Co-authored-by: Annie Li <[email protected]>
  • Loading branch information
2 people authored and prathmesh-stripe committed Aug 12, 2024
1 parent 4209ec9 commit 30de322
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 29 deletions.
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ If you use Composer, these dependencies should be handled automatically. If you
Simple usage looks like:

```php
$stripe = new \Stripe\StripeClient('sk_test_BQokikJOvBiI2HlWgH4olfQ2');
$stripe = new \Stripe\StripeClient('sk_test_xyz');
$customer = $stripe->customers->create([
'description' => 'example customer',
'email' => '[email protected]',
Expand Down Expand Up @@ -223,6 +223,31 @@ If your beta feature requires a `Stripe-Version` header to be sent, set the `api
Stripe::addBetaVersion("feature_beta", "v3");
```

### Custom requests

If you
- would like to send a request to an undocumented API (for example you are in a private beta)
- prefer to bypass the method definitions in the library and specify your request details directly,
- used the method `_request` on `ApiResource` to specify your own requests. `_request` would soon be deprecated and removed.

you can use the `rawRequest` method on the StripeClient.

```php
$stripe = new \Stripe\StripeClient('sk_test_xyz');
$response = $stripe->rawRequest('post', '/v1/beta_endpoint', [
"caveat": "emptor"
], [
"stripe_version" => "2024-06-20",
]);
// $response->body is a string, you can call $stripe->deserialize to get a \Stripe\StripeObject.
$obj = $stripe->deserialize($response->body);

// For GET requests, the params argument must be null, and you should write the query string explicitly.
$get_response = $stripe->rawRequest('get', '/v1/beta_endpoint?caveat=emptor', null, [
"stripe_version" => "2022-11_15",
]);
```

## Support

New features and bug fixes are released on the latest major version of the Stripe PHP library. If you are on an older major version, we recommend that you upgrade to the latest in order to use the new features and bug fixes including those for security vulnerabilities. Older major versions of the package will continue to be available for use, but will not be receiving any updates.
Expand Down
6 changes: 6 additions & 0 deletions lib/ApiRequestor.php
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,12 @@ private static function _defaultHeaders($apiKey, $clientInfo = null, $appInfo =
];
}

/**
* @param 'delete'|'get'|'post' $method
* @param string $url
* @param array $params
* @param array $headers
*/
private function _prepareRequest($method, $url, $params, $headers)
{
$myApiKey = $this->_apiKey;
Expand Down
48 changes: 48 additions & 0 deletions lib/BaseStripeClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,42 @@ public function request($method, $path, $params, $opts)
return $obj;
}

/**
* Sends a raw request to Stripe's API. This is the lowest level method for interacting
* with the Stripe API. This method is useful for interacting with endpoints that are not
* covered yet in stripe-php.
*
* @param 'delete'|'get'|'post' $method the HTTP method
* @param string $path the path of the request
* @param array $params the parameters of the request
* @param array $opts the special modifiers of the request
*
* @return \Stripe\ApiResponse
*/
public function rawRequest($method, $path, $params, $opts)
{
if ('post' !== $method && null !== $params) {
throw new Exception\InvalidArgumentException('Error: rawRequest only supports $params on post requests. Please pass null and add your parameters to $path');
}
$headers = [];
if (\is_array($opts) && \array_key_exists('headers', $opts)) {
$headers = $opts['headers'] ?: [];
unset($opts['headers']);
}
if (\is_array($opts) && \array_key_exists('stripe_context', $opts)) {
$headers['Stripe-Context'] = $opts['stripe_context'];
unset($opts['stripe_context']);
}
$opts = $this->defaultOpts->merge($opts, true);
// Concatenate $headers to $opts->headers, removing duplicates.
$opts->headers = \array_merge($opts->headers, $headers);
$baseUrl = $opts->apiBase ?: $this->getApiBase();
$requestor = new \Stripe\ApiRequestor($this->apiKeyForRequest($opts), $baseUrl);
list($response) = $requestor->request($method, $path, $params, $opts->headers, ['raw_request']);

return $response;
}

/**
* Sends a request to Stripe's API, passing chunks of the streamed response
* into a user-provided $readBodyChunkCallable callback.
Expand Down Expand Up @@ -327,4 +363,16 @@ private function validateConfig($config)
throw new \Stripe\Exception\InvalidArgumentException('Found unknown key(s) in configuration array: ' . $invalidKeys);
}
}

/**
* Deserializes the raw JSON string returned by rawRequest into a similar class.
*
* @param string $json
*
* @return \Stripe\StripeObject
* */
public function deserialize($json)
{
return \Stripe\Util\Util::convertToStripeObject(\json_decode($json, true), []);
}
}
3 changes: 1 addition & 2 deletions lib/HttpClient/ClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ interface ClientInterface
* @param string $absUrl The URL being requested, including domain and protocol
* @param array $headers Headers to be used in the request (full strings, not KV pairs)
* @param array $params KV pairs for parameters. Can be nested for arrays and hashes
* @param bool $hasFile Whether or not $params references a file (via an @ prefix or
* CURLFile)
* @param bool $hasFile Whether $params references a file (via an @ prefix or CURLFile)
*
* @throws \Stripe\Exception\ApiConnectionException
* @throws \Stripe\Exception\UnexpectedValueException
Expand Down
112 changes: 87 additions & 25 deletions lib/HttpClient/CurlClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,46 +193,69 @@ public function getConnectTimeout()

// END OF USER DEFINED TIMEOUTS

private function constructRequest($method, $absUrl, $headers, $params, $hasFile)
/**
* @param 'delete'|'get'|'post' $method
* @param string $absUrl
* @param string[] $params
* @param bool $hasFile
*/
private function constructUrlAndBody($method, $absUrl, $params, $hasFile)
{
$method = \strtolower($method);
$params = Util\Util::objectsToIds($params);
if ('post' === $method) {
$absUrl = Util\Util::utf8($absUrl);
if ($hasFile) {
return [$absUrl, $params];
}

return [$absUrl, Util\Util::encodeParameters($params)];
}
if ($hasFile) {
throw new Exception\UnexpectedValueException("Unexpected. {$method} methods don't support file attachments");
}
if (0 === \count($params)) {
return [Util\Util::utf8($absUrl), null];
}
$encoded = Util\Util::encodeParameters($params);

$opts = [];
$absUrl = "{$absUrl}?{$encoded}";
$absUrl = Util\Util::utf8($absUrl);

return [$absUrl, null];
}

private function calculateDefaultOptions($method, $absUrl, $headers, $params, $hasFile)
{
if (\is_callable($this->defaultOptions)) { // call defaultOptions callback, set options to return value
$opts = \call_user_func_array($this->defaultOptions, \func_get_args());
if (!\is_array($opts)) {
$ret = \call_user_func_array($this->defaultOptions, [$method, $absUrl, $headers, $params, $hasFile]);
if (!\is_array($ret)) {
throw new Exception\UnexpectedValueException('Non-array value returned by defaultOptions CurlClient callback');
}
} elseif (\is_array($this->defaultOptions)) { // set default curlopts from array
$opts = $this->defaultOptions;

return $ret;
}
if (\is_array($this->defaultOptions)) { // set default curlopts from array
return $this->defaultOptions;
}

$params = Util\Util::objectsToIds($params);
return [];
}

private function constructCurlOptions($method, $absUrl, $headers, $body, $opts)
{
if ('get' === $method) {
if ($hasFile) {
throw new Exception\UnexpectedValueException(
'Issuing a GET request with a file parameter'
);
}
$opts[\CURLOPT_HTTPGET] = 1;
if (\count($params) > 0) {
$encoded = Util\Util::encodeParameters($params);
$absUrl = "{$absUrl}?{$encoded}";
}
} elseif ('post' === $method) {
$opts[\CURLOPT_POST] = 1;
$opts[\CURLOPT_POSTFIELDS] = $hasFile ? $params : Util\Util::encodeParameters($params);
} elseif ('delete' === $method) {
$opts[\CURLOPT_CUSTOMREQUEST] = 'DELETE';
if (\count($params) > 0) {
$encoded = Util\Util::encodeParameters($params);
$absUrl = "{$absUrl}?{$encoded}";
}
} else {
throw new Exception\UnexpectedValueException("Unrecognized method {$method}");
}

if ($body) {
$opts[\CURLOPT_POSTFIELDS] = $body;
}
// It is only safe to retry network failures on POST requests if we
// add an Idempotency-Key header
if (('post' === $method) && (Stripe::$maxNetworkRetries > 0)) {
Expand All @@ -255,7 +278,6 @@ private function constructRequest($method, $absUrl, $headers, $params, $hasFile)
// sending an empty `Expect:` header.
$headers[] = 'Expect: ';

$absUrl = Util\Util::utf8($absUrl);
$opts[\CURLOPT_URL] = $absUrl;
$opts[\CURLOPT_RETURNTRANSFER] = true;
$opts[\CURLOPT_CONNECTTIMEOUT] = $this->connectTimeout;
Expand All @@ -271,22 +293,62 @@ private function constructRequest($method, $absUrl, $headers, $params, $hasFile)
$opts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2TLS;
}

// If the user didn't explicitly specify a CURLOPT_IPRESOLVE option, we
// force IPv4 resolving as Stripe's API servers are only accessible over
// IPv4 (see. https://github.com/stripe/stripe-php/issues/1045).
// We let users specify a custom option in case they need to say proxy
// through an IPv6 proxy.
if (!isset($opts[\CURLOPT_IPRESOLVE])) {
$opts[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4;
}

return $opts;
}

/**
* @param 'delete'|'get'|'post' $method
* @param string $absUrl
* @param array $headers
* @param array $params
* @param bool $hasFile
*/
private function constructRequest($method, $absUrl, $headers, $params, $hasFile)
{
$method = \strtolower($method);

$opts = $this->calculateDefaultOptions($method, $absUrl, $headers, $params, $hasFile);
list($absUrl, $body) = $this->constructUrlAndBody($method, $absUrl, $params, $hasFile);
$opts = $this->constructCurlOptions($method, $absUrl, $headers, $body, $opts);

return [$opts, $absUrl];
}

/**
* @param 'delete'|'get'|'post' $method
* @param string $absUrl
* @param array $headers
* @param array $params
* @param bool $hasFile
*/
public function request($method, $absUrl, $headers, $params, $hasFile)
{
list($opts, $absUrl) = $this->constructRequest($method, $absUrl, $headers, $params, $hasFile);

list($rbody, $rcode, $rheaders) = $this->executeRequestWithRetries($opts, $absUrl);

return [$rbody, $rcode, $rheaders];
}

/**
* @param 'delete'|'get'|'post' $method
* @param string $absUrl
* @param array $headers
* @param array $params
* @param bool $hasFile
* @param callable $readBodyChunk
*/
public function requestStream($method, $absUrl, $headers, $params, $hasFile, $readBodyChunk)
{
list($opts, $absUrl) = $this->constructRequest($method, $absUrl, $headers, $params, $hasFile);

$opts[\CURLOPT_RETURNTRANSFER] = false;
list($rbody, $rcode, $rheaders) = $this->executeStreamingRequestWithRetries($opts, $absUrl, $readBodyChunk);

Expand Down
80 changes: 79 additions & 1 deletion tests/Stripe/BaseStripeClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ final class BaseStripeClientTest extends \Stripe\TestCase
/** @var \ReflectionProperty */
private $optsReflector;

protected function headerStartsWith($header, $name)
{
return substr($header, 0, \strlen($name)) === $name;
}

/** @before */
protected function setUpOptsReflector()
{
Expand Down Expand Up @@ -380,7 +385,9 @@ public function testClientAppInfoOverridesGlobal()

public function testConfigValidationFindsExtraAppInfoKeys()
{
$this->expectException(\Stripe\Exception\InvalidArgumentException::class);
$this->expectException(
\Stripe\Exception\InvalidArgumentException::class
);
$client = new BaseStripeClient([
'api_key' => 'sk_test_appinfo',
'app_info' => [
Expand All @@ -389,4 +396,75 @@ public function testConfigValidationFindsExtraAppInfoKeys()
],
]);
}

public function testJsonRawRequestGetWithURLParams()
{
$curlClientStub = $this->getMockBuilder(\Stripe\HttpClient\CurlClient::class)
->setMethods(['executeRequestWithRetries'])
->getMock()
;
$curlClientStub->method('executeRequestWithRetries')
->willReturn(['{}', 200, []])
;

$opts = null;
$curlClientStub->expects(static::once())
->method('executeRequestWithRetries')
->with(static::callback(function ($opts_) use (&$opts) {
$opts = $opts_;

return true;
}), MOCK_URL . '/v1/xyz?foo=bar')
;

ApiRequestor::setHttpClient($curlClientStub);
$client = new BaseStripeClient([
'api_key' => 'sk_test_client',
'stripe_account' => 'acct_123',
'api_base' => MOCK_URL,
]);
$client->rawRequest('get', '/v1/xyz?foo=bar', null, []);
static::assertArrayNotHasKey(\CURLOPT_POST, $opts);
static::assertArrayNotHasKey(\CURLOPT_POSTFIELDS, $opts);
$content_type = null;
foreach ($opts[\CURLOPT_HTTPHEADER] as $header) {
if (self::headerStartsWith($header, 'Content-Type:')) {
$content_type = $header;
}
}
// The library sends Content-Type even with no body, so assert this
// But it would be more correct to not send Content-Type
static::assertSame('Content-Type: application/x-www-form-urlencoded', $content_type);
}

public function testFormRawRequestPost()
{
$curlClientStub = $this->getMockBuilder(\Stripe\HttpClient\CurlClient::class)
->setMethods(['executeRequestWithRetries'])
->getMock()
;
$curlClientStub->method('executeRequestWithRetries')
->willReturn(['{}', 200, []])
;

$curlClientStub->expects(static::once())
->method('executeRequestWithRetries')
->with(static::callback(function ($opts) {
$this->assertSame(1, $opts[\CURLOPT_POST]);
$this->assertSame('foo=bar&baz[qux]=false', $opts[\CURLOPT_POSTFIELDS]);
$this->assertContains('Content-Type: application/x-www-form-urlencoded', $opts[\CURLOPT_HTTPHEADER]);

return true;
}), MOCK_URL . '/v1/xyz')
;

ApiRequestor::setHttpClient($curlClientStub);
$client = new BaseStripeClient([
'api_key' => 'sk_test_client',
'stripe_account' => 'acct_123',
'api_base' => MOCK_URL,
]);
$params = ['foo' => 'bar', 'baz' => ['qux' => false]];
$client->rawRequest('post', '/v1/xyz', $params, []);
}
}

0 comments on commit 30de322

Please sign in to comment.