From 46cae1c5c5f3b50d25a8468a41e61d25f51da228 Mon Sep 17 00:00:00 2001 From: Jan Ebbing Date: Wed, 24 May 2023 08:44:38 +0100 Subject: [PATCH] feat: Allow configuring PSR-18 compliant HTTP client used. --- CHANGELOG.md | 3 + README.md | 15 ++ composer.json | 14 +- src/{HttpClient.php => HttpClientWrapper.php} | 134 +++++++++++++++++- src/Translator.php | 32 +++-- src/TranslatorOptions.php | 66 +++++++++ 6 files changed, 247 insertions(+), 17 deletions(-) rename src/{HttpClient.php => HttpClientWrapper.php} (61%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a16d427..0ca8ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2ec7364..6d727d1 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/composer.json b/composer.json index 9b4cce7..1953007 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -38,5 +43,10 @@ "name": "DeepL SE", "email": "open-source@deepl.com" } - ] + ], + "config": { + "allow-plugins": { + "php-http/discovery": false + } + } } diff --git a/src/HttpClient.php b/src/HttpClientWrapper.php similarity index 61% rename from src/HttpClient.php rename to src/HttpClientWrapper.php index 8b11598..fc311b7 100644 --- a/src/HttpClient.php +++ b/src/HttpClientWrapper.php @@ -6,13 +6,21 @@ 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; @@ -20,9 +28,17 @@ class HttpClient 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; @@ -37,7 +53,8 @@ public function __construct( float $timeout, int $maxRetries, ?LoggerInterface $logger, - ?string $proxy + ?string $proxy, + ?ClientInterface $customHttpClient = null ) { $this->serverUrl = $serverUrl; $this->maxRetries = $maxRetries; @@ -45,12 +62,18 @@ public function __construct( $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); + } } /** @@ -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. @@ -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; diff --git a/src/Translator.php b/src/Translator.php index 24f2ca3..0adf32c 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -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 @@ -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 + ); } /** @@ -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); @@ -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); @@ -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; @@ -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); @@ -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; @@ -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; @@ -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; diff --git a/src/TranslatorOptions.php b/src/TranslatorOptions.php index 9ba0ccc..58a174a 100644 --- a/src/TranslatorOptions.php +++ b/src/TranslatorOptions.php @@ -6,12 +6,39 @@ namespace DeepL; +use Psr\Log\LoggerInterface; + /** * Options that can be specified when constructing a Translator. + * Please note that using any options from TranslatorOptions::IGNORED_OPTIONS_WITH_CUSTOM_HTTP_CLIENT, + * such as proxy or timeout, are ignored when using a custom HTTP client. * @see Translator::__construct */ class TranslatorOptions { + /** + * Array of all strings in this class that are used to reference translator options in the options array. + * If you add a new option, please add it here as well for proper validation. + * The value for each key does not matter. + */ + private const OPTIONS_KEYS = [ + TranslatorOptions::SERVER_URL => true, + TranslatorOptions::HEADERS => true, + TranslatorOptions::TIMEOUT => true, + TranslatorOptions::MAX_RETRIES => true, + TranslatorOptions::PROXY => true, + TranslatorOptions::LOGGER => true, + TranslatorOptions::HTTP_CLIENT => true, + TranslatorOptions::SEND_PLATFORM_INFO => true, + TranslatorOptions::APP_INFO => true, + ]; + + /** List of all options that are ignored when using a custom HTTP client. */ + private const IGNORED_OPTIONS_WITH_CUSTOM_HTTP_CLIENT = [ + TranslatorOptions::TIMEOUT, + TranslatorOptions::PROXY, + ]; + /** * Base URL of DeepL API, can be overridden for example for testing purposes. By default, the correct DeepL API URL * is selected based on the user account type (free or paid). @@ -50,6 +77,11 @@ class TranslatorOptions */ public const LOGGER = 'logger'; + /** + * The PSR-18 compatible HTTP client used to make HTTP requests, or null to use the default client. + */ + public const HTTP_CLIENT = 'http_client'; + /** The default server URL used for DeepL API Pro accounts (if SERVER_URL is unspecified). */ public const DEFAULT_SERVER_URL = 'https://api.deepl.com'; @@ -71,4 +103,38 @@ class TranslatorOptions /** Name and version of the application that uses this client library. */ public const APP_INFO = 'app_info'; + + /** + * Validates the options array passed to the Translator object. + */ + public static function isValid(array $options): bool + { + $is_valid = true; + $maybe_logger = $options[TranslatorOptions::LOGGER] ?? null; + if (isset($options[TranslatorOptions::HTTP_CLIENT])) { + foreach (TranslatorOptions::IGNORED_OPTIONS_WITH_CUSTOM_HTTP_CLIENT as $ignored_option) { + $is_valid &= !TranslatorOptions::isIgnoredOptionSet($ignored_option, $options, $maybe_logger); + } + } + foreach ($options as $option_key => $option_value) { + if (!array_key_exists($option_key, TranslatorOptions::OPTIONS_KEYS)) { + if ($maybe_logger !== null) { + $maybe_logger->warning("Option $option_key is not recognized and thus ignored."); + } + $is_valid = false; + } + } + return $is_valid; + } + + private static function isIgnoredOptionSet(string $keyToCheck, array $options, ?LoggerInterface $maybe_logger): bool + { + if (array_key_exists($keyToCheck, $options)) { + if ($maybe_logger !== null) { + $maybe_logger->warning("Option $keyToCheck is ignored as a custom HTTP client is used."); + } + return true; + } + return false; + } }