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
Some checks failed
ci/woodpecker/push/code-style Pipeline failed
ci/woodpecker/push/functional-tests Pipeline was successful

This commit is contained in:
Ferdinand Kuhl 2023-08-08 17:02:13 +02:00
parent 38e45f5d8f
commit ed40987fea
7 changed files with 77 additions and 108 deletions

View file

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace DigiComp\FlowTranslationEndpoint\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class TransformTranslationRequestMiddleware implements MiddlewareInterface
{
/**
* @var string
*/
protected string $headerName;
/**
* @var string|null
*/
protected ?string $reactOnPath;
/**
* @var string
*/
protected string $translateGetParam;
public function __construct(string $headerName, ?string $reactOnPath, string $translateGetParam)
{
$this->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);
}
}

View file

@ -8,6 +8,7 @@ use GuzzleHttp\Psr7\Response;
use Neos\Cache\Exception as CacheException; use Neos\Cache\Exception as CacheException;
use Neos\Cache\Exception\InvalidDataException; use Neos\Cache\Exception\InvalidDataException;
use Neos\Cache\Frontend\StringFrontend; use Neos\Cache\Frontend\StringFrontend;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\I18n\Detector; use Neos\Flow\I18n\Detector;
use Neos\Flow\I18n\Service; use Neos\Flow\I18n\Service;
use Neos\Flow\I18n\Xliff\Service\XliffFileProvider; use Neos\Flow\I18n\Xliff\Service\XliffFileProvider;
@ -22,7 +23,17 @@ class TranslationRequestMiddleware implements MiddlewareInterface
/** /**
* @var string * @var string
*/ */
protected string $headerName; protected string $reactOnPath;
/**
* @var string
*/
protected string $getParameterName;
/**
* @var int
*/
protected int $browserCacheMaxAge;
/** /**
* @var Service * @var Service
@ -45,13 +56,17 @@ class TranslationRequestMiddleware implements MiddlewareInterface
protected StringFrontend $responseCache; protected StringFrontend $responseCache;
public function __construct( public function __construct(
string $headerName, string $reactOnPath,
string $getParameterName,
int $browserCacheMaxAge,
Service $i18nService, Service $i18nService,
XliffFileProvider $fileProvider, XliffFileProvider $fileProvider,
Detector $detector, Detector $detector,
StringFrontend $responseCache StringFrontend $responseCache
) { ) {
$this->headerName = $headerName; $this->reactOnPath = $reactOnPath;
$this->getParameterName = $getParameterName;
$this->browserCacheMaxAge = $browserCacheMaxAge;
$this->i18nService = $i18nService; $this->i18nService = $i18nService;
$this->fileProvider = $fileProvider; $this->fileProvider = $fileProvider;
$this->detector = $detector; $this->detector = $detector;
@ -68,7 +83,7 @@ class TranslationRequestMiddleware implements MiddlewareInterface
*/ */
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
if ($request->hasHeader($this->headerName)) { if ($request->getUri()->getPath() === $this->reactOnPath) {
return $this->createResponse($request); return $this->createResponse($request);
} }
return $handler->handle($request); return $handler->handle($request);
@ -88,7 +103,7 @@ class TranslationRequestMiddleware implements MiddlewareInterface
$bestMatching = $this->i18nService->findBestMatchingLocale($wishedLocale); $bestMatching = $this->i18nService->findBestMatchingLocale($wishedLocale);
$this->i18nService->getConfiguration()->setCurrentLocale($bestMatching); $this->i18nService->getConfiguration()->setCurrentLocale($bestMatching);
} }
$idPatternList = $request->getHeader($this->headerName)[0] ?? ''; $idPatternList = $request->getQueryParams()[$this->getParameterName] ?? '';
$cacheId = $this->i18nService->getConfiguration()->getCurrentLocale() . '_' . \sha1(\serialize($idPatternList)); $cacheId = $this->i18nService->getConfiguration()->getCurrentLocale() . '_' . \sha1(\serialize($idPatternList));
if ($this->responseCache->has($cacheId)) { if ($this->responseCache->has($cacheId)) {
$response = $this->responseCache->get($cacheId); $response = $this->responseCache->get($cacheId);
@ -97,7 +112,16 @@ class TranslationRequestMiddleware implements MiddlewareInterface
$response = \json_encode($result, \JSON_THROW_ON_ERROR); $response = \json_encode($result, \JSON_THROW_ON_ERROR);
$this->responseCache->set($cacheId, $response); $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 $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;
}
} }

