From a1c7ac820f27d310d7fb3a63c94e666ed7fd8c9c Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 5 May 2023 13:34:49 +0200 Subject: [PATCH 1/5] [FEATURE] Rework and centralize api client operation --- Classes/Client.php | 158 +++++++ Classes/Configuration.php | 62 +++ .../Exception/ClientNotValidUrlException.php | 9 + Classes/Service/Client/Client.php | 257 ----------- Classes/Service/Client/ClientInterface.php | 60 --- Classes/Service/Client/DeepLException.php | 10 - Classes/Service/DeeplGlossaryService.php | 129 ++---- Classes/Service/DeeplService.php | 101 +---- Tests/Functional/ClientTest.php | 416 ++++++++++++++++++ Tests/Functional/ConfigurationTest.php | 43 ++ .../Functional/Services/DeeplServiceTest.php | 17 + composer.json | 1 + 12 files changed, 755 insertions(+), 508 deletions(-) create mode 100644 Classes/Client.php create mode 100644 Classes/Configuration.php create mode 100644 Classes/Exception/ClientNotValidUrlException.php delete mode 100644 Classes/Service/Client/Client.php delete mode 100644 Classes/Service/Client/ClientInterface.php delete mode 100644 Classes/Service/Client/DeepLException.php create mode 100644 Tests/Functional/ClientTest.php create mode 100644 Tests/Functional/ConfigurationTest.php diff --git a/Classes/Client.php b/Classes/Client.php new file mode 100644 index 00000000..bab6c77a --- /dev/null +++ b/Classes/Client.php @@ -0,0 +1,158 @@ +configuration = GeneralUtility::makeInstance(Configuration::class); + $this->requestFactory = GeneralUtility::makeInstance(RequestFactory::class); + } + + public function translate(string $content, string $sourceLang, string $targetLang, string $glossary = ''): ResponseInterface + { + $baseUrl = $this->buildBaseUrl('translate'); + + $postFields = [ + 'text' => $content, + 'source_lang' => $sourceLang, + 'target_lang' => $targetLang, + 'tag_handling' => 'xml', + ]; + + if (!empty($glossary)) { + $postFields['glossary_id'] = $glossary; + } + + $postFields['formality'] = $this->configuration->getFormality(); + + return $this->requestFactory->request($baseUrl, 'POST', $this->mergeRequiredRequestOptions([ + 'form_params' => $postFields, + ])); + } + + public function getSupportedTargetLanguage(string $type = 'target'): ResponseInterface + { + $baseUrl = $this->buildBaseUrl('languages?type=' . $type); + + return $this->requestFactory->request($baseUrl, 'GET', $this->mergeRequiredRequestOptions()); + } + + public function getGlossaryLanguagePairs(): ResponseInterface + { + $baseUrl = $this->buildBaseUrl('glossary-language-pairs'); + + return $this->requestFactory->request($baseUrl, 'GET', $this->mergeRequiredRequestOptions()); + } + + public function getAllGlossaries(): ResponseInterface + { + $baseUrl = $this->buildBaseUrl('glossaries'); + + return $this->requestFactory->request($baseUrl, 'GET', $this->mergeRequiredRequestOptions()); + } + + public function getGlossary(string $glossaryId): ResponseInterface + { + $baseUrl = $this->buildBaseUrl(sprintf('glossaries/%s', $glossaryId)); + + return $this->requestFactory->request($baseUrl, 'GET', $this->mergeRequiredRequestOptions()); + } + + public function createGlossary( + string $glossaryName, + string $sourceLang, + string $targetLang, + array $entries, + string $entriesFormat = 'tsv' + ): ResponseInterface { + $baseUrl = $this->buildBaseUrl('glossaries'); + + $postFields = [ + 'name' => $glossaryName, + 'source_lang' => $sourceLang, + 'target_lang' => $targetLang, + 'entries_format' => $entriesFormat, + ]; + + $formatEntries = ''; + foreach ($entries as $source => $target) { + $formatEntries .= sprintf(self::GLOSSARY_ENTRY_FORMAT, $source, $target); + } + + $postFields['entries'] = $formatEntries; + + return $this->requestFactory->request($baseUrl, 'POST', $this->mergeRequiredRequestOptions([ + 'form_params' => $postFields, + ])); + } + + public function deleteGlossary(string $glossaryId): ResponseInterface + { + $baseUrl = $this->buildBaseUrl(sprintf('glossaries/%s', $glossaryId)); + + return $this->requestFactory->request($baseUrl, 'DELETE', $this->mergeRequiredRequestOptions()); + } + + public function getGlossaryEntries(string $glossaryId): ResponseInterface + { + $baseUrl = $this->buildBaseUrl(sprintf('glossaries/%s/entries', $glossaryId)); + + return $this->requestFactory->request($baseUrl, 'GET', $this->mergeRequiredRequestOptions()); + } + + private function buildBaseUrl(string $path): string + { + $url = sprintf( + 'https://%s/%s/%s', + $this->configuration->getApiUrl(), + self::API_VERSION, + $path + ); + + if (!GeneralUtility::isValidUrl($url)) { + throw new ClientNotValidUrlException(sprintf('BaseURL "%s" is not valid', $url), 1676125513); + } + + return $url; + } + + /** + * @param array $options + * @return array + */ + private function mergeRequiredRequestOptions(array $options = []): array + { + return array_merge_recursive( + [ + 'headers' => [ + 'Authorization' => sprintf('DeepL-Auth-Key %s', $this->configuration->getApiKey()), + 'User-Agent' => 'TYPO3.WvDeepltranslate/1.0', + ], + ], + $options + ); + } +} diff --git a/Classes/Configuration.php b/Classes/Configuration.php new file mode 100644 index 00000000..fecdf7c4 --- /dev/null +++ b/Classes/Configuration.php @@ -0,0 +1,62 @@ +get('wv_deepltranslate'); + + if (isset($extensionConfiguration['apiKey'])) { + $this->apiKey = (string)$extensionConfiguration['apiKey'] ?? ''; + } + + if (isset($extensionConfiguration['apiUrl'])) { + // api url free is default + $this->apiUrl = (string)$extensionConfiguration['apiUrl'] ?? 'https://api-free.deepl.com/'; + } + + // In einer zukünftigen version sollte "Formality" in die SiteConfig verschoben werden + if (isset($extensionConfiguration['deeplFormality'])) { + $this->formality = (string)$extensionConfiguration['deeplFormality'] ?? 'default'; + } + } + + public function getApiKey(): string + { + return $this->apiKey; + } + + public function getApiUrl(): string + { + $parseUrl = parse_url($this->apiUrl); + + return $parseUrl['host'] . ($parseUrl['port'] ? sprintf(':%s', $parseUrl['port']) : '') ?? ''; + } + + public function getFormality(): string + { + return $this->formality; + } +} diff --git a/Classes/Exception/ClientNotValidUrlException.php b/Classes/Exception/ClientNotValidUrlException.php new file mode 100644 index 00000000..417e6ecb --- /dev/null +++ b/Classes/Exception/ClientNotValidUrlException.php @@ -0,0 +1,9 @@ +authKey = DeeplBackendUtility::getApiKey(); - // ugly, but only this way all functions will still keep alive, do better - // and detect - $this->host = parse_url(DeeplBackendUtility::getApiUrl(), PHP_URL_HOST); - } - - /** - * Make a request to the given URL - * - * @param string $url - * @param string $body - * @param string $method - * - * @return array|mixed|null - * - * @throws DeepLException - */ - public function request($url, $body = '', $method = 'POST') - { - $request = new Request( - $url, - $method, - null, - [ - 'Content-Type' => 'application/x-www-form-urlencoded', - 'Authorization' => sprintf('DeepL-Auth-Key %s', $this->authKey), - 'User-Agent' => 'TYPO3.WvDeepltranslate/1.0', - ] - ); - - $options = [ - 'body' => $body, - ]; - - // read TYPO3 Proxy settings and adapt - if (!empty($GLOBALS['TYPO3_CONF_VARS']['HTTP']['proxy'])) { - $httpProxy = $GLOBALS['TYPO3_CONF_VARS']['HTTP']['proxy']; - if (is_string($httpProxy)) { - $options['proxy'] = [ - 'http' => $httpProxy, - 'https' => $httpProxy, - ]; - } - if (is_array($httpProxy)) { - $options['proxy'] = [ - 'http' => $httpProxy['http'] ?: '', - 'https' => $httpProxy['https'] - ?: $httpProxy['http'] ?: '', - ]; - } - } - - $response = (new \GuzzleHttp\Client())->send($request, $options); - - if (in_array($response->getStatusCode(), self::HTTP_CODE_READ)) { - return json_decode($response->getBody()->getContents(), true); - } - - return []; - } - - /** - * Set a proxy to use for querying the DeepL API if needed - * - * @param string $proxy Proxy URL (e.g 'http://proxy-domain.com:3128') - */ - public function setProxy($proxy) - { - $this->proxy = $proxy; - } - - /** - * Set the proxy credentials - * - * @param string $proxyCredentials proxy credentials (using 'username:password' format) - */ - public function setProxyCredentials($proxyCredentials) - { - $this->proxyCredentials = $proxyCredentials; - } - - /** - * Set a timeout for queries to the DeepL API - * - * @param int $timeout Timeout in seconds - */ - public function setTimeout($timeout) - { - $this->timeout = $timeout; - } - - /** - * Creates the Base-Url which all the API-resources have in common. - * - * @param string $resource - * @return string - */ - public function buildBaseUrl(string $resource = 'translate'): string - { - return sprintf( - self::API_URL_BASE_NO_AUTH, - self::API_URL_SCHEMA, - $this->host, - $this->apiVersion, - $resource - ); - } - - /** - * @param array $paramsArray - * @return string - */ - public function buildQuery($paramsArray): string - { - if (isset($paramsArray['text']) && true === is_array($paramsArray['text'])) { - $text = $paramsArray['text']; - unset($paramsArray['text']); - $textString = ''; - foreach ($text as $textElement) { - $textString .= '&text=' . rawurlencode($textElement); - } - } - - foreach ($paramsArray as $key => $value) { - if (true === is_array($value)) { - $paramsArray[$key] = implode(',', $value); - } - } - - $body = http_build_query($paramsArray, '', '&'); - - if (isset($textString)) { - $body = $textString . '&' . $body; - } - - return $body; - } - - /** - * Handles the different kind of response returned from API, array, string or null - * - * @param string|bool $response - * @param int $httpCode - * @return array|mixed|null - * @throws DeepLException - */ - private function handleResponse($response, $httpCode) - { - $responseArray = json_decode($response, true); - if (($httpCode === 200 || $httpCode === 204) && is_null($responseArray)) { - return empty($response) ? null : $response; - } - - if ($httpCode !== 200 && is_array($responseArray) && array_key_exists('message', $responseArray)) { - if (str_contains($responseArray['message'], 'Unsupported')) { - // FlashMessage($message, $title, $severity = self::OK, $storeInSession) - $message = GeneralUtility::makeInstance( - FlashMessage::class, - $responseArray['message'], - 'DeepL Api', - FlashMessage::ERROR, - true - ); - $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); - $messageQueue = $flashMessageService->getMessageQueueByIdentifier(); - $messageQueue->addMessage($message); - } else { - throw new DeepLException($responseArray['message'], $httpCode); - } - } - - if (!is_array($responseArray)) { - throw new DeepLException('The Response seems to not be valid JSON.', $httpCode); - } - - return $responseArray; - } -} diff --git a/Classes/Service/Client/ClientInterface.php b/Classes/Service/Client/ClientInterface.php deleted file mode 100644 index 453e5211..00000000 --- a/Classes/Service/Client/ClientInterface.php +++ /dev/null @@ -1,60 +0,0 @@ -cache = $cache ?? GeneralUtility::makeInstance(CacheManager::class)->getCache('wvdeepltranslate'); - - $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('wv_deepltranslate'); - $this->apiKey = $extensionConfiguration['apiKey']; - $this->apiUrl = $extensionConfiguration['apiUrl']; - $this->apiUrl = parse_url($this->apiUrl, PHP_URL_HOST); // @TODO - Remove this line when we get only the host from ext config - - $this->client = $client ?? GeneralUtility::makeInstance( - Client::class, - $this->apiKey, - self::API_VERSION, - $this->apiUrl - ); + $this->client = $client ?? GeneralUtility::makeInstance(Client::class); $this->glossaryRepository = $glossaryRepository ?? GeneralUtility::makeInstance(GlossaryRepository::class); } @@ -82,24 +39,26 @@ public function __construct( * Calls the glossary-Endpoint and return Json-response as an array * * @return array - * * @throws DeepLException */ - public function listLanguagePairs() + public function listLanguagePairs(): array { - return $this->client->request($this->client->buildBaseUrl(self::API_URL_SUFFIX_GLOSSARIES_LANG_PAIRS), '', 'GET'); + $response = $this->client->getGlossaryLanguagePairs(); + + return json_decode($response->getBody()->getContents(), true); } /** * Calls the glossary-Endpoint and return Json-response as an array * * @return array - * * @throws DeepLException */ - public function listGlossaries() + public function listGlossaries(): array { - return $this->client->request($this->client->buildBaseUrl(self::API_URL_SUFFIX_GLOSSARIES), '', 'GET'); + $response = $this->client->getAllGlossaries(); + + return json_decode($response->getBody()->getContents(), true); } /** @@ -136,23 +95,9 @@ public function createGlossary( ); } - $formattedEntries = []; - foreach ($entries as $entry) { - $formattedEntries[] = sprintf("%s\t%s", trim($entry['source']), trim($entry['target'])); - } + $response = $this->client->createGlossary($name, $sourceLang, $targetLang, $entries); - $paramsArray = [ - 'name' => $name, - 'source_lang' => $sourceLang, - 'target_lang' => $targetLang, - 'entries' => implode("\n", $formattedEntries), - 'entries_format' => self::GLOSSARY_FORMAT, - ]; - - $url = $this->client->buildBaseUrl(self::API_URL_SUFFIX_GLOSSARIES); - $body = $this->client->buildQuery($paramsArray); - - return $this->client->request($url, $body); + return json_decode($response->getBody()->getContents(), true); } /** @@ -166,28 +111,25 @@ public function createGlossary( */ public function deleteGlossary(string $glossaryId): ?array { - $url = $this->client->buildBaseUrl(self::API_URL_SUFFIX_GLOSSARIES); - $url .= "/$glossaryId"; - try { - $this->client->request($url, '', 'DELETE'); + $this->client->deleteGlossary($glossaryId); } catch (BadResponseException $e) { // FlashMessage($message, $title, $severity = self::OK, $storeInSession) if (Environment::isCli()) { throw $e; - }else { - $message = GeneralUtility::makeInstance( - FlashMessage::class, - $e->getMessage(), - 'DeepL Api', - FlashMessage::WARNING, - true - ); - $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); - $messageQueue = $flashMessageService->getMessageQueueByIdentifier(); - $messageQueue->addMessage($message); } + $message = GeneralUtility::makeInstance( + FlashMessage::class, + $e->getMessage(), + 'DeepL Api', + FlashMessage::WARNING, + true + ); + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + $messageQueue = $flashMessageService->getMessageQueueByIdentifier(); + $messageQueue->addMessage($message); } + return null; } @@ -195,38 +137,33 @@ public function deleteGlossary(string $glossaryId): ?array * Gets information about a glossary * * @param string $glossaryId - * * @return array|null * * @throws DeepLException */ public function glossaryInformation(string $glossaryId): ?array { - $url = $this->client->buildBaseUrl(self::API_URL_SUFFIX_GLOSSARIES); - $url .= "/$glossaryId"; + $response = $this->client->getGlossary($glossaryId); - return $this->client->request($url, '', 'GET'); + return json_decode($response->getBody()->getContents(), true); } /** * Fetch glossary entries and format them as associative array [source => target] * * @param string $glossaryId - * * @return array - * * @throws DeepLException */ public function glossaryEntries(string $glossaryId): array { - $url = $this->client->buildBaseUrl(self::API_URL_SUFFIX_GLOSSARIES); - $url .= "/$glossaryId/entries"; + $response = $this->client->getGlossaryEntries($glossaryId); - $response = $this->client->request($url, '', 'GET'); + $jsons = json_decode($response->getBody()->getContents(), true); $entries = []; if (!empty($response)) { - $allEntries = explode("\n", $response); + $allEntries = explode("\n", $jsons); foreach ($allEntries as $entry) { $sourceAndTarget = preg_split('/\s+/', rtrim($entry)); if (isset($sourceAndTarget[0], $sourceAndTarget[1])) { diff --git a/Classes/Service/DeeplService.php b/Classes/Service/DeeplService.php index f6971433..193929d4 100644 --- a/Classes/Service/DeeplService.php +++ b/Classes/Service/DeeplService.php @@ -7,25 +7,18 @@ use GuzzleHttp\Exception\ClientException; use TYPO3\CMS\Core\Cache\CacheManager; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; -use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Http\Request; -use TYPO3\CMS\Core\Http\RequestFactory; use TYPO3\CMS\Core\Messaging\FlashMessage; use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Object\ObjectManager; +use WebVision\WvDeepltranslate\Client; use WebVision\WvDeepltranslate\Domain\Repository\GlossaryRepository; use WebVision\WvDeepltranslate\Domain\Repository\SettingsRepository; use WebVision\WvDeepltranslate\Utility\DeeplBackendUtility; class DeeplService { - public string $apiKey; - - public string $apiUrl; - - public string $deeplFormality; - /** * Default supported languages * @@ -43,26 +36,22 @@ class DeeplService */ public array $formalitySupportedLanguages = []; - public RequestFactory $requestFactory; - protected SettingsRepository $deeplSettingsRepository; protected GlossaryRepository $glossaryRepository; private FrontendInterface $cache; - public function __construct(?FrontendInterface $cache = null) - { + public function __construct( + ?FrontendInterface $cache = null, + ?Client $client = null + ) { $this->cache = $cache ?? GeneralUtility::makeInstance(CacheManager::class)->getCache('wvdeepltranslate'); + $this->client = $client ?? GeneralUtility::makeInstance(Client::class); + $this->glossaryRepository = GeneralUtility::makeInstance(GlossaryRepository::class); + $objectManager = GeneralUtility::makeInstance(ObjectManager::class); $this->deeplSettingsRepository = $objectManager->get(SettingsRepository::class); - $this->glossaryRepository = $objectManager->get(GlossaryRepository::class); - $this->requestFactory = GeneralUtility::makeInstance(RequestFactory::class); - - $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('wv_deepltranslate'); - $this->apiUrl = $extensionConfiguration['apiUrl']; - $this->apiKey = $extensionConfiguration['apiKey']; - $this->deeplFormality = $extensionConfiguration['deeplFormality']; $this->loadSupportedLanguages(); $this->apiSupportedLanguages['target'] = $this->deeplSettingsRepository->getSupportedLanguages($this->apiSupportedLanguages['target']); @@ -74,51 +63,16 @@ public function __construct(?FrontendInterface $cache = null) */ public function translateRequest($content, $targetLanguage, $sourceLanguage): array { - $postFields = [ - 'auth_key' => $this->apiKey, - 'text' => $content, - 'source_lang' => urlencode($sourceLanguage), - 'target_lang' => urlencode($targetLanguage), - 'tag_handling' => urlencode('xml'), - ]; - // TODO make glossary findable by current site // Implementation of glossary into translation - $glossary = $this->glossaryRepository - ->getGlossaryBySourceAndTarget( - $sourceLanguage, - $targetLanguage, - DeeplBackendUtility::detectCurrentPage() - ); - - // use glossary only, if is synced and DeepL marked ready - if ( - $glossary['glossary_id'] !== '' - && $glossary['glossary_ready'] === 1 - ) { - $postFields['glossary_id'] = $glossary['glossary_id']; - } - - if (!empty($this->deeplFormality) && in_array(strtoupper($targetLanguage), $this->formalitySupportedLanguages, true)) { - $postFields['formality'] = $this->deeplFormality; - } - //url-ify the data to get content length - $postFieldString = ''; - foreach ($postFields as $key => $value) { - $postFieldString .= $key . '=' . $value . '&'; - } - - $postFieldString = rtrim($postFieldString, '&'); - $contentLength = mb_strlen($postFieldString, '8bit'); + $glossary = $this->glossaryRepository->getGlossaryBySourceAndTarget( + $sourceLanguage, + $targetLanguage, + DeeplBackendUtility::detectCurrentPage() + ); try { - $response = $this->requestFactory->request($this->apiUrl, 'POST', [ - 'form_params' => $postFields, - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - 'Content-Length' => $contentLength, - ], - ]); + $response = $this->client->translate($content, $sourceLanguage, $targetLanguage, $glossary['glossary_id']); } catch (ClientException $e) { $flashMessage = GeneralUtility::makeInstance( FlashMessage::class, @@ -129,6 +83,7 @@ public function translateRequest($content, $targetLanguage, $sourceLanguage): ar GeneralUtility::makeInstance(FlashMessageService::class) ->getMessageQueueByIdentifier() ->addMessage($flashMessage); + return []; } @@ -166,32 +121,8 @@ private function loadSupportedLanguages(): void private function loadSupportedLanguagesFromAPI(string $type = 'target'): array { - $mainApiUrl = parse_url($this->apiUrl); - $languageApiUrl = sprintf( - '%s://%s/v2/languages?type=%s', - $mainApiUrl['scheme'], - $mainApiUrl['host'], - $type - ); - if ($mainApiUrl['port'] ?? false) { - $languageApiUrl = sprintf( - '%s://%s:%s/v2/languages?type=%s', - $mainApiUrl['scheme'], - $mainApiUrl['host'], - $mainApiUrl['port'], - $type - ); - } - - $headers = []; - if (!empty($this->apiKey)) { - $headers['Authorization'] = sprintf('DeepL-Auth-Key %s', $this->apiKey); - } - try { - $response = $this->requestFactory->request($languageApiUrl, 'GET', [ - 'headers' => $headers, - ]); + $response = $this->client->getSupportedTargetLanguage($type); } catch (ClientException $e) { return []; } diff --git a/Tests/Functional/ClientTest.php b/Tests/Functional/ClientTest.php new file mode 100644 index 00000000..6329b536 --- /dev/null +++ b/Tests/Functional/ClientTest.php @@ -0,0 +1,416 @@ +configurationToUseInTestInstance = array_merge( + $this->configurationToUseInTestInstance, + require __DIR__ . '/Fixtures/ExtensionConfig.php' + ); + } + + /** + * @test + */ + public function checkResponseFromTranslateContent(): void + { + $client = new Client(); + $response = $client->translate( + 'Ich möchte gern übersetzt werden!', + 'DE', + 'EN', + '' + ); + + static::assertSame(200, $response->getStatusCode()); + $content = $response->getBody()->getContents(); + static::assertJson($content); + } + + /** + * @test + */ + public function checkJsonTranslateContentIsValid(): void + { + $client = new Client(); + $response = $client->translate( + 'Ich möchte gern übersetzt werden!', + 'DE', + 'EN', + '' + ); + + $content = $response->getBody()->getContents(); + $jsonObject = json_decode($content); + + static::assertJsonDocumentMatches($jsonObject, [ + '$.translations' => new IsType(IsType::TYPE_ARRAY), + '$.translations[*].text' => new IsType(IsType::TYPE_STRING), + ]); + + static::assertJsonValueEquals( + $jsonObject, + '$.translations[*].text', + 'I would like to be translated!' + ); + } + + /** + * @test + */ + public function checkResponseFromSupportedTargetLanguage(): void + { + $client = new Client(); + $response = $client->getSupportedTargetLanguage(); + + static::assertSame(200, $response->getStatusCode()); + $content = $response->getBody()->getContents(); + static::assertJson($content); + } + + /** + * @test + */ + public function checkJsonFromSupportedTargetLanguageIsValid(): void + { + $client = new Client(); + $response = $client->getSupportedTargetLanguage(); + + $content = $response->getBody()->getContents(); + $jsonObject = json_decode($content); + + static::assertJsonDocumentMatches($jsonObject, [ + '$.' => new IsType(IsType::TYPE_ARRAY), + '$.[*].language' => new IsType(IsType::TYPE_STRING), + ]); + static::assertJsonValueEquals($jsonObject, '$.[*].language', 'EN-GB'); + static::assertJsonValueEquals($jsonObject, '$.[*].language', 'EN-US'); + static::assertJsonValueEquals($jsonObject, '$.[*].language', 'DE'); + static::assertJsonValueEquals($jsonObject, '$.[*].language', 'UK'); + } + + /** + * @test + */ + public function checkResponseFromGlossaryLanguagePairs(): void + { + $client = new Client(); + $response = $client->getGlossaryLanguagePairs(); + + static::assertSame(200, $response->getStatusCode()); + $content = $response->getBody()->getContents(); + static::assertJson($content); + } + + /** + * @test + */ + public function checkJsonFromGlossaryLanguagePairsIsValid(): void + { + $client = new Client(); + $response = $client->getGlossaryLanguagePairs(); + + $content = $response->getBody()->getContents(); + $jsonObject = json_decode($content); + + static::assertJsonDocumentMatches($jsonObject, [ + '$.supported_languages' => new IsType(IsType::TYPE_ARRAY), + '$.supported_languages[*].source_lang' => new IsType(IsType::TYPE_STRING), + '$.supported_languages[*].target_lang' => new IsType(IsType::TYPE_STRING), + ]); + } + + /** + * @test + */ + public function checkResponseFromCreateGlossary(): void + { + $client = new Client(); + $response = $client->createGlossary( + 'Deepl-Client-Create-Function-Test:' . __FUNCTION__, + 'de', + 'en', + [ + 'hallo Welt' => 'hello world', + ], + ); + + static::assertSame(201, $response->getStatusCode()); + $content = $response->getBody()->getContents(); + static::assertJson($content); + + $jsonObject = json_decode($content, true); + $this->glossaryIdStorage[] = $jsonObject['glossary_id']; + } + + /** + * @test + */ + public function checkJsonFromCreateGlossaryIsValid(): void + { + $client = new Client(); + $response = $client->createGlossary( + 'Deepl-Client-Create-Function-Test:' . __FUNCTION__, + 'de', + 'en', + [ + 'hallo Welt' => 'hello world', + ], + ); + + $content = $response->getBody()->getContents(); + $jsonObject = json_decode($content, true); + $this->glossaryIdStorage[] = $jsonObject['glossary_id']; + + static::assertJsonDocumentMatches($jsonObject, [ + '$.glossary_id' => new IsType(IsType::TYPE_STRING), + '$.ready' => new IsType(IsType::TYPE_BOOL), + '$.name' => new IsType(IsType::TYPE_STRING), + '$.source_lang' => new IsType(IsType::TYPE_STRING), + '$.target_lang' => new IsType(IsType::TYPE_STRING), + '$.entry_count' => new IsType(IsType::TYPE_INT), + ]); + + static::assertJsonValueEquals($jsonObject, '$.name', 'Deepl-Client-Create-Function-Test:' . __FUNCTION__); + static::assertJsonValueEquals($jsonObject, '$.source_lang', 'de'); + static::assertJsonValueEquals($jsonObject, '$.target_lang', 'en'); + } + + /** + * @test + */ + public function checkResponseGetAllGlossaries(): void + { + $client = new Client(); + $response = $client->getAllGlossaries(); + + static::assertSame(200, $response->getStatusCode()); + $content = $response->getBody()->getContents(); + static::assertJson($content); + } + + /** + * @test + */ + public function checkJsonFromGetAllGlossariesIsValid(): void + { + $client = new Client(); + $createResponse = $client->createGlossary( + 'Deepl-Client-Create-Function-Test:' . __FUNCTION__, + 'de', + 'en', + [ + 'hallo Welt' => 'hello world', + ], + ); + + $createResponse = json_decode($createResponse->getBody()->getContents(), true); + $this->glossaryIdStorage[] = $createResponse['glossary_id']; + + $response = $client->getAllGlossaries(); + + $content = $response->getBody()->getContents(); + $jsonObject = json_decode($content, true); + + static::assertJsonDocumentMatches($jsonObject, [ + '$.glossaries' => new IsType(IsType::TYPE_ARRAY), + '$.glossaries[*].glossary_id' => new IsType(IsType::TYPE_STRING), + '$.glossaries[*].ready' => new IsType(IsType::TYPE_BOOL), + '$.glossaries[*].name' => new IsType(IsType::TYPE_STRING), + '$.glossaries[*].source_lang' => new IsType(IsType::TYPE_STRING), + '$.glossaries[*].target_lang' => new IsType(IsType::TYPE_STRING), + '$.glossaries[*].entry_count' => new IsType(IsType::TYPE_INT), + ]); + + static::assertJsonValueEquals($jsonObject, '$.glossaries[*].name', 'Deepl-Client-Create-Function-Test:' . __FUNCTION__); + static::assertJsonValueEquals($jsonObject, '$.glossaries[*].source_lang', 'de'); + static::assertJsonValueEquals($jsonObject, '$.glossaries[*].target_lang', 'en'); + } + + /** + * @test + */ + public function checkResponseFromGetGlossary(): void + { + $client = new Client(); + $createResponse = $client->createGlossary( + 'Deepl-Client-Create-Function-Test:' . __FUNCTION__, + 'de', + 'en', + [ + 'hallo Welt' => 'hello world', + ], + ); + + $createResponse = json_decode($createResponse->getBody()->getContents(), true); + $this->glossaryIdStorage[] = $createResponse['glossary_id']; + + $response = $client->getGlossary($createResponse['glossary_id']); + static::assertSame(200, $response->getStatusCode()); + $content = $response->getBody()->getContents(); + static::assertJson($content); + } + + /** + * @test + */ + public function checkJsonFromGetGlossaryIsValid(): void + { + $client = new Client(); + $createResponse = $client->createGlossary( + 'Deepl-Client-Create-Function-Test:' . __FUNCTION__, + 'de', + 'en', + [ + 'hallo Welt' => 'hello world', + ], + ); + + $createResponse = json_decode($createResponse->getBody()->getContents(), true); + $this->glossaryIdStorage[] = $createResponse['glossary_id']; + + $response = $client->getGlossary($createResponse['glossary_id']); + $content = $response->getBody()->getContents(); + $jsonObject = json_decode($content, true); + + static::assertJsonDocumentMatches($jsonObject, [ + '$.glossary_id' => new IsType(IsType::TYPE_STRING), + '$.ready' => new IsType(IsType::TYPE_BOOL), + '$.name' => new IsType(IsType::TYPE_STRING), + '$.source_lang' => new IsType(IsType::TYPE_STRING), + '$.target_lang' => new IsType(IsType::TYPE_STRING), + '$.entry_count' => new IsType(IsType::TYPE_INT), + ]); + + static::assertJsonValueEquals($jsonObject, '$.name', 'Deepl-Client-Create-Function-Test:' . __FUNCTION__); + static::assertJsonValueEquals($jsonObject, '$.source_lang', 'de'); + static::assertJsonValueEquals($jsonObject, '$.target_lang', 'en'); + } + + /** + * @test + */ + public function checkResponseFromDeleteGlossary(): void + { + $client = new Client(); + $createResponse = $client->createGlossary( + 'Deepl-Client-Create-Function-Test' . __FUNCTION__, + 'de', + 'en', + [ + 'hallo Welt' => 'hello world', + ], + ); + + $createResponse = json_decode($createResponse->getBody()->getContents(), true); + $this->glossaryIdStorage[] = $createResponse['glossary_id']; + $response = $client->deleteGlossary($createResponse['glossary_id']); + static::assertSame(204, $response->getStatusCode()); + + $key = array_search($createResponse['glossary_id'], $this->glossaryIdStorage); + unset($this->glossaryIdStorage[$key]); + } + + /** + * @test + */ + public function checkResponseFromGetGlossaryEntries(): void + { + $client = new Client(); + $createResponse = $client->createGlossary( + 'Deepl-Client-Create-Function-Test:' . __FUNCTION__, + 'de', + 'en', + [ + 'hallo Welt' => 'hello world', + ], + ); + + $createResponse = json_decode($createResponse->getBody()->getContents(), true); + $this->glossaryIdStorage[] = $createResponse['glossary_id']; + $response = $client->getGlossaryEntries($createResponse['glossary_id']); + + static::assertSame(200, $response->getStatusCode()); + $content = $response->getBody()->getContents(); + static::assertIsString($content); + } + + /** + * @test + */ + public function checkTextFromGetGlossaryEntriesIsValid(): void + { + $client = new Client(); + $createResponse = $client->createGlossary( + 'Deepl-Client-Create-Function-Test:' . __FUNCTION__, + 'de', + 'en', + [ + 'hallo Welt' => 'hello world', + ], + ); + + $createResponse = json_decode($createResponse->getBody()->getContents(), true); + $this->glossaryIdStorage[] = $createResponse['glossary_id']; + $response = $client->getGlossaryEntries($createResponse['glossary_id']); + + $content = $response->getBody()->getContents(); + + static::assertSame($content, sprintf("%s\t%s", 'hallo Welt', 'hello world')); + } + + protected function tearDown(): void + { + parent::tearDown(); + + if (!empty($this->glossaryIdStorage)) { + $requestFactory = GeneralUtility::makeInstance(RequestFactory::class); + $configuration = GeneralUtility::makeInstance(Configuration::class); + + foreach ($this->glossaryIdStorage as $glossaryId) { + $baseUrl = sprintf( + 'https://%s/v2/glossaries/%s', + $configuration->getApiUrl(), + $glossaryId + ); + + $requestFactory->request($baseUrl, 'DELETE', [ + 'headers' => [ + 'Authorization' => sprintf('DeepL-Auth-Key %s', $configuration->getApiKey()), + ], + ]); + } + } + } +} diff --git a/Tests/Functional/ConfigurationTest.php b/Tests/Functional/ConfigurationTest.php new file mode 100644 index 00000000..5e64fefb --- /dev/null +++ b/Tests/Functional/ConfigurationTest.php @@ -0,0 +1,43 @@ +configurationToUseInTestInstance = array_merge( + $this->configurationToUseInTestInstance, + require __DIR__ . '/Fixtures/ExtensionConfig.php' + ); + } + + /** + * @test + */ + public function checkApiParseUrlAndGiveOnlyDomain(): void + { + $configuration = GeneralUtility::makeInstance(Configuration::class); + + if (defined('DEEPL_API_KEY') && getenv('DEEPL_API_KEY') !== '') { + static::assertSame('api-free.deepl.com', $configuration->getApiUrl()); + } else { + static::assertSame('ddev-deepltranslate-deeplmockserver:3000', $configuration->getApiUrl()); + } + } +} diff --git a/Tests/Functional/Services/DeeplServiceTest.php b/Tests/Functional/Services/DeeplServiceTest.php index f56ae7c8..f81dc98a 100644 --- a/Tests/Functional/Services/DeeplServiceTest.php +++ b/Tests/Functional/Services/DeeplServiceTest.php @@ -33,6 +33,23 @@ protected function setUp(): void $this->importDataSet(__DIR__ . '/../Fixtures/Language.xml'); } + /** + * @test + */ + public function translateContentFromDeToEn(): void + { + $deeplService = GeneralUtility::makeInstance(DeeplService::class); + + $responseObject = $deeplService->translateRequest( + 'Ich möchte gern übersetzt werden!', + 'EN', + 'DE', + '' + ); + + static::assertSame('I would like to be translated!', $responseObject['translations'][0]['text']); + } + /** * @test */ diff --git a/composer.json b/composer.json index e2caf378..1747b90d 100644 --- a/composer.json +++ b/composer.json @@ -74,6 +74,7 @@ "b13/container": "^1.6", "friendsofphp/php-cs-fixer": "^3.0", "helhum/typo3-console": "^5.8 || ^6.7 || ^7.1", + "helmich/phpunit-json-assert": "3.4.3", "helmich/typo3-typoscript-lint": "^2.5", "nikic/php-parser": "^4.15.1", "nimut/testing-framework": "^6.0", From 5ca809889d46ef5f3cb64bc31202757f09bd01f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Fri, 5 May 2023 15:49:10 +0200 Subject: [PATCH 2/5] [TASK] Skip one test when `DEEPLMOCKAPISERVER` is used The `DEEPLMOCKAPISERVER` is only a really rudimentary api implementation and do not simulate "full" api in any way. That means, that not all source/target languages are supported like for production api. Therefore, one test is skipped now when `DEEPLMOCKAPISERVER` is in use because of not supporting `EN` as target language. An additionally test is added to test from `EN` to `DE` in anny case. Furthermore, when mock server is used the supported content and and translated strings are used for test expectations. --- .../Functional/Services/DeeplServiceTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Tests/Functional/Services/DeeplServiceTest.php b/Tests/Functional/Services/DeeplServiceTest.php index f81dc98a..15995ac9 100644 --- a/Tests/Functional/Services/DeeplServiceTest.php +++ b/Tests/Functional/Services/DeeplServiceTest.php @@ -38,6 +38,9 @@ protected function setUp(): void */ public function translateContentFromDeToEn(): void { + if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { + self::markTestSkipped(__METHOD__ . ' skipped, because DEEPL MOCKSERVER do not support EN as TARGET language.'); + } $deeplService = GeneralUtility::makeInstance(DeeplService::class); $responseObject = $deeplService->translateRequest( @@ -50,6 +53,29 @@ public function translateContentFromDeToEn(): void static::assertSame('I would like to be translated!', $responseObject['translations'][0]['text']); } + /** + * @test + */ + public function translateContentFromEnToDe(): void + { + $translateContent = 'I would like to be translated!'; + $expectedTranslation = 'Ich möchte gern übersetzt werden!'; + if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { + $translateContent = 'proton beam'; + $expectedTranslation = 'Protonenstrahl'; + } + $deeplService = GeneralUtility::makeInstance(DeeplService::class); + + $responseObject = $deeplService->translateRequest( + $translateContent, + 'DE', + 'EN', + '' + ); + + static::assertSame($expectedTranslation, $responseObject['translations'][0]['text']); + } + /** * @test */ From c1c263a4e1fda05bacac4dae52b19830c8a8e53a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Fri, 5 May 2023 15:55:37 +0200 Subject: [PATCH 3/5] [TASK] Use `DEEPLMOCKAPISERVER` supported content and translation The `DEEPLMOCKAPISERVER` is only a really rudimentary api implementation and do not simulate "full" api in any way. That means, that not all source/target languages are supported like for production api. Therefore, when mock server is used, the supported content and and translated strings are used for test expectations. --- Tests/Functional/ClientTest.php | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Tests/Functional/ClientTest.php b/Tests/Functional/ClientTest.php index 6329b536..fee94498 100644 --- a/Tests/Functional/ClientTest.php +++ b/Tests/Functional/ClientTest.php @@ -45,11 +45,15 @@ public function __construct(...$arguments) */ public function checkResponseFromTranslateContent(): void { + $translateContent = 'I would like to be translated!'; + if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { + $translateContent = 'proton beam'; + } $client = new Client(); $response = $client->translate( - 'Ich möchte gern übersetzt werden!', - 'DE', + $translateContent, 'EN', + 'DE', '' ); @@ -63,11 +67,17 @@ public function checkResponseFromTranslateContent(): void */ public function checkJsonTranslateContentIsValid(): void { + $translateContent = 'I would like to be translated!'; + $expectedTranslation = 'Ich möchte gern übersetzt werden!'; + if (defined('DEEPL_MOCKSERVER_USED') && DEEPL_MOCKSERVER_USED === true) { + $translateContent = 'proton beam'; + $expectedTranslation = 'Protonenstrahl'; + } $client = new Client(); $response = $client->translate( - 'Ich möchte gern übersetzt werden!', - 'DE', + $translateContent, 'EN', + 'DE', '' ); @@ -82,7 +92,7 @@ public function checkJsonTranslateContentIsValid(): void static::assertJsonValueEquals( $jsonObject, '$.translations[*].text', - 'I would like to be translated!' + $expectedTranslation ); } From f9a9701831c457777fb0c53f8eb4efc95cda493e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Fri, 5 May 2023 15:59:05 +0200 Subject: [PATCH 4/5] [TASK] Use `DEEPLMOCKAPISERVER` supported content and translation The `DEEPLMOCKAPISERVER` is only a really rudimentary api implementation and do not simulate "full" api in any way. That means, that not all source/target languages are supported like for production api. Therefore, when mock server is used, the supported content and and translated strings are used for test expectations. [TASK] Donate fine grained method to retrieve api url parts `\WebVision\WvDeepltranslate\Configuration` transports the required configuration settings through the system, which is a good approach. This change donates more fine grained read methods to this class, to access api url parts. This is a preparation for an upcoming change. --- Classes/Configuration.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Classes/Configuration.php b/Classes/Configuration.php index fecdf7c4..18574c74 100644 --- a/Classes/Configuration.php +++ b/Classes/Configuration.php @@ -50,9 +50,24 @@ public function getApiKey(): string public function getApiUrl(): string { - $parseUrl = parse_url($this->apiUrl); + return $this->getApiHost() + . ($this->getApiPort() ? ':' . $this->getApiPort() : ''); + } + + public function getApiScheme(): string + { + return parse_url($this->apiUrl)['scheme'] ?? 'https'; + } - return $parseUrl['host'] . ($parseUrl['port'] ? sprintf(':%s', $parseUrl['port']) : '') ?? ''; + public function getApiHost(): string + { + return parse_url($this->apiUrl)['host'] ?? 'localhost'; + } + + public function getApiPort(): ?int + { + $port = parse_url($this->apiUrl)['port'] ?? null; + return $port ? (int)$port : null; } public function getFormality(): string From be842350fc767ce7dc9eaebd7caff226467aa9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Fri, 5 May 2023 16:03:20 +0200 Subject: [PATCH 5/5] [TASK] Respect the url scheme of configured api url In a couple of places, `https` has been hardcoded enforced as request scheme, ignoring the scheme of the configured api url. This may seem to be a "security" enforcement, but these kind of overrides are not very user friendly and hidden magic is always a pain in the ass for debugging issues. This change now re-uses the configured api url scheme when building api requests. One test is adopted to do the same. The donated methods on the `Configuration` class to access api url parts are used for this. --- Classes/Client.php | 3 ++- Tests/Functional/ClientTest.php | 3 ++- Tests/Functional/ConfigurationTest.php | 10 +++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Classes/Client.php b/Classes/Client.php index bab6c77a..7baba63f 100644 --- a/Classes/Client.php +++ b/Classes/Client.php @@ -126,7 +126,8 @@ public function getGlossaryEntries(string $glossaryId): ResponseInterface private function buildBaseUrl(string $path): string { $url = sprintf( - 'https://%s/%s/%s', + '%s://%s/%s/%s', + $this->configuration->getApiScheme(), $this->configuration->getApiUrl(), self::API_VERSION, $path diff --git a/Tests/Functional/ClientTest.php b/Tests/Functional/ClientTest.php index fee94498..ca1d8276 100644 --- a/Tests/Functional/ClientTest.php +++ b/Tests/Functional/ClientTest.php @@ -410,7 +410,8 @@ protected function tearDown(): void foreach ($this->glossaryIdStorage as $glossaryId) { $baseUrl = sprintf( - 'https://%s/v2/glossaries/%s', + '%s://%s/v2/glossaries/%s', + $configuration->getApiScheme(), $configuration->getApiUrl(), $glossaryId ); diff --git a/Tests/Functional/ConfigurationTest.php b/Tests/Functional/ConfigurationTest.php index 5e64fefb..296d7f81 100644 --- a/Tests/Functional/ConfigurationTest.php +++ b/Tests/Functional/ConfigurationTest.php @@ -37,7 +37,15 @@ public function checkApiParseUrlAndGiveOnlyDomain(): void if (defined('DEEPL_API_KEY') && getenv('DEEPL_API_KEY') !== '') { static::assertSame('api-free.deepl.com', $configuration->getApiUrl()); } else { - static::assertSame('ddev-deepltranslate-deeplmockserver:3000', $configuration->getApiUrl()); + $parsedUrl = parse_url( + $this->configurationToUseInTestInstance['EXTENSIONS']['wv_deepltranslate']['apiUrl'] + ?? 'http://ddev-deepltranslate-deeplmockserver:3000' + ); + $checkApiUrl = $parsedUrl['host'] . ($parsedUrl['port'] ? ':' . $parsedUrl['port'] : ''); + static::assertSame( + $checkApiUrl, + $configuration->getApiUrl() + ); } } }