Implemented translation via Yandex service.

This commit is contained in:
Leonid Nikitin 2024-09-16 22:22:24 +05:00
parent 82dc0b9f99
commit 0d13d602a7
Signed by: kor-elf
GPG Key ID: 3C0F720C170F6E1D
12 changed files with 491 additions and 1 deletions

View File

@ -1,6 +1,6 @@
MIT License 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: 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:

View File

@ -1,2 +1,15 @@
# translate-laravel # translate-laravel
Библиотека для Laravel, что бы общаться с сервисом перевода от <a href="https://yandex.cloud/ru/docs/translate/" target="_blank">yandex</a>. В будущем (скорей всего в далёком будущем) планирую расширить поддержку разных сервисов перевода. Что бы можно было общаться через один интерфейс с разными сервисами.
Параметры в **.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**.

38
composer.json Normal file
View File

@ -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/"
}
}
}

34
config/translate.php Normal file
View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
/**
* Copyright (c) 2024
* author: Leonid Nikitin - i@kor-elf.net
* web: https://git.kor-elf.net/kor-elf
* Initial version created on: 15.09.2024
* MIT license: https://git.kor-elf.net/kor-elf/translate-laravel/src/branch/main/LICENSE
*/
return [
/*
|--------------------------------------------------------------------------
| Default Translate Service
|--------------------------------------------------------------------------
|
| This option controls the default translate service that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a translation operation inside the application.
|
*/
'default' => 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')),
],
],
],
];

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace korElf\TranslateLaravel\Contracts;
use korElf\TranslateLaravel\DTO\Languages;
use Illuminate\Contracts\Foundation\Application;
interface Translate
{
public static function init(Application $app, array $config = []): self;
public function translateText(string|array $text, string $targetLanguageCode, ?string $sourceLanguageCode = null): string|array;
public function translateHtml(string|array $text, string $targetLanguageCode, ?string $sourceLanguageCode = null): string|array;
public function listLanguages(): Languages;
}

18
src/DTO/Languages.php Normal file
View File

@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace korElf\TranslateLaravel\DTO;
final class Languages
{
private array $languages;
public function add(string $code, string $name): void
{
$this->languages[$code] = $name;
}
public function getLanguages(): array
{
return $this->languages;
}
}

View File

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace korElf\TranslateLaravel\Exceptions;
use RuntimeException;
class TranslateException extends RuntimeException
{
}

24
src/Facades/Translate.php Normal file
View File

@ -0,0 +1,24 @@
<?php declare(strict_types=1);
namespace korElf\TranslateLaravel\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @method static \korElf\TranslateLaravel\Contracts\Translate service(?string $name = null)
* @method static \korElf\TranslateLaravel\Contracts\Translate resolve(string $name)
* @method static string|array translateText(string|array $text, string $targetLanguageCode, ?string $sourceLanguageCode = null)
* @method static string|array translateHtml(string|array $text, string $targetLanguageCode, ?string $sourceLanguageCode = null)
* @method static \korElf\TranslateLaravel\DTO\Languages listLanguages()
* @method static string getDefaultDriver()
* @method static void setDefaultDriver(string $name)
* @method static void purge(?string $name)
*/
final class Translate extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'translate';
}
}

View File

@ -0,0 +1,75 @@
<?php declare(strict_types=1);
namespace korElf\TranslateLaravel\Translate;
use Illuminate\Contracts\Foundation\Application;
use korElf\TranslateLaravel\Contracts\Translate;
use InvalidArgumentException;
final class TranslateManager
{
/**
* The array of resolved translation services.
*/
private array $translates = [];
public function __construct(
private Application $app,
) { }
public function service(?string $name = null): Translate
{
$name = $name ?: $this->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;
}
}

View File

@ -0,0 +1,113 @@
<?php declare(strict_types=1);
namespace korElf\TranslateLaravel\Translate\Yandex;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Carbon;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\PS256;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\Serializer\CompactSerializer;
use korElf\TranslateLaravel\Exceptions\TranslateException;
final class Connection
{
private readonly string $path;
private readonly string $folderId;
private readonly string $privateKey;
private readonly string $keyId;
private readonly string $serviceAccountId;
public function __construct(string $path, string $folderId, string $privateKey, string $keyId, string $serviceAccountId)
{
$this->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();
}
}

View File

@ -0,0 +1,113 @@
<?php declare(strict_types=1);
namespace korElf\TranslateLaravel\Translate;
use korElf\TranslateLaravel\Contracts\Translate;
use korElf\TranslateLaravel\DTO\Languages;
use korElf\TranslateLaravel\Exceptions\TranslateException;
use korElf\TranslateLaravel\Translate\Yandex\Connection;
use Illuminate\Contracts\Foundation\Application;
final class YandexDriver implements Translate
{
public static function init(Application $app, array $config = []): self
{
$keyData = json_decode(file_get_contents($config['authorized_key_path']), true);
$path = 'https://translate.api.cloud.yandex.net/translate/v2/';
return new self(
$app->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());
}
}
}

View File

@ -0,0 +1,35 @@
<?php declare(strict_types=1);
namespace korElf\TranslateLaravel;
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Foundation\Application;
use korElf\TranslateLaravel\Translate\TranslateManager;
final class TranslateLaravelProvider extends ServiceProvider
{
public function boot(): Void
{
$this->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);
});
}
}