From 5a073ce6b98267d7adafdd06810eb09a12d43fb5 Mon Sep 17 00:00:00 2001 From: Ferdinand Kuhl Date: Sat, 5 Aug 2023 15:49:46 +0200 Subject: [PATCH 1/6] First version, extracted and tested --- .woodpecker/code-style.yml | 8 + .woodpecker/functional-tests.yml | 32 ++++ Classes/Http/ReplaceGetTranslationRequest.php | 51 +++++++ Classes/Http/TranslationMiddleware.php | 143 ++++++++++++++++++ .../ViewHelpers/CurrentHtmlLangViewHelper.php | 32 ++++ Configuration/Caches.yaml | 3 + Configuration/Objects.yaml | 24 +++ Configuration/Settings.yaml | 17 +++ Configuration/Testing/Settings.yaml | 5 + README.md | 45 ++++++ Resources/Private/Translations/de/Test.xlf | 10 ++ Resources/Private/Translations/en/Test.xlf | 10 ++ .../Functional/TranslationMiddlewareTest.php | 63 ++++++++ composer.json | 45 ++++++ 14 files changed, 488 insertions(+) create mode 100644 .woodpecker/code-style.yml create mode 100644 .woodpecker/functional-tests.yml create mode 100644 Classes/Http/ReplaceGetTranslationRequest.php create mode 100644 Classes/Http/TranslationMiddleware.php create mode 100644 Classes/ViewHelpers/CurrentHtmlLangViewHelper.php create mode 100644 Configuration/Caches.yaml create mode 100644 Configuration/Objects.yaml create mode 100644 Configuration/Settings.yaml create mode 100644 Configuration/Testing/Settings.yaml create mode 100644 README.md create mode 100644 Resources/Private/Translations/de/Test.xlf create mode 100644 Resources/Private/Translations/en/Test.xlf create mode 100644 Tests/Functional/TranslationMiddlewareTest.php create mode 100644 composer.json diff --git a/.woodpecker/code-style.yml b/.woodpecker/code-style.yml new file mode 100644 index 0000000..bfd94b0 --- /dev/null +++ b/.woodpecker/code-style.yml @@ -0,0 +1,8 @@ +pipeline: + code-style: + image: composer + commands: + - composer global config repositories.repo-name vcs https://git.digital-competence.de/Packages/php-codesniffer + - composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true + - composer global require digicomp/php-codesniffer:@dev + - composer global exec -- phpcs --runtime-set ignore_warnings_on_exit 1 --standard=DigiComp Classes/ Tests/ diff --git a/.woodpecker/functional-tests.yml b/.woodpecker/functional-tests.yml new file mode 100644 index 0000000..d5e2d19 --- /dev/null +++ b/.woodpecker/functional-tests.yml @@ -0,0 +1,32 @@ +workspace: + base: /woodpecker + path: package + +matrix: + include: + - FLOW_VERSION: 6.3 + PHP_VERSION: 7.4 + - FLOW_VERSION: 7.3 + PHP_VERSION: 7.4 + - FLOW_VERSION: 7.3 + PHP_VERSION: 8.1 + - FLOW_VERSION: 8.3 + PHP_VERSION: 8.1 + +pipeline: + functional-tests: + image: "thecodingmachine/php:${PHP_VERSION}-v4-cli" + environment: + # Enable the PDO_SQLITE extension + - "PHP_EXTENSION_PDO_SQLITE=1" + - "FLOW_VERSION=${FLOW_VERSION}" + - "NEOS_BUILD_DIR=/woodpecker/Build-${FLOW_VERSION}" + commands: + - "sudo mkdir $NEOS_BUILD_DIR" + - "sudo chown -R docker:docker $NEOS_BUILD_DIR" + - "cd $NEOS_BUILD_DIR" + - "composer create-project --no-install neos/flow-base-distribution:^$FLOW_VERSION ." + - "composer config repositories.repo-name path /woodpecker/package" + - "composer config --no-plugins allow-plugins.neos/composer-plugin true" + - "composer require digicomp/flow-translation-endpoint:@dev" + - "bin/phpunit --configuration Build/BuildEssentials/PhpUnit/FunctionalTests.xml Packages/Application/DigiComp.FlowTranslationEndpoint/Tests/Functional" diff --git a/Classes/Http/ReplaceGetTranslationRequest.php b/Classes/Http/ReplaceGetTranslationRequest.php new file mode 100644 index 0000000..4b2f6d4 --- /dev/null +++ b/Classes/Http/ReplaceGetTranslationRequest.php @@ -0,0 +1,51 @@ +headerName = $headerName; + $this->reactOnPath = $reactOnPath; + $this->translateGetParam = $translateGetParam; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->reactOnPath !== null && $request->getUri()->getPath() === $this->reactOnPath) { + $request = $request->withAddedHeader( + $this->headerName, + $request->getQueryParams()[$this->translateGetParam] ?? '' + ); + } + return $handler->handle($request); + } +} diff --git a/Classes/Http/TranslationMiddleware.php b/Classes/Http/TranslationMiddleware.php new file mode 100644 index 0000000..78d4b2e --- /dev/null +++ b/Classes/Http/TranslationMiddleware.php @@ -0,0 +1,143 @@ +headerName = $headerName; + $this->i18nService = $i18nService; + $this->fileProvider = $fileProvider; + $this->detector = $detector; + $this->responseCache = $responseCache; + } + + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + * @throws CacheException + * @throws InvalidDataException + * @throws \JsonException + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($request->hasHeader($this->headerName)) { + return $this->createResponse($request); + } + return $handler->handle($request); + } + + /** + * @param ServerRequestInterface $request + * @return ResponseInterface + * @throws \JsonException + * @throws CacheException + * @throws InvalidDataException + */ + protected function createResponse(ServerRequestInterface $request): ResponseInterface + { + if ($request->hasHeader('Accept-Language')) { + $wishedLocale = $this->detector->detectLocaleFromHttpHeader($request->getHeader('Accept-Language')[0]); + $bestMatching = $this->i18nService->findBestMatchingLocale($wishedLocale); + $this->i18nService->getConfiguration()->setCurrentLocale($bestMatching); + } + $idPatternList = $request->getHeader($this->headerName)[0] ?? ''; + $cacheId = $this->i18nService->getConfiguration()->getCurrentLocale() . '_' . \sha1(\serialize($idPatternList)); + if ($this->responseCache->has($cacheId)) { + $response = $this->responseCache->get($cacheId); + } else { + $result = $this->getTranslations(Arrays::trimExplode(',', $idPatternList)); + $response = \json_encode($result, \JSON_THROW_ON_ERROR); + $this->responseCache->set($cacheId, $response); + } + return new Response(200, ['Content-Type' => 'application/json'], $response); + } + + /** + * @param array $idPatternList + * + * @return array + */ + protected function getTranslations(array $idPatternList): array + { + $result = []; + foreach ($idPatternList as $idPattern) { + $package = 'Neos.Flow:Main'; + $parts = \explode('|', $idPattern); + switch (\count($parts)) { + case 2: + [$package, $pattern] = $parts; + break; + case 1: + [$pattern] = $parts; + break; + default: + throw new \InvalidArgumentException('Could not parse idPattern: ' . $idPattern); + } + $translationUnits = $this->fileProvider->getFile( + $package, + $this->i18nService->getConfiguration()->getCurrentLocale() + )->getTranslationUnits(); + if (!isset($result[$package])) { + $result[$package] = []; + } + $matchingUnits = \array_filter( + $translationUnits, + static fn($unitId) => \preg_match('~^' . $pattern . '$~', $unitId), + \ARRAY_FILTER_USE_KEY + ); + $result[$package] += \array_map( + static fn($value) => $value[0]['target'], + $matchingUnits + ); + } + return $result; + } +} diff --git a/Classes/ViewHelpers/CurrentHtmlLangViewHelper.php b/Classes/ViewHelpers/CurrentHtmlLangViewHelper.php new file mode 100644 index 0000000..e331d8a --- /dev/null +++ b/Classes/ViewHelpers/CurrentHtmlLangViewHelper.php @@ -0,0 +1,32 @@ +i18nService = $i18nService; + } + + /** + * @return string + */ + public function render(): string + { + return \str_replace('_', '-', (string)$this->i18nService->getConfiguration()->getCurrentLocale()); + } +} diff --git a/Configuration/Caches.yaml b/Configuration/Caches.yaml new file mode 100644 index 0000000..e4aa7cf --- /dev/null +++ b/Configuration/Caches.yaml @@ -0,0 +1,3 @@ +DigiComp_FlowTranslationEndpoint_Responses: + frontend: Neos\Cache\Frontend\StringFrontend + backend: Neos\Cache\Backend\SimpleFileBackend diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml new file mode 100644 index 0000000..14ebadb --- /dev/null +++ b/Configuration/Objects.yaml @@ -0,0 +1,24 @@ +DigiComp:TransformTranslationRequest: + className: "DigiComp\\FlowTranslationEndpoint\\Http\\ReplaceGetTranslationRequest" + arguments: + 1: + setting: "DigiComp.FlowTranslationEndpoint.headerName" + 2: + setting: "DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.reactOnPath" + 3: + setting: "DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.translateGetParam" + +DigiComp:TranslationResponseCache: + className: "Neos\\Cache\\Frontend\\StringFrontend" + factoryObjectName: Neos\Flow\Cache\CacheManager + factoryMethodName: getCache + arguments: + 1: + value: DigiComp_FlowTranslationEndpoint_Responses + +DigiComp\FlowTranslationEndpoint\Http\TranslationMiddleware: + arguments: + 1: + setting: "DigiComp.FlowTranslationEndpoint.headerName" + 5: + object: "DigiComp:TranslationResponseCache" diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml new file mode 100644 index 0000000..374483e --- /dev/null +++ b/Configuration/Settings.yaml @@ -0,0 +1,17 @@ +Neos: + Flow: + http: + middlewares: + translationReplace: + position: "before translation" + middleware: "DigiComp:TransformTranslationRequest" + translation: + position: "start 100" + middleware: "DigiComp\\FlowTranslationEndpoint\\Http\\TranslationMiddleware" + +DigiComp: + FlowTranslationEndpoint: + replaceRoutedEndpoint: + reactOnPath: ~ + translateGetParam: "idPatterns" + headerName: "X-Translation-Request" diff --git a/Configuration/Testing/Settings.yaml b/Configuration/Testing/Settings.yaml new file mode 100644 index 0000000..5afa1eb --- /dev/null +++ b/Configuration/Testing/Settings.yaml @@ -0,0 +1,5 @@ +DigiComp: + FlowTranslationEndpoint: + replaceRoutedEndpoint: + reactOnPath: 'testing/translate' + headerName: "X-Translation-Request" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e01ccb --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# DigiComp.FlowTranslationEndpoint + +![Build status](https://ci.digital-competence.de/api/badges/Packages/DigiComp.FlowTranslationEndpoint/status.svg) + +This package is designed to help bringing needed translations to javascript components, without pushing them to the DOM in your views. + +Other solutions, which would generate files available for usage in client scope, have the disadvantage that one would have to repeat the relativ complex overriding and merging logic of Flow. With this endpoint you can get the same content, as you would get, if you call the translation service with your translation id. + +The main components are a `CurrentHtmlLangViewHelper`, which is intended to be used to fill the `lang` attribute of the `html` tag, so the frontend knows, which language is currently active (and is good practice anyway) and a `TranslationMiddleware`, which will respond to any request, which have a `X-Translation-Request` header, carrying a pattern of translation ids which should be returned. + +For example: +```` +X-Translation-Request: Neos.Flow:Main|authentication.* +```` + +would return all translation keys from the main unit of `Neos.Flow` starting with "authentication" and would look like that: + +```json +{ + "Neos.Flow:Main": { + "authentication.required": "Authentication required", + "authentication.username": "Username", + "authentication.password": "Password", + "authentication.new-password": "New password", + "authentication.login": "Login", + "authentication.logout": "Logout" + } +} +``` + +To let the middleware know, in which langauge the translated units should be, you should set the correct `Accept-Language`-Header with your request, which you obtained from the `lang` attribute of the `html` element. + +Your JavaScript could look like that: + +```javascript +async function translate(idPatterns) { + return fetch(document.location, {headers: { + 'X-Translation-Request': idPatterns, + 'Accept-Language': document.documentElement.lang + }}) + .then(response => response.json()); +} +``` + +If, for whatever reason, you prefer to have a "traditional" single endpoint, which works without a custom header, you can set `DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.reactOnPath`. At this point a middleware translates all incoming requests and the GET parameter `idPatterns` (you can change the name in Settings.yaml). diff --git a/Resources/Private/Translations/de/Test.xlf b/Resources/Private/Translations/de/Test.xlf new file mode 100644 index 0000000..247e225 --- /dev/null +++ b/Resources/Private/Translations/de/Test.xlf @@ -0,0 +1,10 @@ + + + + + + de_key1 + + + + diff --git a/Resources/Private/Translations/en/Test.xlf b/Resources/Private/Translations/en/Test.xlf new file mode 100644 index 0000000..2c03f93 --- /dev/null +++ b/Resources/Private/Translations/en/Test.xlf @@ -0,0 +1,10 @@ + + + + + + en_key1 + + + + diff --git a/Tests/Functional/TranslationMiddlewareTest.php b/Tests/Functional/TranslationMiddlewareTest.php new file mode 100644 index 0000000..0e93ddc --- /dev/null +++ b/Tests/Functional/TranslationMiddlewareTest.php @@ -0,0 +1,63 @@ +serverRequestFactory = $this->objectManager->get(ServerRequestFactoryInterface::class); + } + + /** + * @test + */ + public function itRespondsToRequestsWithTheConfiguredHeader(): void + { + $request = $this->serverRequestFactory->createServerRequest('GET', 'dummyUrl'); + $request = $request + ->withHeader('X-Translation-Request', 'DigiComp.FlowTranslationEndpoint:Test|.*') + ->withHeader('Accept-Language', 'en'); + $response = $this->browser->sendRequest($request); + static::assertEquals( + '{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"en_key1"}}', + (string)$response->getBody() + ); + $request = $this->serverRequestFactory->createServerRequest('GET', 'dummyUrl'); + $request = $request + ->withHeader('X-Translation-Request', 'DigiComp.FlowTranslationEndpoint:Test|.*') + ->withHeader('Accept-Language', 'de'); + $response = $this->browser->sendRequest($request); + static::assertEquals( + '{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"de_key1"}}', + (string)$response->getBody() + ); + } + + /** + * @test + */ + public function itRespondsToConfiguredRoute(): void + { + $request = $this->serverRequestFactory->createServerRequest('GET', 'testing/translate'); + $request = $request + ->withQueryParams(['idPatterns' => 'DigiComp.FlowTranslationEndpoint:Test|.*']) + ->withHeader('Accept-Language', 'en'); + $response = $this->browser->sendRequest($request); + static::assertEquals( + '{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"en_key1"}}', + (string)$response->getBody() + ); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..bc7a063 --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "name": "digicomp/flow-translation-endpoint", + "description": "A simple endpoint providing XLIFF translations as string", + "type": "neos-package", + "require": { + "ext-json": "*", + "neos/flow": "^6.3.0 | ^7.0 | ^8.0", + "php": ">=7.4" + }, + "autoload": { + "psr-4": { + "DigiComp\\FlowTranslationEndpoint\\": "Classes/" + } + }, + "autoload-dev": { + "psr-4": { + "DigiComp\\FlowTranslationEndpoint\\Tests\\": "Tests/" + } + }, + "extra": { + "neos": { + "package-key": "DigiComp.FlowTranslationEndpoint" + }, + "branch-alias": { + "dev-develop": "1.0.x-dev" + } + }, + "authors": [ + { + "name": "Ferdinand Kuhl", + "email": "f.kuhl@digital-competence.de", + "homepage": "https://www.digital-competence.de", + "role": "Developer" + } + ], + "license": "MIT", + "homepage": "https://git.digital-competence.de/Packages/DigiComp.FlowTranslationEndpoint", + "keywords": [ + "Neos", + "Flow", + "translation", + "xliff", + "json" + ] +} From ad9c9fd678dbab49c268b7b16971b44e04e7217c Mon Sep 17 00:00:00 2001 From: Ferdinand Kuhl Date: Tue, 8 Aug 2023 10:48:14 +0200 Subject: [PATCH 2/6] Adding error handling to code example --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0e01ccb..0c0294d 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,14 @@ Your JavaScript could look like that: ```javascript async function translate(idPatterns) { - return fetch(document.location, {headers: { + const response = await fetch(document.location, {headers: { 'X-Translation-Request': idPatterns, 'Accept-Language': document.documentElement.lang - }}) - .then(response => response.json()); + }}); + if (! response.ok) { + return Promise.reject('Unexpected server response'); + } + return await response.json(); } ``` From ef297fa8e15ceb654a829c64a4716db0416dfdce Mon Sep 17 00:00:00 2001 From: Ferdinand Kuhl Date: Tue, 8 Aug 2023 12:11:46 +0200 Subject: [PATCH 3/6] review: consistent naming and methodology --- ...TransformTranslationRequestMiddleware.php} | 7 +----- ...e.php => TranslationRequestMiddleware.php} | 2 +- Configuration/Objects.yaml | 24 ++++++++++++------- Configuration/Settings.yaml | 4 ++-- README.md | 4 ++-- 5 files changed, 22 insertions(+), 19 deletions(-) rename Classes/Http/{ReplaceGetTranslationRequest.php => TransformTranslationRequestMiddleware.php} (85%) rename Classes/Http/{TranslationMiddleware.php => TranslationRequestMiddleware.php} (98%) diff --git a/Classes/Http/ReplaceGetTranslationRequest.php b/Classes/Http/TransformTranslationRequestMiddleware.php similarity index 85% rename from Classes/Http/ReplaceGetTranslationRequest.php rename to Classes/Http/TransformTranslationRequestMiddleware.php index 4b2f6d4..c43395c 100644 --- a/Classes/Http/ReplaceGetTranslationRequest.php +++ b/Classes/Http/TransformTranslationRequestMiddleware.php @@ -9,7 +9,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -class ReplaceGetTranslationRequest implements MiddlewareInterface +class TransformTranslationRequestMiddleware implements MiddlewareInterface { /** * @var string @@ -26,11 +26,6 @@ class ReplaceGetTranslationRequest implements MiddlewareInterface */ protected string $translateGetParam; - /** - * @param string $headerName - * @param string|null $reactOnPath - * @param string $translateGetParam - */ public function __construct(string $headerName, ?string $reactOnPath, string $translateGetParam) { $this->headerName = $headerName; diff --git a/Classes/Http/TranslationMiddleware.php b/Classes/Http/TranslationRequestMiddleware.php similarity index 98% rename from Classes/Http/TranslationMiddleware.php rename to Classes/Http/TranslationRequestMiddleware.php index 78d4b2e..d25bc3d 100644 --- a/Classes/Http/TranslationMiddleware.php +++ b/Classes/Http/TranslationRequestMiddleware.php @@ -17,7 +17,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -class TranslationMiddleware implements MiddlewareInterface +class TranslationRequestMiddleware implements MiddlewareInterface { /** * @var string diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index 14ebadb..196f3a3 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -1,5 +1,5 @@ -DigiComp:TransformTranslationRequest: - className: "DigiComp\\FlowTranslationEndpoint\\Http\\ReplaceGetTranslationRequest" +DigiComp.FlowTranslationEndpoint:TransformTranslationRequestMiddleware: + className: "DigiComp\\FlowTranslationEndpoint\\Http\\TransformTranslationRequestMiddleware" arguments: 1: setting: "DigiComp.FlowTranslationEndpoint.headerName" @@ -8,17 +8,25 @@ DigiComp:TransformTranslationRequest: 3: setting: "DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.translateGetParam" -DigiComp:TranslationResponseCache: +DigiComp.FlowTranslationEndpoint:TranslationResponseCache: className: "Neos\\Cache\\Frontend\\StringFrontend" - factoryObjectName: Neos\Flow\Cache\CacheManager - factoryMethodName: getCache + factoryObjectName: "Neos\\Flow\\Cache\\CacheManager" + factoryMethodName: "getCache" arguments: 1: - value: DigiComp_FlowTranslationEndpoint_Responses + value: "DigiComp_FlowTranslationEndpoint_Responses" -DigiComp\FlowTranslationEndpoint\Http\TranslationMiddleware: +DigiComp.FlowTranslationEndpoint:TranslationRequestMiddleware: + className: "DigiComp\\FlowTranslationEndpoint\\Http\\TranslationRequestMiddleware" + autowiring: true arguments: 1: setting: "DigiComp.FlowTranslationEndpoint.headerName" + 2: + object: "Neos\\Flow\\I18n\\Service" + 3: + object: "Neos\\Flow\\I18n\\Xliff\\Service\\XliffFileProvider" + 4: + object: "Neos\\Flow\\I18n\\Detector" 5: - object: "DigiComp:TranslationResponseCache" + object: "DigiComp.FlowTranslationEndpoint:TranslationResponseCache" diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 374483e..727af5a 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -4,10 +4,10 @@ Neos: middlewares: translationReplace: position: "before translation" - middleware: "DigiComp:TransformTranslationRequest" + middleware: "DigiComp.FlowTranslationEndpoint:TransformTranslationRequestMiddleware" translation: position: "start 100" - middleware: "DigiComp\\FlowTranslationEndpoint\\Http\\TranslationMiddleware" + middleware: "DigiComp.FlowTranslationEndpoint:TranslationRequestMiddleware" DigiComp: FlowTranslationEndpoint: diff --git a/README.md b/README.md index 0c0294d..632bf41 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This package is designed to help bringing needed translations to javascript comp Other solutions, which would generate files available for usage in client scope, have the disadvantage that one would have to repeat the relativ complex overriding and merging logic of Flow. With this endpoint you can get the same content, as you would get, if you call the translation service with your translation id. -The main components are a `CurrentHtmlLangViewHelper`, which is intended to be used to fill the `lang` attribute of the `html` tag, so the frontend knows, which language is currently active (and is good practice anyway) and a `TranslationMiddleware`, which will respond to any request, which have a `X-Translation-Request` header, carrying a pattern of translation ids which should be returned. +The main components are a `CurrentHtmlLangViewHelper`, which is intended to be used to fill the `lang` attribute of the `html` tag, so the frontend knows, which language is currently active (and is good practice anyway) and a `TranslationRequestMiddleware`, which will respond to any request, which have a `X-Translation-Request` header, carrying a pattern of translation ids which should be returned. For example: ```` @@ -45,4 +45,4 @@ async function translate(idPatterns) { } ``` -If, for whatever reason, you prefer to have a "traditional" single endpoint, which works without a custom header, you can set `DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.reactOnPath`. At this point a middleware translates all incoming requests and the GET parameter `idPatterns` (you can change the name in Settings.yaml). +If, for whatever reason, you prefer to have a "traditional" single endpoint, which works without a custom header, you can set `DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.reactOnPath`. At this point a second middleware (`TransformGetTranslationRequestMiddleware`) transforms all incoming requests and the GET parameter `idPatterns` (you can change names in Settings.yaml). From 38e45f5d8f43019b8c78f556191b9d314b94dcf6 Mon Sep 17 00:00:00 2001 From: Ferdinand Kuhl Date: Tue, 8 Aug 2023 14:30:48 +0200 Subject: [PATCH 4/6] Adding automatic cache clearing in case of changed translation files --- Classes/Package.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Classes/Package.php diff --git a/Classes/Package.php b/Classes/Package.php new file mode 100644 index 0000000..2ec7ec6 --- /dev/null +++ b/Classes/Package.php @@ -0,0 +1,32 @@ +getSignalSlotDispatcher(); + $dispatcher->connect( + FileMonitor::class, + 'filesHaveChanged', + static function ($fileMonitorId, $changedFiles) use ($bootstrap) { + if ($fileMonitorId !== 'Flow_TranslationFiles') { + return; + } + if ($changedFiles !== []) { + $cacheManager = $bootstrap->getObjectManager()->get(CacheManager::class); + $cache = $cacheManager->getCache('DigiComp_FlowTranslationEndpoint_Responses'); + $cache->flush(); + } + } + ); + } +} From ed40987fea50b2602d9f2904796a995e9a40d140 Mon Sep 17 00:00:00 2001 From: Ferdinand Kuhl Date: Tue, 8 Aug 2023 17:02:13 +0200 Subject: [PATCH 5/6] As the tests shown, that the custom parameter breaks browser based caching, we now enforce the 'traditional url' approach and added cache control information for the browser --- .../TransformTranslationRequestMiddleware.php | 46 ----------------- Classes/Http/TranslationRequestMiddleware.php | 50 ++++++++++++++++--- Configuration/Objects.yaml | 22 +++----- Configuration/Settings.yaml | 11 ++-- Configuration/Testing/Settings.yaml | 5 +- README.md | 16 +++--- .../Functional/TranslationMiddlewareTest.php | 35 ++++--------- 7 files changed, 77 insertions(+), 108 deletions(-) delete mode 100644 Classes/Http/TransformTranslationRequestMiddleware.php diff --git a/Classes/Http/TransformTranslationRequestMiddleware.php b/Classes/Http/TransformTranslationRequestMiddleware.php deleted file mode 100644 index c43395c..0000000 --- a/Classes/Http/TransformTranslationRequestMiddleware.php +++ /dev/null @@ -1,46 +0,0 @@ -headerName = $headerName; - $this->reactOnPath = $reactOnPath; - $this->translateGetParam = $translateGetParam; - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - if ($this->reactOnPath !== null && $request->getUri()->getPath() === $this->reactOnPath) { - $request = $request->withAddedHeader( - $this->headerName, - $request->getQueryParams()[$this->translateGetParam] ?? '' - ); - } - return $handler->handle($request); - } -} diff --git a/Classes/Http/TranslationRequestMiddleware.php b/Classes/Http/TranslationRequestMiddleware.php index d25bc3d..a91b47e 100644 --- a/Classes/Http/TranslationRequestMiddleware.php +++ b/Classes/Http/TranslationRequestMiddleware.php @@ -8,6 +8,7 @@ use GuzzleHttp\Psr7\Response; use Neos\Cache\Exception as CacheException; use Neos\Cache\Exception\InvalidDataException; use Neos\Cache\Frontend\StringFrontend; +use Neos\Flow\Annotations as Flow; use Neos\Flow\I18n\Detector; use Neos\Flow\I18n\Service; use Neos\Flow\I18n\Xliff\Service\XliffFileProvider; @@ -22,7 +23,17 @@ class TranslationRequestMiddleware implements MiddlewareInterface /** * @var string */ - protected string $headerName; + protected string $reactOnPath; + + /** + * @var string + */ + protected string $getParameterName; + + /** + * @var int + */ + protected int $browserCacheMaxAge; /** * @var Service @@ -45,13 +56,17 @@ class TranslationRequestMiddleware implements MiddlewareInterface protected StringFrontend $responseCache; public function __construct( - string $headerName, + string $reactOnPath, + string $getParameterName, + int $browserCacheMaxAge, Service $i18nService, XliffFileProvider $fileProvider, Detector $detector, StringFrontend $responseCache ) { - $this->headerName = $headerName; + $this->reactOnPath = $reactOnPath; + $this->getParameterName = $getParameterName; + $this->browserCacheMaxAge = $browserCacheMaxAge; $this->i18nService = $i18nService; $this->fileProvider = $fileProvider; $this->detector = $detector; @@ -68,7 +83,7 @@ class TranslationRequestMiddleware implements MiddlewareInterface */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - if ($request->hasHeader($this->headerName)) { + if ($request->getUri()->getPath() === $this->reactOnPath) { return $this->createResponse($request); } return $handler->handle($request); @@ -88,7 +103,7 @@ class TranslationRequestMiddleware implements MiddlewareInterface $bestMatching = $this->i18nService->findBestMatchingLocale($wishedLocale); $this->i18nService->getConfiguration()->setCurrentLocale($bestMatching); } - $idPatternList = $request->getHeader($this->headerName)[0] ?? ''; + $idPatternList = $request->getQueryParams()[$this->getParameterName] ?? ''; $cacheId = $this->i18nService->getConfiguration()->getCurrentLocale() . '_' . \sha1(\serialize($idPatternList)); if ($this->responseCache->has($cacheId)) { $response = $this->responseCache->get($cacheId); @@ -97,7 +112,16 @@ class TranslationRequestMiddleware implements MiddlewareInterface $response = \json_encode($result, \JSON_THROW_ON_ERROR); $this->responseCache->set($cacheId, $response); } - return new Response(200, ['Content-Type' => 'application/json'], $response); + return new Response( + 200, + [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'max-age=' . $this->browserCacheMaxAge . ', must-revalidate', + 'Last-Modified' => $this->getCacheDate(), + 'Vary' => 'Accept, Accept-Language' + ], + $response + ); } /** @@ -140,4 +164,18 @@ class TranslationRequestMiddleware implements MiddlewareInterface } return $result; } + + /** + * @return string + */ + protected function getCacheDate(): string + { + // the translation file monitor will flush our complete cache, each time a file is modified. That way it resets + // the last modified date here, too. + if (false === $lastModified = $this->responseCache->get('lastModified')) { + $lastModified = (new \DateTimeImmutable())->format(\DATE_RFC7231); + $this->responseCache->set('lastModified', $lastModified); + } + return $lastModified; + } } diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index 196f3a3..18221ee 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -1,13 +1,3 @@ -DigiComp.FlowTranslationEndpoint:TransformTranslationRequestMiddleware: - className: "DigiComp\\FlowTranslationEndpoint\\Http\\TransformTranslationRequestMiddleware" - arguments: - 1: - setting: "DigiComp.FlowTranslationEndpoint.headerName" - 2: - setting: "DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.reactOnPath" - 3: - setting: "DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.translateGetParam" - DigiComp.FlowTranslationEndpoint:TranslationResponseCache: className: "Neos\\Cache\\Frontend\\StringFrontend" factoryObjectName: "Neos\\Flow\\Cache\\CacheManager" @@ -21,12 +11,16 @@ DigiComp.FlowTranslationEndpoint:TranslationRequestMiddleware: autowiring: true arguments: 1: - setting: "DigiComp.FlowTranslationEndpoint.headerName" + setting: "DigiComp.FlowTranslationEndpoint.reactOnPath" 2: - object: "Neos\\Flow\\I18n\\Service" + setting: "DigiComp.FlowTranslationEndpoint.getParameterName" 3: - object: "Neos\\Flow\\I18n\\Xliff\\Service\\XliffFileProvider" + setting: "DigiComp.FlowTranslationEndpoint.browserCacheMaxAge" 4: - object: "Neos\\Flow\\I18n\\Detector" + object: "Neos\\Flow\\I18n\\Service" 5: + object: "Neos\\Flow\\I18n\\Xliff\\Service\\XliffFileProvider" + 6: + object: "Neos\\Flow\\I18n\\Detector" + 7: object: "DigiComp.FlowTranslationEndpoint:TranslationResponseCache" diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 727af5a..5953567 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -2,16 +2,13 @@ Neos: Flow: http: middlewares: - translationReplace: - position: "before translation" - middleware: "DigiComp.FlowTranslationEndpoint:TransformTranslationRequestMiddleware" translation: position: "start 100" middleware: "DigiComp.FlowTranslationEndpoint:TranslationRequestMiddleware" DigiComp: FlowTranslationEndpoint: - replaceRoutedEndpoint: - reactOnPath: ~ - translateGetParam: "idPatterns" - headerName: "X-Translation-Request" + reactOnPath: '/xliff-units' + getParameterName: "idPatterns" + # default is 6 minutes, so we "expect" in average 3 minutes of "old" translation in worst case + browserCacheMaxAge: 360 diff --git a/Configuration/Testing/Settings.yaml b/Configuration/Testing/Settings.yaml index 5afa1eb..806032f 100644 --- a/Configuration/Testing/Settings.yaml +++ b/Configuration/Testing/Settings.yaml @@ -1,5 +1,4 @@ DigiComp: FlowTranslationEndpoint: - replaceRoutedEndpoint: - reactOnPath: 'testing/translate' - headerName: "X-Translation-Request" + reactOnPath: 'testing/translate' + getParameterName: "idPatterns" diff --git a/README.md b/README.md index 632bf41..7a11702 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ This package is designed to help bringing needed translations to javascript comp Other solutions, which would generate files available for usage in client scope, have the disadvantage that one would have to repeat the relativ complex overriding and merging logic of Flow. With this endpoint you can get the same content, as you would get, if you call the translation service with your translation id. -The main components are a `CurrentHtmlLangViewHelper`, which is intended to be used to fill the `lang` attribute of the `html` tag, so the frontend knows, which language is currently active (and is good practice anyway) and a `TranslationRequestMiddleware`, which will respond to any request, which have a `X-Translation-Request` header, carrying a pattern of translation ids which should be returned. +The main components are a `CurrentHtmlLangViewHelper`, which is intended to be used to fill the `lang` attribute of the `html` tag, so the frontend knows, which language is currently active (and is good practice anyway) and a `TranslationRequestMiddleware`, which will respond to any request, where the request path equals `DigiComp.FlowTranslationEndpoint.reactOnPath` (Default: "/xliff-units"), and search for unit patterns in the `DigiComp.FlowTranslationEndpoint.getParameterName` (Default: "idPatterns"). For example: ```` -X-Translation-Request: Neos.Flow:Main|authentication.* +GET /xliff-units?idPatterns=Neos.Flow:Main|authentication.* ```` would return all translation keys from the main unit of `Neos.Flow` starting with "authentication" and would look like that: @@ -34,9 +34,11 @@ Your JavaScript could look like that: ```javascript async function translate(idPatterns) { - const response = await fetch(document.location, {headers: { - 'X-Translation-Request': idPatterns, - 'Accept-Language': document.documentElement.lang + const uri = new URL('/xliff-units', document.location); + uri.searchParams.set('idPatterns', idPatterns); + const response = await fetch(uri, {headers: { + 'Accept': 'application/json', + 'Accept-Language': document.documentElement.lang, }}); if (! response.ok) { return Promise.reject('Unexpected server response'); @@ -44,5 +46,5 @@ async function translate(idPatterns) { return await response.json(); } ``` - -If, for whatever reason, you prefer to have a "traditional" single endpoint, which works without a custom header, you can set `DigiComp.FlowTranslationEndpoint.replaceRoutedEndpoint.reactOnPath`. At this point a second middleware (`TransformGetTranslationRequestMiddleware`) transforms all incoming requests and the GET parameter `idPatterns` (you can change names in Settings.yaml). +Last but not least: +Do not forget to have a lot of fun. diff --git a/Tests/Functional/TranslationMiddlewareTest.php b/Tests/Functional/TranslationMiddlewareTest.php index 0e93ddc..bfb3f05 100644 --- a/Tests/Functional/TranslationMiddlewareTest.php +++ b/Tests/Functional/TranslationMiddlewareTest.php @@ -20,31 +20,6 @@ class TranslationMiddlewareTest extends FunctionalTestCase $this->serverRequestFactory = $this->objectManager->get(ServerRequestFactoryInterface::class); } - /** - * @test - */ - public function itRespondsToRequestsWithTheConfiguredHeader(): void - { - $request = $this->serverRequestFactory->createServerRequest('GET', 'dummyUrl'); - $request = $request - ->withHeader('X-Translation-Request', 'DigiComp.FlowTranslationEndpoint:Test|.*') - ->withHeader('Accept-Language', 'en'); - $response = $this->browser->sendRequest($request); - static::assertEquals( - '{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"en_key1"}}', - (string)$response->getBody() - ); - $request = $this->serverRequestFactory->createServerRequest('GET', 'dummyUrl'); - $request = $request - ->withHeader('X-Translation-Request', 'DigiComp.FlowTranslationEndpoint:Test|.*') - ->withHeader('Accept-Language', 'de'); - $response = $this->browser->sendRequest($request); - static::assertEquals( - '{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"de_key1"}}', - (string)$response->getBody() - ); - } - /** * @test */ @@ -59,5 +34,15 @@ class TranslationMiddlewareTest extends FunctionalTestCase '{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"en_key1"}}', (string)$response->getBody() ); + + $request = $this->serverRequestFactory->createServerRequest('GET', 'testing/translate'); + $request = $request + ->withQueryParams(['idPatterns' => 'DigiComp.FlowTranslationEndpoint:Test|.*']) + ->withHeader('Accept-Language', 'de'); + $response = $this->browser->sendRequest($request); + static::assertEquals( + '{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"de_key1"}}', + (string)$response->getBody() + ); } } From c1226604bfeee910fc55a2c90c65ff8d9e264533 Mon Sep 17 00:00:00 2001 From: Ferdinand Kuhl Date: Tue, 8 Aug 2023 17:28:14 +0200 Subject: [PATCH 6/6] Better documentation for the lessons learned --- Classes/Http/TranslationRequestMiddleware.php | 1 - .../ViewHelpers/ParameterNameViewHelper.php | 22 +++++++++++++++++++ Classes/ViewHelpers/UriViewHelper.php | 22 +++++++++++++++++++ README.md | 12 ++++++++-- 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 Classes/ViewHelpers/ParameterNameViewHelper.php create mode 100644 Classes/ViewHelpers/UriViewHelper.php diff --git a/Classes/Http/TranslationRequestMiddleware.php b/Classes/Http/TranslationRequestMiddleware.php index a91b47e..207d073 100644 --- a/Classes/Http/TranslationRequestMiddleware.php +++ b/Classes/Http/TranslationRequestMiddleware.php @@ -8,7 +8,6 @@ use GuzzleHttp\Psr7\Response; use Neos\Cache\Exception as CacheException; use Neos\Cache\Exception\InvalidDataException; use Neos\Cache\Frontend\StringFrontend; -use Neos\Flow\Annotations as Flow; use Neos\Flow\I18n\Detector; use Neos\Flow\I18n\Service; use Neos\Flow\I18n\Xliff\Service\XliffFileProvider; diff --git a/Classes/ViewHelpers/ParameterNameViewHelper.php b/Classes/ViewHelpers/ParameterNameViewHelper.php new file mode 100644 index 0000000..62f806d --- /dev/null +++ b/Classes/ViewHelpers/ParameterNameViewHelper.php @@ -0,0 +1,22 @@ +parameterName; + } +} diff --git a/Classes/ViewHelpers/UriViewHelper.php b/Classes/ViewHelpers/UriViewHelper.php new file mode 100644 index 0000000..fb2a3be --- /dev/null +++ b/Classes/ViewHelpers/UriViewHelper.php @@ -0,0 +1,22 @@ +reactOnPath; + } +} diff --git a/README.md b/README.md index 7a11702..6475265 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ Other solutions, which would generate files available for usage in client scope, The main components are a `CurrentHtmlLangViewHelper`, which is intended to be used to fill the `lang` attribute of the `html` tag, so the frontend knows, which language is currently active (and is good practice anyway) and a `TranslationRequestMiddleware`, which will respond to any request, where the request path equals `DigiComp.FlowTranslationEndpoint.reactOnPath` (Default: "/xliff-units"), and search for unit patterns in the `DigiComp.FlowTranslationEndpoint.getParameterName` (Default: "idPatterns"). +"idPatterns" is built with following syntax: +`packageName:catalogName|SEARCH_REGEX, ANOTHER PATTERN...` + For example: ```` GET /xliff-units?idPatterns=Neos.Flow:Main|authentication.* @@ -30,12 +33,17 @@ would return all translation keys from the main unit of `Neos.Flow` starting wit To let the middleware know, in which langauge the translated units should be, you should set the correct `Accept-Language`-Header with your request, which you obtained from the `lang` attribute of the `html` element. +Given your HTML head looks like that: +```html + +``` + Your JavaScript could look like that: ```javascript async function translate(idPatterns) { - const uri = new URL('/xliff-units', document.location); - uri.searchParams.set('idPatterns', idPatterns); + const uri = new URL(document.documentElement.dataset.xliffUri, document.location); + uri.searchParams.set(document.documentElement.dataset.xliffParameter, idPatterns); const response = await fetch(uri, {headers: { 'Accept': 'application/json', 'Accept-Language': document.documentElement.lang,