View file

@ -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: DigiComp.FlowTranslationEndpoint:TranslationResponseCache:
className: "Neos\\Cache\\Frontend\\StringFrontend" className: "Neos\\Cache\\Frontend\\StringFrontend"
factoryObjectName: "Neos\\Flow\\Cache\\CacheManager" factoryObjectName: "Neos\\Flow\\Cache\\CacheManager"
@ -21,12 +11,16 @@ DigiComp.FlowTranslationEndpoint:TranslationRequestMiddleware:
autowiring: true autowiring: true
arguments: arguments:
1: 1:
setting: "DigiComp.FlowTranslationEndpoint.headerName" setting: "DigiComp.FlowTranslationEndpoint.reactOnPath"
2: 2:
object: "Neos\\Flow\\I18n\\Service" setting: "DigiComp.FlowTranslationEndpoint.getParameterName"
3: 3:
object: "Neos\\Flow\\I18n\\Xliff\\Service\\XliffFileProvider" setting: "DigiComp.FlowTranslationEndpoint.browserCacheMaxAge"
4: 4:
object: "Neos\\Flow\\I18n\\Detector" object: "Neos\\Flow\\I18n\\Service"
5: 5:
object: "Neos\\Flow\\I18n\\Xliff\\Service\\XliffFileProvider"
6:
object: "Neos\\Flow\\I18n\\Detector"
7:
object: "DigiComp.FlowTranslationEndpoint:TranslationResponseCache" object: "DigiComp.FlowTranslationEndpoint:TranslationResponseCache"

View file

@ -2,16 +2,13 @@ Neos:
Flow: Flow:
http: http:
middlewares: middlewares:
translationReplace:
position: "before translation"
middleware: "DigiComp.FlowTranslationEndpoint:TransformTranslationRequestMiddleware"
translation: translation:
position: "start 100" position: "start 100"
middleware: "DigiComp.FlowTranslationEndpoint:TranslationRequestMiddleware" middleware: "DigiComp.FlowTranslationEndpoint:TranslationRequestMiddleware"
DigiComp: DigiComp:
FlowTranslationEndpoint: FlowTranslationEndpoint:
replaceRoutedEndpoint: reactOnPath: '/xliff-units'
reactOnPath: ~ getParameterName: "idPatterns"
translateGetParam: "idPatterns" # default is 6 minutes, so we "expect" in average 3 minutes of "old" translation in worst case
headerName: "X-Translation-Request" browserCacheMaxAge: 360

View file

@ -1,5 +1,4 @@
DigiComp: DigiComp:
FlowTranslationEndpoint: FlowTranslationEndpoint:
replaceRoutedEndpoint:
reactOnPath: 'testing/translate' reactOnPath: 'testing/translate'
headerName: "X-Translation-Request" getParameterName: "idPatterns"

View file

@ -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. 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: 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: 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 ```javascript
async function translate(idPatterns) { async function translate(idPatterns) {
const response = await fetch(document.location, {headers: { const uri = new URL('/xliff-units', document.location);
'X-Translation-Request': idPatterns, uri.searchParams.set('idPatterns', idPatterns);
'Accept-Language': document.documentElement.lang const response = await fetch(uri, {headers: {
'Accept': 'application/json',
'Accept-Language': document.documentElement.lang,
}}); }});
if (! response.ok) { if (! response.ok) {
return Promise.reject('Unexpected server response'); return Promise.reject('Unexpected server response');
@ -44,5 +46,5 @@ async function translate(idPatterns) {
return await response.json(); return await response.json();
} }
``` ```
Last but not least:
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). Do not forget to have a lot of fun.

View file

@ -20,31 +20,6 @@ class TranslationMiddlewareTest extends FunctionalTestCase
$this->serverRequestFactory = $this->objectManager->get(ServerRequestFactoryInterface::class); $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 * @test
*/ */
@ -59,5 +34,15 @@ class TranslationMiddlewareTest extends FunctionalTestCase
'{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"en_key1"}}', '{"DigiComp.FlowTranslationEndpoint:Test":{"key1":"en_key1"}}',
(string)$response->getBody() (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()
);
} }
} }