Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.18% covered (success)
95.18%
158 / 166
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AmazonTranslationProvider
95.18% covered (success)
95.18%
158 / 166
75.00% covered (warning)
75.00%
6 / 8
19
0.00% covered (danger)
0.00%
0 / 1
 doTranslate
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
9.02
 doTranslateBatch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 translateBatch
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
3.69
 mapLanguageCode
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 supportsLanguagePair
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getSupportedLanguages
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
1 / 1
1
 getProviderName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAwsSignedHeaders
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Amazon Translate translation provider implementation.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-01-18
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Translation\Provider;
21
22use phpMyFAQ\Translation\AbstractTranslationProvider;
23use phpMyFAQ\Translation\Exception\ApiException;
24use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
25
26/**
27 * Class AmazonTranslationProvider
28 *
29 * Implements translation using Amazon Translate API.
30 * API Documentation: https://docs.aws.amazon.com/translate/
31 */
32class AmazonTranslationProvider extends AbstractTranslationProvider
33{
34    private const string API_ENDPOINT_TEMPLATE = 'https://translate.%s.amazonaws.com/';
35    private const string API_VERSION = '20170701';
36    private const string SERVICE_NAME = 'translate';
37
38    /**
39     * Performs the actual translation using Amazon Translate API.
40     *
41     * @param string $text Text to translate
42     * @param string $sourceLang Source language code
43     * @param string $targetLang Target language code
44     * @return string Translated text
45     * @throws ApiException
46     */
47    protected function doTranslate(string $text, string $sourceLang, string $targetLang): string
48    {
49        $configuredRegion = (string) ($this->configuration->get('translation.amazonRegion') ?? '');
50        $region = $configuredRegion !== '' ? $configuredRegion : 'us-east-1';
51        $accessKeyId = (string) ($this->configuration->get('translation.amazonAccessKeyId') ?? '');
52        $secretAccessKey = (string) ($this->configuration->get('translation.amazonSecretAccessKey') ?? '');
53
54        if ($accessKeyId === '' || $secretAccessKey === '') {
55            throw new ApiException('Amazon Translate API credentials not configured');
56        }
57
58        $endpoint = sprintf(self::API_ENDPOINT_TEMPLATE, $region);
59
60        // Map language codes if needed
61        $sourceLang = $this->mapLanguageCode($sourceLang);
62        $targetLang = $this->mapLanguageCode($targetLang);
63
64        // Prepare request payload
65        $payload = json_encode([
66            'Text' => $text,
67            'SourceLanguageCode' => $sourceLang,
68            'TargetLanguageCode' => $targetLang,
69        ]);
70        if ($payload === false) {
71            throw new ApiException('Cannot encode the Amazon Translate API request payload');
72        }
73
74        // AWS Signature V4
75        $headers = $this->getAwsSignedHeaders('POST', $payload, $region, $accessKeyId, $secretAccessKey);
76
77        try {
78            $response = $this->httpClient->request('POST', $endpoint, [
79                'headers' => $headers,
80                'body' => $payload,
81            ]);
82
83            $statusCode = $response->getStatusCode();
84
85            if ($statusCode !== 200) {
86                throw new ApiException('Amazon Translate API error: HTTP ' . $statusCode);
87            }
88
89            $data = json_decode(json: $response->getContent(), associative: true);
90
91            if (!is_array($data) || !array_key_exists('TranslatedText', $data)) {
92                throw new ApiException('Invalid response from Amazon Translate API');
93            }
94
95            return (string) $data['TranslatedText'];
96        } catch (TransportExceptionInterface $e) {
97            throw new ApiException('Amazon Translate API request failed: ' . $e->getMessage(), 0, $e);
98        }
99    }
100
101    /**
102     * Performs batch translation (Amazon Translate doesn't have a native batch API, so we loop).
103     *
104     * @param array<string> $texts Array of texts to translate
105     * @param string $sourceLang Source language code
106     * @param string $targetLang Target language code
107     * @return array<string> Array of translated texts
108     * @throws ApiException
109     */
110    protected function doTranslateBatch(array $texts, string $sourceLang, string $targetLang): array
111    {
112        $results = [];
113        foreach ($texts as $text) {
114            $results[] = $this->doTranslate($text, $sourceLang, $targetLang);
115        }
116        return $results;
117    }
118
119    /**
120     * @inheritDoc
121     */
122    public function translateBatch(
123        array $texts,
124        string $sourceLang,
125        string $targetLang,
126        bool $preserveHtml = false,
127    ): array {
128        if ($preserveHtml) {
129            // Process each text individually with HTML preservation
130            return array_map(fn($text) => $this->translate(
131                $text,
132                $sourceLang,
133                $targetLang,
134                preserveHtml: true,
135            ), $texts);
136        }
137
138        return $this->doTranslateBatch($texts, $sourceLang, $targetLang);
139    }
140
141    /**
142     * Map language codes to Amazon Translate format.
143     * Amazon uses standard ISO 639-1 codes with some exceptions.
144     *
145     * @param string $languageCode Language code
146     * @return string Mapped language code
147     */
148    protected function mapLanguageCode(string $languageCode): string
149    {
150        // Amazon Translate uses mostly standard ISO 639-1 codes
151        // Special cases:
152        $mapping = [
153            'zh' => 'zh', // Chinese (simplified)
154            'zh-TW' => 'zh-TW', // Chinese (traditional)
155            'no' => 'no', // Norwegian
156            'pt' => 'pt', // Portuguese
157            'pt-BR' => 'pt', // Brazilian Portuguese -> Portuguese
158        ];
159
160        return $mapping[$languageCode] ?? $languageCode;
161    }
162
163    /**
164     * Check if the provider supports the given language pair.
165     *
166     * @param string $sourceLang Source language code
167     * @param string $targetLang Target language code
168     * @return bool True if supported
169     */
170    public function supportsLanguagePair(string $sourceLang, string $targetLang): bool
171    {
172        $supportedLanguages = $this->getSupportedLanguages();
173        return (
174            in_array($sourceLang, $supportedLanguages, strict: true)
175            && in_array($targetLang, $supportedLanguages, strict: true)
176        );
177    }
178
179    /**
180     * Get a list of supported languages.
181     * Amazon Translate supports 75+ languages.
182     *
183     * @return array<string> Array of supported language codes
184     */
185    public function getSupportedLanguages(): array
186    {
187        return [
188            'af',
189            'sq',
190            'am',
191            'ar',
192            'hy',
193            'az',
194            'bn',
195            'bs',
196            'bg',
197            'ca',
198            'zh',
199            'zh-TW',
200            'hr',
201            'cs',
202            'da',
203            'fa-AF',
204            'nl',
205            'en',
206            'et',
207            'fa',
208            'tl',
209            'fi',
210            'fr',
211            'fr-CA',
212            'ka',
213            'de',
214            'el',
215            'gu',
216            'ht',
217            'ha',
218            'he',
219            'hi',
220            'hu',
221            'is',
222            'id',
223            'ga',
224            'it',
225            'ja',
226            'kn',
227            'kk',
228            'ko',
229            'lv',
230            'lt',
231            'mk',
232            'ms',
233            'ml',
234            'mt',
235            'mr',
236            'mn',
237            'no',
238            'ps',
239            'pl',
240            'pt',
241            'pa',
242            'ro',
243            'ru',
244            'sr',
245            'si',
246            'sk',
247            'sl',
248            'so',
249            'es',
250            'es-MX',
251            'sw',
252            'sv',
253            'ta',
254            'te',
255            'th',
256            'tr',
257            'uk',
258            'ur',
259            'uz',
260            'vi',
261            'cy',
262        ];
263    }
264
265    /**
266     * Get the provider name.
267     *
268     * @return string Provider name
269     */
270    public function getProviderName(): string
271    {
272        return 'Amazon Translate';
273    }
274
275    /**
276     * Generate AWS Signature Version 4 headers for authentication.
277     *
278     * @param string $method HTTP method (POST)
279     * @param string $payload Request payload
280     * @param string $region AWS region
281     * @param string $accessKeyId AWS access key ID
282     * @param string $secretAccessKey AWS secret access key
283     * @return array Headers array
284     */
285    private function getAwsSignedHeaders(
286        string $method,
287        string $payload,
288        string $region,
289        string $accessKeyId,
290        string $secretAccessKey,
291    ): array {
292        $service = self::SERVICE_NAME;
293        $host = sprintf('translate.%s.amazonaws.com', $region);
294        $amzDate = gmdate('Ymd\THis\Z');
295        $dateStamp = gmdate('Ymd');
296
297        // Task 1: Create canonical request
298        $canonicalUri = '/';
299        $canonicalQuerystring = '';
300        $canonicalHeaders =
301            "content-type:application/x-amz-json-1.1\n"
302            . "host:{$host}\n"
303            . "x-amz-date:{$amzDate}\n"
304            . "x-amz-target:AWSShineFrontendService_20170701.TranslateText\n";
305        $signedHeaders = 'content-type;host;x-amz-date;x-amz-target';
306        $payloadHash = hash('sha256', $payload);
307
308        $canonicalRequest =
309            "{$method}\n{$canonicalUri}\n{$canonicalQuerystring}\n"
310            . "{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
311
312        // Task 2: Create string to sign
313        $algorithm = 'AWS4-HMAC-SHA256';
314        $credentialScope = "{$dateStamp}/{$region}/{$service}/aws4_request";
315        $stringToSign = "{$algorithm}\n{$amzDate}\n{$credentialScope}\n" . hash('sha256', $canonicalRequest);
316
317        // Task 3: Calculate signature
318        $kDate = hash_hmac(algo: 'sha256', data: $dateStamp, key: 'AWS4' . $secretAccessKey, binary: true);
319        $kRegion = hash_hmac(algo: 'sha256', data: $region, key: $kDate, binary: true);
320        $kService = hash_hmac(algo: 'sha256', data: $service, key: $kRegion, binary: true);
321        $kSigning = hash_hmac(algo: 'sha256', data: 'aws4_request', key: $kService, binary: true);
322        $signature = hash_hmac('sha256', $stringToSign, $kSigning);
323
324        // Task 4: Add signing information to the request
325        $authorizationHeader =
326            "{$algorithm} Credential={$accessKeyId}/{$credentialScope}"
327            . "SignedHeaders={$signedHeaders}, Signature={$signature}";
328
329        return [
330            'Content-Type' => 'application/x-amz-json-1.1',
331            'Host' => $host,
332            'X-Amz-Date' => $amzDate,
333            'X-Amz-Target' => 'AWSShineFrontendService_20170701.TranslateText',
334            'Authorization' => $authorizationHeader,
335        ];
336    }
337}