From 0d13d602a7c0ba5fe7545b37ab921a633963fa17 Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Mon, 16 Sep 2024 22:22:24 +0500 Subject: [PATCH] Implemented translation via Yandex service. --- LICENSE | 2 +- README.md | 13 +++ composer.json | 38 +++++++++ config/translate.php | 34 ++++++++ src/Contracts/Translate.php | 17 ++++ src/DTO/Languages.php | 18 ++++ src/Exceptions/TranslateException.php | 10 +++ src/Facades/Translate.php | 24 ++++++ src/Translate/TranslateManager.php | 75 +++++++++++++++++ src/Translate/Yandex/Connection.php | 113 ++++++++++++++++++++++++++ src/Translate/YandexDriver.php | 113 ++++++++++++++++++++++++++ src/TranslateLaravelProvider.php | 35 ++++++++ 12 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 composer.json create mode 100644 config/translate.php create mode 100644 src/Contracts/Translate.php create mode 100644 src/DTO/Languages.php create mode 100644 src/Exceptions/TranslateException.php create mode 100644 src/Facades/Translate.php create mode 100644 src/Translate/TranslateManager.php create mode 100644 src/Translate/Yandex/Connection.php create mode 100644 src/Translate/YandexDriver.php create mode 100644 src/TranslateLaravelProvider.php diff --git a/LICENSE b/LICENSE index d08a830..0fce2f8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 kor-elf +Copyright (c) 2024 Leonid Nikitin (kor-elf) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 29f52c7..d690baa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,15 @@ # translate-laravel +Библиотека для Laravel, что бы общаться с сервисом перевода от yandex. В будущем (скорей всего в далёком будущем) планирую расширить поддержку разных сервисов перевода. Что бы можно было общаться через один интерфейс с разными сервисами. + +Параметры в **.env**: + +# TRANSLATE_SERVICE +По умолчанию через какой сервис обращаться за переводом. На данный момент доступен только один сервис - **yandex**. + +# TRANSLATE_YANDEX_FOLDER_ID +ID folder. Код можно увидеть в адресе console.yandex.cloud/folders/**{тут будет код}**. + +# TRANSLATE_YANDEX_AUTHORIZED_KEY_PATH +Абсолютный путь в рамках приложения Laravel к файлу **authorized_key.json**. +Например: положили файл в корень Laravel. Тогда будет путь такой: **/authorized_key.json**. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..292d5fe --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "kor-elf/translate-laravel", + "description": "Translator for laravel", + "license": "MIT", + "type": "library", + "keywords": [ + "Translate", + "Laravel", + "Yandex Translate" + ], + "homepage": "https://git.kor-elf.net/kor-elf/translate-laravel", + "authors": [ + { + "name": "Leonid Nikitin", + "email": "i@kor-elf.net", + "homepage": "https://git.kor-elf.net/kor-elf", + "role": "Developer" + } + ], + "require": { + "php": "^8.2", + "illuminate/support": "^10.0|^11.0", + "guzzlehttp/guzzle": "^7.0.1", + "web-token/jwt-framework": "^3.0" + }, + "extra": { + "laravel": { + "providers": [ + "korElf\\TranslateLaravel\\TranslateLaravelProvider" + ] + } + }, + "autoload": { + "psr-4": { + "korElf\\TranslateLaravel\\": "src/" + } + } +} diff --git a/config/translate.php b/config/translate.php new file mode 100644 index 0000000..35f7c4f --- /dev/null +++ b/config/translate.php @@ -0,0 +1,34 @@ + env('TRANSLATE_SERVICE', 'yandex'), + + 'services' => [ + 'yandex' => [ + 'driver' => '\korElf\TranslateLaravel\Translate\YandexDriver', + 'config' => [ + 'folder_id' => env('TRANSLATE_YANDEX_FOLDER_ID'), + 'authorized_key_path' => base_path(env('TRANSLATE_YANDEX_AUTHORIZED_KEY_PATH')), + ], + ], + ], +]; diff --git a/src/Contracts/Translate.php b/src/Contracts/Translate.php new file mode 100644 index 0000000..93e4d3d --- /dev/null +++ b/src/Contracts/Translate.php @@ -0,0 +1,17 @@ +languages[$code] = $name; + } + + public function getLanguages(): array + { + return $this->languages; + } +} \ No newline at end of file diff --git a/src/Exceptions/TranslateException.php b/src/Exceptions/TranslateException.php new file mode 100644 index 0000000..0e44c71 --- /dev/null +++ b/src/Exceptions/TranslateException.php @@ -0,0 +1,10 @@ +getDefaultDriver(); + + return $this->translates[$name] ??= $this->resolve($name); + } + + public function getDefaultDriver(): string + { + return $this->app['config']['translate.default']; + } + + public function setDefaultDriver(string $name): void + { + $this->app['config']['translate.default'] = $name; + } + + /** + * Resolve the given translate service. + * + * @throws InvalidArgumentException + */ + public function resolve(string $name): Translate + { + $config = $this->getConfig($name); + + if (\is_null($config)) { + throw new InvalidArgumentException("Translate service [{$name}] is not defined."); + } + + return $config['driver']::init($this->app, $config['config']); + } + + /** + * Disconnect the given driver and remove from local cache. + */ + public function purge(?string $name = null): void + { + $name ??= $this->getDefaultDriver(); + + unset($this->translates[$name]); + } + + /** + * Dynamically call the default driver instance. + */ + public function __call(string $method, mixed $parameters): mixed + { + return $this->service()->$method(...$parameters); + } + + private function getConfig(string $name): ?array + { + return $this->app['config']["translate.services.{$name}"] ?? null; + } +} \ No newline at end of file diff --git a/src/Translate/Yandex/Connection.php b/src/Translate/Yandex/Connection.php new file mode 100644 index 0000000..8c4f73c --- /dev/null +++ b/src/Translate/Yandex/Connection.php @@ -0,0 +1,113 @@ +path = $path; + $this->folderId = $folderId; + + // Need to remove header/metadata from private key + if (\strpos($privateKey, "PLEASE DO NOT REMOVE THIS LINE!") === 0) { + $privateKey = \substr($privateKey, \strpos($privateKey, "\n") + 1); + } + $this->privateKey = $privateKey; + + $this->keyId = $keyId; + $this->serviceAccountId = $serviceAccountId; + } + + public function post(string $path, array $post): array + { + $token = $this->getToken(); + $path = $this->path . $path; + $post['folderId'] = $this->folderId; + + $response = Http::withToken($token) + ->timeout(30) + ->post($path, $post); + + $data = $response->json(); + + if ($data !== null && $response->status() === 200) { + return $data; + } + $message = $data['message'] ?? 'Error in request'; + + throw new TranslateException($message, $response->status()); + } + + private function getToken(): string + { + $cacheName = self::class . '_token'; + $token = Cache::get($cacheName); + if ($token !== null) { + return $token; + } + + $token = $this->createNewToken(); + Cache::put($cacheName, $token['iamToken'], Carbon::createFromDate($token['expiresAt'])); + + return $token['iamToken']; + } + + private function getJWTToken(): string + { + $jwk = JWKFactory::createFromKey( + $this->privateKey, + null, + [ + 'alg' => 'PS256', + 'use' => 'sig', + 'kid' => $this->keyId, + ] + ); + + $algorithmManager = new AlgorithmManager([new PS256()]); + $jwsBuilder = new JWSBuilder($algorithmManager); + + $payload = \json_encode([ + 'iss' => $this->serviceAccountId, + 'aud' => "https://iam.api.cloud.yandex.net/iam/v1/tokens", + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 600, + ]); + + $jws = $jwsBuilder + ->create() + ->withPayload($payload) + ->addSignature($jwk, ['alg' => 'PS256', 'typ'=>'JWT', 'kid' => $this->keyId]) + ->build(); + + $serializer = new CompactSerializer(); + return $serializer->serialize($jws, 0); + } + + private function createNewToken(): array + { + $jwtToken = $this->getJWTToken(); + $token = Http::timeout(30) + ->post('https://iam.api.cloud.yandex.net/iam/v1/tokens', ['jwt' => $jwtToken]); + + return $token->json(); + } +} \ No newline at end of file diff --git a/src/Translate/YandexDriver.php b/src/Translate/YandexDriver.php new file mode 100644 index 0000000..532015b --- /dev/null +++ b/src/Translate/YandexDriver.php @@ -0,0 +1,113 @@ +make(Connection::class, [ + 'path' => $path, + 'folderId' => $config['folder_id'], + 'privateKey' => $keyData['private_key'], + 'keyId' => $keyData['id'], + 'serviceAccountId' => $keyData['service_account_id'], + ]), + ); + } + + public function __construct( + private readonly Connection $connection + ) { } + + public function translateText(string|array $text, string $targetLanguageCode, ?string $sourceLanguageCode = null): string|array + { + $format = 'PLAIN_TEXT'; + $result = $this->translate($text, $format, $targetLanguageCode, $sourceLanguageCode); + + if (\is_string($text)) { + return array_shift($result); + } + + return $result; + } + + public function translateHtml(string|array $text, string $targetLanguageCode, ?string $sourceLanguageCode = null): string|array + { + $format = 'HTML'; + $result = $this->translate($text, $format, $targetLanguageCode, $sourceLanguageCode); + + if (\is_string($text)) { + return array_shift($result); + } + + return $result; + } + + public function listLanguages(): Languages + { + $params = []; + try { + $result = $this->connection->post('languages', $params); + if (! isset($result['languages'])) { + $message = $result['message'] ?? 'Missing value languages in array'; + throw new TranslateException($message); + } + + $languages = new Languages(); + foreach ($result['languages'] as $language) { + $languages->add($language['code'], $language['name'] ?? $language['code']); + } + return $languages; + } catch (TranslateException $e) { + throw $e; + } catch (\Throwable $exception) { + throw new TranslateException($exception->getMessage()); + } + } + + private function translate(string|array $text, string $format, string $targetLanguageCode, ?string $sourceLanguageCode = null): array + { + if (\is_string($text)) { + $text = [$text]; + } + + $params = [ + 'targetLanguageCode' => $targetLanguageCode, + 'format' => $format, + 'texts' => $text, + 'speller' => true, + ]; + if ($sourceLanguageCode) { + $params['sourceLanguageCode'] = $sourceLanguageCode; + } + + try { + $result = $this->connection->post('translate', $params); + if (! isset($result['translations'])) { + $message = $result['message'] ?? 'Missing value translations in array'; + throw new TranslateException($message); + } + + $translations = []; + foreach ($result['translations'] as $translation) { + $translations[] = $translation['text']; + } + return $translations; + } catch (TranslateException $e) { + throw $e; + } catch (\Throwable $exception) { + throw new TranslateException($exception->getMessage()); + } + } +} \ No newline at end of file diff --git a/src/TranslateLaravelProvider.php b/src/TranslateLaravelProvider.php new file mode 100644 index 0000000..4b9b59a --- /dev/null +++ b/src/TranslateLaravelProvider.php @@ -0,0 +1,35 @@ +publishes([ + __DIR__ . '/../config/translate.php' => config_path('translate.php'), + ], 'config'); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register(): Void + { + $this->mergeConfigFrom( + __DIR__ . '/../config/translate.php', + 'translate' + ); + + $this->app->singleton('translate', function (Application $app) { + return new TranslateManager($app); + }); + } + +} \ No newline at end of file