Skip to content

Commit

Permalink
feat: Allow configuring PSR-18 compliant HTTP client used.
Browse files Browse the repository at this point in the history
  • Loading branch information
JanEbbing committed Jun 3, 2023
1 parent b235af7 commit 46cae1c
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 17 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0


## [Unreleased]
### Added
* Allow users to supply their own custom HTTP client to the `Translator` object, in order to configure timeouts, security features etc more granularly.
* Thanks to [VincentLanglet](https://github.com/VincentLanglet) for the good input and work in [#22](https://github.com/DeepLcom/deepl-php/pull/22)


## [1.4.0] - 2023-05-24
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,21 @@ $headers = [
$translator = new \DeepL\Translator('YOUR_AUTH_KEY', ['headers' => $headers]);
```

### Custom HTTP client

If you want to set specific HTTP options that we don't expose (or otherwise want more control over the API calls by the library), you can configure the library to use a PSR-18 compliant HTTP client of your choosing.
For example, in order to use a connect timeout of 5.2 seconds and read timeout of 7.4 seconds while using a proxy with [Guzzle](https://github.com/guzzle/guzzle):

```php
$client = new \GuzzleHttp\Client([
'connect_timeout' => 5.2,
'read_timeout' => 7.4,
'proxy' => 'http://localhost:8125'
]);
$translator = new \DeepL\Translator('YOUR_AUTH_KEY', [TranslatorOptions::HTTP_CLIENT => $client]);
$translator->getUsage(); // Or a translate call, etc
```

### Request retries

Requests to the DeepL API that fail due to transient conditions (for example,
Expand Down
14 changes: 12 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@
"require": {
"php": ">=7.3.0",
"psr/log": "^1.1 || ^2.0 || ^3.0",
"psr/http-client": "^1.0",
"php-http/discovery": "^1.18",
"ext-json": "*",
"ext-curl": "*",
"ext-mbstring": "*"
"ext-mbstring": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"php-http/multipart-stream-builder": "^1.3"
},
"require-dev": {
"phpunit/phpunit": "^9",
Expand All @@ -38,5 +43,10 @@
"name": "DeepL SE",
"email": "[email protected]"
}
]
],
"config": {
"allow-plugins": {
"php-http/discovery": false
}
}
}
134 changes: 129 additions & 5 deletions src/HttpClient.php → src/HttpClientWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,39 @@

namespace DeepL;

use Http\Discovery\Psr17FactoryDiscovery;
use Http\Discovery\Psr18Client;
use Http\Message\MultipartStream\MultipartStreamBuilder;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\RequestExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
use Psr\Http\Client\ClientInterface;

/**
* Internal class implementing HTTP requests.
* @private
*/
class HttpClient
class HttpClientWrapper
{
private $serverUrl;
private $headers;
private $maxRetries;
private $minTimeout;
private $logger;
private $proxy;
private $customHttpClient;
private $requestFactory;
/**
* PSR-18 client that is only used to construct the HTTP request, not to send it.
*/
private $streamClient;
private $streamFactory;

/**
* @var resource cURL handle.
* @var resource cURL handle, or null if using a custom HTTP client.
* @see HttpClientWrapper::__construct
*/
private $curlHandle;

Expand All @@ -37,20 +53,27 @@ public function __construct(
float $timeout,
int $maxRetries,
?LoggerInterface $logger,
?string $proxy
?string $proxy,
?ClientInterface $customHttpClient = null
) {
$this->serverUrl = $serverUrl;
$this->maxRetries = $maxRetries;
$this->minTimeout = $timeout;
$this->headers = $headers;
$this->logger = $logger;
$this->proxy = $proxy;
$this->curlHandle = \curl_init();
$this->customHttpClient = $customHttpClient;
$this->requestFactory = Psr17FactoryDiscovery::findRequestFactory();
$this->streamClient = new Psr18Client();
$this->streamFactory = Psr17FactoryDiscovery::findStreamFactory();
$this->curlHandle = $customHttpClient === null ? \curl_init() : null;
}

public function __destruct()
{
\curl_close($this->curlHandle);
if ($this->customHttpClient === null) {
\curl_close($this->curlHandle);
}
}

/**
Expand Down Expand Up @@ -114,6 +137,9 @@ public function sendRequestWithBackoff(string $method, string $url, ?array $opti
}

/**
* Sends a HTTP request. Note that in the case of a custom HTTP client, some of these options are
* ignored in favor of whatever is set in the client (e.g. timeouts and proxy). If we fall back to cURL,
* those options are respected.
* @param string $method HTTP method to use.
* @param string $url Absolute URL to query.
* @param float $timeout Time to wait before triggering timeout, in seconds.
Expand All @@ -132,6 +158,104 @@ private function sendRequest(
array $params,
?string $filePath,
$outFile
): array {
if ($this->customHttpClient !== null) {
return $this->sendCustomHttpRequest($method, $url, $headers, $params, $filePath, $outFile);
} else {
return $this->sendCurlRequest($method, $url, $timeout, $headers, $params, $filePath, $outFile);
}
}

/**
* Creates a PSR-7 compliant HTTP request with the given arguments.
* @param string $method HTTP method to use
* @param string $uri The URI for the request
* @param array $headers Array of headers for the request
* @param StreamInterface $body body to be used for the request.
* @return RequestInterface HTTP request object
*/
private function createHttpRequest(string $method, string $url, array $headers, StreamInterface $body)
{
$request = $this->requestFactory->createRequest($method, $url);
foreach ($headers as $header_key => $header_val) {
$request = $request->withHeader($header_key, $header_val);
}
$request = $request->withBody($body);
return $request;
}

/**
* Sends a HTTP request using the custom HTTP client.
* @param string $method HTTP method to use.
* @param string $url Absolute URL to query.
* @param array $headers Array of headers to include in request.
* @param array $params Array of parameters to include in body.
* @param string|null $filePath If not null, path to file to upload with request.
* @param resource|null $outFile If not null, file to write output to.
* @return array Array where the first element is the HTTP status code and the second element is the response body.
* @throws ConnectionException
*/
private function sendCustomHttpRequest(
string $method,
string $url,
array $headers,
array $params,
?string $filePath,
$outFile
): array {
$body = null;
if ($filePath !== null) {
$builder = new MultipartStreamBuilder($this->streamClient);
$builder->addResource('file', fopen($filePath, 'r'));
foreach ($params as $param_name => $value) {
$builder->addResource($param_name, $value);
}
$body = $builder->build();
$boundary = $builder->getBoundary();
$headers['Content-Type'] = "multipart/form-data; boundary=\"$boundary\"";
} elseif (count($params) > 0) {
$headers['Content-Type'] = 'application/x-www-form-urlencoded';
$body = $this->streamFactory->createStream(
$this->urlEncodeWithRepeatedParams($params)
);
} else {
$body = $this->streamFactory->createStream('');
}
$request = $this->createHttpRequest($method, $url, $headers, $body);
try {
$response = $this->customHttpClient->sendRequest($request);
$response_data = (string) $response->getBody();
if ($outFile) {
fwrite($outFile, $response_data);
}
return [$response->getStatusCode(), $response_data];
} catch (RequestExceptionInterface $e) {
throw new ConnectionException($e->getMessage(), $e->getCode(), null, false);
} catch (ClientExceptionInterface $e) {
throw new ConnectionException($e->getMessage(), $e->getCode(), null, true);
}
}

/**
* Sends a HTTP request using cURL
* @param string $method HTTP method to use.
* @param string $url Absolute URL to query.
* @param float $timeout Time to wait before triggering timeout, in seconds.
* @param array $headers Array of headers to include in request.
* @param array $params Array of parameters to include in body.
* @param string|null $filePath If not null, path to file to upload with request.
* @param resource|null $outFile If not null, file to write output to.
* @return array Array where the first element is the HTTP status code and the second element is the response body.
* @throws ConnectionException
*/
private function sendCurlRequest(
string $method,
string $url,
float $timeout,
array $headers,
array $params,
?string $filePath,
$outFile
): array {
$curlOptions = [];
$curlOptions[\CURLOPT_HEADER] = false;
Expand Down
32 changes: 22 additions & 10 deletions src/Translator.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public function __construct(string $authKey, array $options = [])
if ($authKey === '') {
throw new DeepLException('authKey must be a non-empty string');
}
// Validation is currently only logging warnings
$_ = TranslatorOptions::isValid($options);

$serverUrl = $options[TranslatorOptions::SERVER_URL] ??
(self::isAuthKeyFreeAccount($authKey) ? TranslatorOptions::DEFAULT_SERVER_URL_FREE
Expand Down Expand Up @@ -67,7 +69,17 @@ public function __construct(string $authKey, array $options = [])

$proxy = $options[TranslatorOptions::PROXY] ?? null;

$this->client = new HttpClient($serverUrl, $headers, $timeout, $maxRetries, $logger, $proxy);
$http_client = $options[TranslatorOptions::HTTP_CLIENT] ?? null;

$this->client = new HttpClientWrapper(
$serverUrl,
$headers,
$timeout,
$maxRetries,
$logger,
$proxy,
$http_client
);
}

/**
Expand Down Expand Up @@ -153,7 +165,7 @@ public function translateText($texts, ?string $sourceLang, string $targetLang, a
$response = $this->client->sendRequestWithBackoff(
'POST',
'/v2/translate',
[HttpClient::OPTION_PARAMS => $params]
[HttpClientWrapper::OPTION_PARAMS => $params]
);
$this->checkStatusCode($response);

Expand Down Expand Up @@ -240,8 +252,8 @@ public function uploadDocument(
'POST',
'/v2/document',
[
HttpClient::OPTION_PARAMS => $params,
HttpClient::OPTION_FILE => $inputFile,
HttpClientWrapper::OPTION_PARAMS => $params,
HttpClientWrapper::OPTION_FILE => $inputFile,
]
);
$this->checkStatusCode($response);
Expand Down Expand Up @@ -269,7 +281,7 @@ public function getDocumentStatus(DocumentHandle $handle): DocumentStatus
$response = $this->client->sendRequestWithBackoff(
'POST',
"/v2/document/$handle->documentId",
[HttpClient::OPTION_PARAMS => ['document_key' => $handle->documentKey]]
[HttpClientWrapper::OPTION_PARAMS => ['document_key' => $handle->documentKey]]
);
$this->checkStatusCode($response);
list(, $content) = $response;
Expand All @@ -292,8 +304,8 @@ public function downloadDocument(DocumentHandle $handle, string $outputFile): vo
'POST',
"/v2/document/$handle->documentId/result",
[
HttpClient::OPTION_PARAMS => ['document_key' => $handle->documentKey],
HttpClient::OPTION_OUTFILE => $outputFile,
HttpClientWrapper::OPTION_PARAMS => ['document_key' => $handle->documentKey],
HttpClientWrapper::OPTION_OUTFILE => $outputFile,
]
);
$this->checkStatusCode($response, true);
Expand Down Expand Up @@ -363,7 +375,7 @@ public function createGlossary(
$response = $this->client->sendRequestWithBackoff(
'POST',
'/v2/glossaries',
[HttpClient::OPTION_PARAMS => $params]
[HttpClientWrapper::OPTION_PARAMS => $params]
);
$this->checkStatusCode($response, false, true);
list(, $content) = $response;
Expand Down Expand Up @@ -404,7 +416,7 @@ public function createGlossaryFromCsv(
$response = $this->client->sendRequestWithBackoff(
'POST',
'/v2/glossaries',
[HttpClient::OPTION_PARAMS => $params]
[HttpClientWrapper::OPTION_PARAMS => $params]
);
$this->checkStatusCode($response, false, true);
list(, $content) = $response;
Expand Down Expand Up @@ -476,7 +488,7 @@ private function getLanguages(bool $target): array
$response = $this->client->sendRequestWithBackoff(
'GET',
'/v2/languages',
[HttpClient::OPTION_PARAMS => ['type' => $target ? 'target' : null]]
[HttpClientWrapper::OPTION_PARAMS => ['type' => $target ? 'target' : 'source']]
);
$this->checkStatusCode($response);
list(, $content) = $response;
Expand Down
Loading

0 comments on commit 46cae1c

Please sign in to comment.