Skip to content

Commit

Permalink
feat: Translation support for application eg. validation messages, et…
Browse files Browse the repository at this point in the history
…c. (#311)

* feat: Translation support for application eg. validation messages, etc.

* Added support for translated authentication failure.
  • Loading branch information
tarlepp committed Feb 22, 2020
1 parent 8d1130e commit d61bc9d
Show file tree
Hide file tree
Showing 11 changed files with 297 additions and 8 deletions.
4 changes: 4 additions & 0 deletions config/packages/framework.yaml
Expand Up @@ -4,6 +4,10 @@ framework:
csrf_protection: false
#http_method_override: true

default_locale: '%locale%'
translator:
default_path: '%kernel.project_dir%/translations'

serializer:
enable_annotations: true

Expand Down
2 changes: 1 addition & 1 deletion config/packages/security.yaml
Expand Up @@ -27,7 +27,7 @@ security:
provider: security_user_provider
check_path: /auth/getToken
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
failure_handler: App\Security\Handler\TranslatedAuthenticationFailureHandler
root:
pattern: ^/$
stateless: true
Expand Down
2 changes: 1 addition & 1 deletion config/packages/stof_doctrine_extensions.yaml
@@ -1,7 +1,7 @@
# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
# See the official DoctrineExtensions documentation for more details: https://github.com/Atlantic18/DoctrineExtensions/tree/master/doc/
stof_doctrine_extensions:
default_locale: en_US
default_locale: '%locale%'
orm:
default:
timestampable: true
Expand Down
3 changes: 1 addition & 2 deletions config/packages/translation.yaml
@@ -1,6 +1,5 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
- '%locale%'
1 change: 1 addition & 0 deletions config/services.yaml
Expand Up @@ -26,6 +26,7 @@ services:
$projectDir: '%kernel.project_dir%'
$environment: '%kernel.environment%'
$uuidRegex: '%app.uuid_regex%'
$locale: '%locale%'
_instanceof:
App\Rest\Interfaces\ControllerInterface:
tags: [ 'app.rest.controller' ]
Expand Down
93 changes: 93 additions & 0 deletions src/EventSubscriber/AcceptLanguageSubscriber.php
@@ -0,0 +1,93 @@
<?php
declare(strict_types = 1);
/**
* /src/EventSubscriber/LocaleSubscriber.php
*
* @author TLe, Tarmo Leppänen <tarmo.leppanen@protacon.com>
*/

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use function in_array;

/**
* Class LocaleSubscriber
*
* @package App\EventSubscriber
* @author TLe, Tarmo Leppänen <tarmo.leppanen@protacon.com>
*/
class AcceptLanguageSubscriber implements EventSubscriberInterface
{
// Supported locales
public const LOCALE_EN = 'en';
public const LOCALE_FI = 'fi';

/**
* @var array<int, string>
*/
public const SUPPORTED_LOCALES = [
self::LOCALE_EN,
self::LOCALE_FI,
];

private string $defaultLocale;

/**
* LocaleSubscriber constructor.
*
* @param string $locale
*/
public function __construct(string $locale)
{
$this->defaultLocale = $locale;
}

/**
* Returns an array of event names this subscriber wants to listen to.
*
* The array keys are event names and the value can be:
*
* * The method name to call (priority defaults to 0)
* * An array composed of the method name to call and the priority
* * An array of arrays composed of the method names to call and respective
* priorities, or 0 if unset
*
* For instance:
*
* * ['eventName' => 'methodName']
* * ['eventName' => ['methodName', $priority]]
* * ['eventName' => [['methodName1', $priority], ['methodName2']]]
*
* @return array The event names to listen to
*/
public static function getSubscribedEvents(): array
{
return [
RequestEvent::class => [
'onKernelRequest',
100, // Note that this needs to at least `100` to get translation messages as expected
],
];
}

/**
* Method to change used locale according to current request.
*
* @param RequestEvent $event
*/
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();

$locale = $request->headers->get('Accept-Language', $this->defaultLocale);

// Ensure that given locale is supported, if not fallback to default.
if (!in_array($locale, self::SUPPORTED_LOCALES, true)) {
$locale = $this->defaultLocale;
}

$request->setLocale($locale);
}
}
12 changes: 8 additions & 4 deletions src/Rest/Traits/RestResourceBaseMethods.php
Expand Up @@ -12,13 +12,15 @@
use App\Entity\Interfaces\EntityInterface;
use App\Repository\Interfaces\BaseRepositoryInterface;
use App\Utils\JSON;
use JsonException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Exception\ValidatorException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Throwable;
use function get_class;
use function str_replace;

/**
* Trait RestResourceBaseMethods
Expand Down Expand Up @@ -458,7 +460,7 @@ private function validateDto(RestDtoInterface $dto, bool $skipValidation): void

// Oh noes, we have some errors
if ($errors !== null && $errors->count() > 0) {
$this->createValidatorException($errors);
$this->createValidatorException($errors, get_class($dto));
}
}

Expand All @@ -476,7 +478,7 @@ private function validateEntity(EntityInterface $entity, bool $skipValidation):

// Oh noes, we have some errors
if ($errors !== null && $errors->count() > 0) {
$this->createValidatorException($errors);
$this->createValidatorException($errors, get_class($entity));
}
}

Expand All @@ -495,10 +497,11 @@ private function createEntity(): EntityInterface

/**
* @param ConstraintViolationListInterface $errors
* @param string $target
*
* @throws Throwable
* @throws JsonException
*/
private function createValidatorException(ConstraintViolationListInterface $errors): void
private function createValidatorException(ConstraintViolationListInterface $errors, string $target): void
{
$output = [];

Expand All @@ -507,6 +510,7 @@ private function createValidatorException(ConstraintViolationListInterface $erro
$output[] = [
'message' => $error->getMessage(),
'propertyPath' => $error->getPropertyPath(),
'target' => str_replace('\\', '.', $target),
'code' => $error->getCode(),
];
}
Expand Down
66 changes: 66 additions & 0 deletions src/Security/Handler/TranslatedAuthenticationFailureHandler.php
@@ -0,0 +1,66 @@
<?php
declare(strict_types = 1);
/**
* /src/Security/Handler/TranslatedAuthenticationFailureHandler.php
*
* @author TLe, Tarmo Leppänen <tarmo.leppanen@protacon.com>
*/

namespace App\Security\Handler;

use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationFailureEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
* Class TranslatedAuthenticationFailureHandler
*
* @package App\Security\Handler
* @author TLe, Tarmo Leppänen <tarmo.leppanen@protacon.com>
*/
class TranslatedAuthenticationFailureHandler extends AuthenticationFailureHandler
{
private TranslatorInterface $translator;

/**
* TranslatedAuthenticationFailureHandler constructor.
*
* @param EventDispatcherInterface $dispatcher
* @param TranslatorInterface $translator
*/
public function __construct(EventDispatcherInterface $dispatcher, TranslatorInterface $translator)
{
parent::__construct($dispatcher);

$this->translator = $translator;
}

/**
* @inheritDoc
*
* @param Request $request
* @param AuthenticationException $exception
*
* @return Response
*
* @noinspection PhpMissingParentCallCommonInspection
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
$event = new AuthenticationFailureEvent(
$exception,
new JWTAuthenticationFailureResponse(
$this->translator->trans('Invalid credentials.', [], 'security')
)
);

$this->dispatcher->dispatch($event);

return $event->getResponse();
}
}
74 changes: 74 additions & 0 deletions tests/Integration/EventSubscriber/AcceptLanguageSubscriberTest.php
@@ -0,0 +1,74 @@
<?php
declare(strict_types = 1);
/**
* /tests/Integration/EventSubscriber/AcceptLanguageSubscriberTest.php
*
* @author TLe, Tarmo Leppänen <tarmo.leppanen@protacon.com>
*/

namespace App\Tests\Integration\EventSubscriber;

use App\EventSubscriber\AcceptLanguageSubscriber;
use Generator;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
* Class AcceptLanguageSubscriberTest
*
* @package App\Tests\Integration\EventSubscriber
* @author TLe, Tarmo Leppänen <tarmo.leppanen@protacon.com>
*/
class AcceptLanguageSubscriberTest extends KernelTestCase
{
public function testThatSpecifiedDefaultLanguageIsSet(): void
{
static::bootKernel();

$request = new Request();
$request->headers->set('Accept-Language', 'foo');

$event = new RequestEvent(static::$kernel, $request, HttpKernelInterface::MASTER_REQUEST);

$subscriber = new AcceptLanguageSubscriber('bar');
$subscriber->onKernelRequest($event);

static::assertSame('bar', $request->getLocale());
}

/**
* @dataProvider dataProviderTestThatLocaleIsSetAsExpected
*
* @param string $expected
* @param string $default
* @param string $asked
*
* @testdox Test that when default locale is `$default` and when asking `$asked` locale result is `$expected`.
*/
public function testThatLocaleIsSetAsExpected(string $expected, string $default, string $asked): void
{
static::bootKernel();

$request = new Request();
$request->headers->set('Accept-Language', $asked);

$event = new RequestEvent(static::$kernel, $request, HttpKernelInterface::MASTER_REQUEST);

$subscriber = new AcceptLanguageSubscriber($default);
$subscriber->onKernelRequest($event);

static::assertSame($expected, $request->getLocale());
}

/**
* @return Generator
*/
public function dataProviderTestThatLocaleIsSetAsExpected(): Generator
{
yield ['fi', 'fi', 'fi'];
yield ['fi', 'fi', 'sv'];
yield ['en', 'fi', 'en'];
}
}
34 changes: 34 additions & 0 deletions tests/Unit/EventSubscriber/AcceptLanguageSubscriberTest.php
@@ -0,0 +1,34 @@
<?php
declare(strict_types = 1);
/**
* /tests/Unit/EventSubscriber/AcceptLanguageSubscriberTest.php
*
* @author TLe, Tarmo Leppänen <tarmo.leppanen@protacon.com>
*/

namespace App\Tests\Unit\EventSubscriber;

use App\EventSubscriber\AcceptLanguageSubscriber;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\Event\RequestEvent;

/**
* Class AcceptLanguageSubscriberTest
*
* @package App\Tests\Unit\EventSubscriber
* @author TLe, Tarmo Leppänen <tarmo.leppanen@protacon.com>
*/
class AcceptLanguageSubscriberTest extends KernelTestCase
{
public function testThatGetSubscribedEventsReturnsExpected(): void
{
$expected = [
RequestEvent::class => [
'onKernelRequest',
100,
],
];

static::assertSame($expected, AcceptLanguageSubscriber::getSubscribedEvents());
}
}
14 changes: 14 additions & 0 deletions translations/security+intl-icu.fi.xlf
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fi" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="qr0aiUo" resname="Invalid credentials.">
<source>Invalid credentials.</source>
<target>Virheelliset käyttäjätunnukset.</target>
</trans-unit>
</body>
</file>
</xliff>

0 comments on commit d61bc9d

Please sign in to comment.