Can JMSI18nRoutingBundle use HTTP Accept-Language array? - symfony

I'm trying to do an internationalized website, with an URL prefix for each language I translated (eg. /fr/my/page or /it/my/page).
I tried JMSI18nRoutingBundle and it works pretty good with almost no additional configuration. But I really want to determine automatically the user preferred language.
The user's favorite languages are transmitted into the Accept-Language HTTP header, and I want to choose the first language I have a translation for.
Here is my JMSI18nRouting config:
jms_i18n_routing:
default_locale: en
locales: [fr, en]
strategy: prefix_except_default
I want this type of behaviour:
http://mywebsite.com/my/page do an automatic language detection then a redirection to /xx/... (where xx is the user favorite language) because language is not specified in URL — Presently the default language is EN.
http://mywebsite.com/XX/my/page shows the page in XX language — Presently, works fine.
Any idea to do this ? Is the config OK ?
Oh, and, if anyone has a solution to do the same thing in pure Symfony (without JMSI18nRoutingBundle), my ears are widely open.
EDIT / Found a way to have intelligent redirections with JMSI18nRoutingBundle to respect user's favorite language or let user force the display of a language. See my answer.

Finally, I answer my question.
I developed a small "patch" that uses JMSI18nRoutingBundle and detects the user's preferred language, and also let the user force a language.
Create listener YourBundle/EventListener/LocaleListener.php
This listener will change the URL if the user's preferred locale is different to the locale defined by Symfony or JMSI18nRoutingBundle. In this way, you have two URL for two different contents in two different languages : it's SEO friendly.
You can also create a language selector composed of links hrefing to ?setlang=xx where xx is the language the user wants to display. The listener will detect the setlang query and will force the display of the xx lang, including in the next requests.
Note the $this->translatable = [... array. It let you define what parts of your site are translated/translatable. The granularity can be defined from the vendor to the action method.
You can also create a config node to define your translatable vendors/bundles/controllers, I don't made this because of performance considerations.
<?php
namespace YourVendor\YourBundle\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class LocaleListener implements EventSubscriberInterface
{
private $defaultLocale;
private $acceptedLocales;
private $translatable;
public function __construct($router, $defaultLocale, $acceptedLocales)
{
$this->router = $router;
$this->defaultLocale = $defaultLocale;
$this->acceptedLocales = $acceptedLocales;
$this->translatable = [
'Vendor1',
'Vendor2\Bundle1',
'Vendor2\Bundle2\Controller1',
'Vendor2\Bundle2\Controller2::myPageAction',
];
}
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
$route = $request->get('_route');
if(!empty($newLocale = $request->query->get('setlang'))) {
if(in_array($newLocale, $this->acceptedLocales)) {
$cookie = new Cookie('force_lang', $newLocale, time() + 3600 * 24 * 7);
$url = $this->router->generate($route, ['_locale' => $newLocale] + $request->attributes->get('_route_params'));
$response = new RedirectResponse($url);
$response->headers->setCookie($cookie);
$event->setResponse($response);
}
} else if($this->translatable($request->attributes->get('_controller'))) {
$preferred = empty($force = $request->cookies->get('force_lang')) ? $request->getPreferredLanguage($this->acceptedLocales) : $force;
if($preferred && $request->attributes->get('_locale') != $preferred) {
$url = $this->router->generate($route, ['_locale' => $preferred] + $request->attributes->get('_route_params'));
$event->setResponse(new RedirectResponse($url));
}
}
}
private function translatable($str)
{
foreach($this->translatable as $t) {
if(strpos($str, $t) !== false) return true;
}
return false;
}
public static function getSubscribedEvents()
{
return [ KernelEvents::REQUEST => [['onKernelRequest', 200]] ];
}
}
Bind your listener on the HTTP kernel.
Edit your services.yml file.
services:
app.event_listener.locale_listener:
class: YourVendor\YourBundle\EventListener\LocaleListener
arguments: ["#router", "%kernel.default_locale%", "%jms_i18n_routing.locales%"]
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
Configuration of JMSI18nRoutingBundle
You have nothing to change.
Example:
# JMS i18n Routing Configuration
jms_i18n_routing:
default_locale: "%locale%"
locales: [fr, en]
strategy: prefix_except_default

Here's a method to do it using straight Symfony. It might feel a tad hacky because it requires specifying 2 routes per each action, so if someone can think of a better way I'm all ears.
First, I would define some sort of config parameter for all of the acceptable locales, and list the first one as the default
parameters.yml.dist:
parameters:
accepted_locales: [en, es, fr]
Then make sure your Controller routes match for when _locale is both set and not set. Use the same route name for both, except suffix the one without a _locale with a delimiter like |:
/**
* #Route("/{_locale}/test/{var}", name="test")
* #Route( "/test/{var}", name="test|")
*/
public function testAction(Request $request, $var, $_locale = null)
{
// whatever your controller action does
}
Next define a service that will listen on the Controller event and pass your accepted locales to it:
<service id="kernel.listener.locale" class="My\Bundle\EventListener\LocaleListener">
<tag name="kernel.event_listener" event="kernel.controller" method="onKernelController" />
<argument>%accepted_locales%</argument>
</service>
Now use the service to detect if _locale is set in your route, and if not, determine the locale based on the HTTP_ACCEPT_LANGUAGE header and redirect to the route that contains it. Here's an example listener that will do this (I added comments to explain what I was doing):
namespace NAB\UtilityBundle\EventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
class ControllerListener
{
private $acceptedLocales;
public function __construct(array $acceptedLocales)
{
$this->acceptedLocales = $acceptedLocales;
}
public function onKernelController(FilterControllerEvent $event)
{
if (HttpKernelInterface::MASTER_REQUEST != $event->getRequestType()) {
return;
}
$controller = $event->getController();
if (!is_array($controller)) {
return;
}
$request = $event->getRequest();
$params = $request->attributes->get('_route_params');
// return if _locale is already set on the route
if ($request->attributes->get('_locale')) {
return;
}
// if the user has accepted languages set, set the locale on the first match found
$languages = $request->server->get('HTTP_ACCEPT_LANGUAGE');
if (!empty($languages))
{
foreach (explode(',', $languages) as $language)
{
$splits = array();
$pattern = '/^(?P<primarytag>[a-zA-Z]{2,8})(?:-(?P<subtag>[a-zA-Z]{2,8}))?(?:(?:;q=)(?P<quantifier>\d\.\d))?$/';
// if the user's locale matches the accepted locales, set _locale in the route params
if (preg_match($pattern, $language, $splits) && in_array($splits['primarytag'], $this->acceptedLocales))
{
$params['_locale'] = $splits['primarytag'];
// stop checking once the first match is found
break;
}
}
}
// if no locale was found, default to the first accepted locale
if (!$params['_locale']) {
$params['_locale'] = $this->acceptedLocales[0];
}
// drop the '|' to get the appropriate route name
list($localeRoute) = explode('|', $request->attributes->get('_route'));
// attempt get the redirect URL but return if it could not be found
try {
$redirectUrl = $controller[0]->generateUrl($localeRoute, $params);
}
catch (\Exception $e) {
return;
}
// set the controller response to redirect to the route we just created
$event->setController(function() use ($redirectUrl) {
return new RedirectResponse($redirectUrl);
});
}
}
For further explanation on setting up a before filter on a Controller, check out the Symfony documentation here. If you use something like this, be very careful that every route name is defined properly.

Another more usable solution
Go to the vendor of I18nRoutingBundle and edit the listener
/www/vendor/jms/i18n-routing-bundle/JMS/I18nRoutingBundle/EventListener
Replace
$locale = $this->localeResolver->resolveLocale($request, $this->locales) ?: $this->defaultLocale;
by
$locale = $this->localeResolver->resolveLocale($request, $this->locales) ?: $request->getPreferredLanguage($this->locales);
(It is cleaner to overide the listener than to directly edit the vendors)

Related

Removing some schemas/models from API-Platforms Swagger/OpenAPI documentation output

API-Platform will generate Swagger/OpenAPI route documentation and then below documentation for the Schemas (AKA Models) (the docs show them as "Models" but current versions such as 2.7 show them as "Schemas").
Where is the content generated to show these schemas/models? How can some be removed? The functionality to display them is part of Swagger-UI, but API-Platform must be responsible for providing the JSON configuration and thus which to change API-Platform and not Swagger-UI. Note that this post shows how to add a schema but not how to remove one. Is there any documentation on the subject other than this which doesn't go into detail?
As seen by the output below, I am exposing AbstractOrganization, however, this class is extended by a couple other classes and is not meant to be exposed, but only schemas for the concrete classes should be exposed. Note that my AbstractOrganization entity class is not tagged with #ApiResource and is not shown in the Swagger/OpenAPI routing documentation but only the schema/model documentation.
Thank you
I am pretty certain there are better ways to implement this, however, the following will work and might be helpful for others.
<?php
declare(strict_types=1);
namespace App\OpenApi;
use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\Core\OpenApi\OpenApi;
use ApiPlatform\Core\OpenApi\Model\Paths;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class OpenApiRouteHider implements OpenApiFactoryInterface {
public function __construct(private OpenApiFactoryInterface $decorated, private TokenStorageInterface $tokenStorage)
{
}
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
$removedPaths = $this->getRemovedPaths();
$paths = new Paths;
$pathArray = $openApi->getPaths()->getPaths();
foreach($openApi->getPaths()->getPaths() as $path=>$pathItem) {
if(!isset($removedPaths[$path])) {
// No restrictions
$paths->addPath($path, $pathItem);
}
elseif($removedPaths[$path]!=='*') {
// Remove one or more operation
foreach($removedPaths[$path] as $operation) {
$method = 'with'.ucFirst($operation);
$pathItem = $pathItem->$method(null);
}
$paths->addPath($path, $pathItem);
}
// else don't add this route to the documentation
}
$openApiTest = $openApi->withPaths($paths);
return $openApi->withPaths($paths);
}
private function getRemovedPaths():array
{
// Use $user to determine which ones to remove.
$user = $this->tokenStorage->getToken()->getUser();
return [
'/guids'=>'*', // Remove all operations
'/guids/{guid}'=>'*', // Remove all operations
'/tenants'=>['post', 'get'], // Remove only post and get operations
'/tenants/{uuid}'=>['delete'], // Remove only delete operation
'/chart_themes'=>'*',
'/chart_themes/{id}'=>['put', 'delete', 'patch'],
];
}
}

Symfony 5 dynamic routing resolve

I am migrating legacy project routing (Yii1) to Symfony 5
Right now my config/routing.yaml looks something like this:
- {path: '/login', methods: ['GET'], controller: 'App\Controller\RestController::actionLogin'}
- {path: '/logout', methods: ['GET'], controller: 'App\Controller\RestController::actionLogout'}
# [...]
- {path: '/readme', methods: ['GET'], controller: 'App\Controller\RestController::actionReadme'}
As you can see there is plenty of repetitive url to action conversion.
Is it possible to dynamically resolve controller method depending on some parameter. E.g.
- {path: '/{action<login|logout|...|readme>}', methods: ['GET'], controller: 'App\Controller\RestController::action<action>'}
One option would be to write annotations, but that somehow does not work for me and throws Route.php not found
The controller is determined by a RequestListener, specifically the router RouterListener. This in turn uses UrlMatcher to check the uri against the RouteCollection. You could implement a Matcher that resolves the controller based on the route. All you have to do is return an array with a _controller key.
Take note that this solution won't allow you to generate a url from a route name, since that's a different Interface, but you could wire it together.
// src/Routing/NaiveRequestMatcher
namespace App\Routing;
use App\Controller\RestController;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;
use Symfony\Component\Routing\RequestContext;
class NaiveRequestMatcher implements UrlMatcherInterface
{
private $matcher;
/**
* #param $matcher The original 'router' service (implements UrlMatcher)
*/
public function __construct($matcher)
{
$this->matcher = $matcher;
}
public function setContext(RequestContext $context)
{
return $this->matcher->setContext($context);
}
public function getContext()
{
return $this->matcher->getContext();
}
public function match(string $pathinfo)
{
try {
// Check if the route is already defined
return $this->matcher->match($pathinfo);
} catch (ResourceNotFoundException $resourceNotFoundException) {
// Allow only GET requests
if ('GET' != $this->getContext()->getMethod()) {
throw $resourceNotFoundException;
}
// Get the first component of the uri
$routeName = current(explode('/', ltrim($pathinfo, '/')));
// Check that the method is available...
$baseControllerClass = RestController::class;
$controller = $baseControllerClass.'::action'.ucfirst($routeName);
if (is_callable($controller)) {
return [
'_controller' => $controller,
];
}
// Or bail
throw $resourceNotFoundException;
}
}
}
Now you need to override the Listener configuration:
// config/services.yaml
Symfony\Component\HttpKernel\EventListener\RouterListener:
arguments:
- '#App\Routing\NaiveRequestMatcher'
App\Routing\NaiveRequestMatcher:
arguments:
- '#router.default'
Not sure if it's the best approach, but seems the simpler one. The other option that comes to mind is to hook into the RouteCompiler itself.

Decorate all services that implement the same interface by default?

I have a growing number of service classes that share a common interface (let's say BarService and BazService, that implement FooInterface).
All of these need to be decorated with the same decorator. Reading the docs, I know that I can do:
services:
App\BarDecorator:
# overrides the App\BarService service
decorates: App\BarService
Since I have to use the same decorator for different services I guess I would need to do:
services:
bar_service_decorator:
class: App\BarDecorator
# overrides the App\BarService service
decorates: App\BarService
baz_service_decorator:
class: App\BarDecorator
# overrides the App\BazService service
decorates: App\BazService
Problem is: this gets repetitive, quickly. And every time a new implementation of FooInterface is created, another set needs to be added to the configuration.
How can I declare that I want to decorate all services that implement FooInterface automatically, without having to declare each one individually?
A compiler pass allows to modify the container programmatically, to alter service definitions or add new ones.
First you'll need a way to locate all implementations of FooInterface. You can do this with the help of autoconfigure:
services:
_instanceof:
App\FooInterface:
tags: ['app.bar_decorated']
Then you'll need to create the compiler pass that collects all FooServices and creates a new decorated definition:
// src/DependencyInjection/Compiler/FooInterfaceDecoratorPass.php
namespace App\DependencyInjection\Compiler;
use App\BarDecorator;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class FooInterfaceDecoratorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
if (!$container->has(BarDecorator::class)) {
// If the decorator isn't registered in the container you could register it here
return;
}
$taggedServices = $container->findTaggedServiceIds('app.bar_decorated');
foreach ($taggedServices as $id => $tags) {
// skip the decorator, we do it's not self-decorated
if ($id === BarDecorator::class) {
continue;
}
$decoratedServiceId = $this->generateAliasName($id);
// Add the new decorated service.
$container->register($decoratedServiceId, BarDecorator::class)
->setDecoratedService($id)
->setPublic(true)
->setAutowired(true);
}
}
/**
* Generate a snake_case service name from the service class name
*/
private function generateAliasName($serviceName)
{
if (false !== strpos($serviceName, '\\')) {
$parts = explode('\\', $serviceName);
$className = end($parts);
$alias = strtolower(preg_replace('/[A-Z]/', '_\\0', lcfirst($className)));
} else {
$alias = $serviceName;
}
return $alias . '_decorator';
}
}
Finally, register the compiler pass in the kernel:
// src/Kernel.php
use App\DependencyInjection\Compiler\FooInterfaceDecoratorPass;
class Kernel extends BaseKernel
{
// ...
protected function build(ContainerBuilder $container)
{
$container->addCompilerPass(new FooInterfaceDecoratorPass());
}
}
Interesting! I think that's going to be tricky... but maybe with some hints here you might come up with a solution that fits your needs
find all Decorators... not sure if there's an easier way in that case but I use tags for that. So create a DecoratorInterface add auto tag it...
loop through the definitions and and modify and set the decorated service
e. g. in your Kernel or AcmeAwesomeBundle do
protected function build(ContainerBuilder $container)
{
$container->registerForAutoconfiguration(DecoratorInterface::class)
->addTag('my.decorator.tag');
$decoratorIds = $container->findTaggedServiceIds('my.decorator.tag');
foreach ($decoratorIds as $decoratorId) {
$definition = $container->getDefinition($decoratorId);
$decoratedServiceId = $this->getDecoratedServiceId($definition);
$definition->setDecoratedService($decoratedServiceId);
}
}
private function getDecoratedServiceId(Definition $decoratorDefinition): string
{
// todo
// maybe u can use the arguments here
// e.g. the first arg is always the decoratedService
// might not work because the arguments are not resolved yet?
$arg1 = $decoratorDefinition->getArgument(0);
// or use a static function in your DecoratorInterface like
// public static function getDecoratedServiceId():string;
$class = $decoratorDefinition->getClass();
$decoratedServiceId = $class::getDecoratedServiceId();
return 'myDecoratedServiceId';
}
I'm pretty sure this is not complete yet but let us know how you solved it

Symfony 2: Two step (not two-factor) authentication

I need to implement a two step (not two-factor) authentication in Symfony 2.3. The first step is the usual user+password+csrf form. The second step is "Terms & Conditions", which the user should see when they first log in or when the terms are updated, and they should have to tick a box in order to proceed to the rest of the site.
The second step isn't really an authentication step, but a user shouldn't be able to access the rest of the site unless that second step is acted upon, so it makes sense to conceptually think of it as part of the authentication.
While writing this, the rubber duck tells me that I should think about authorisation instead, and the idea of starting the user on a "didn't accept terms yet" role, and updating the role to "fully authorised user" if the terms have been accepted. This sounds like the most sound solution so far, as I can let the firewall take care of the logic.
Stumbled upon these pieces of information so far:
http://blogsh.de/2011/11/15/change-user-roles-during-a-session-in-symfony/
http://php-and-symfony.matthiasnoback.nl/2012/07/symfony2-security-creating-dynamic-roles-using-roleinterface/
There's one behaviour that I expect to encounter as I dig deeper into this: the firewall will display an error instead of re-directing the user to the Terms page and then let them on their way once they accept them.
Has anybody done this before, so I have to invent as little of the wheel as possible?
I found somebody with a similar problem, and he received a solution I could use:
Symfony 2 : Redirect a user to a page if he has a specific role
The event listener class:
namespace Acme\DemoBundle\Lib;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\HttpKernel;
use Acme\DemoBundle\Entity\User;
class TermsAndConditionsRequestListener
{
private $security;
private $router;
public function __construct($security, $router)
{
$this->security = $security;
$this->router = $router;
}
public function onKernelRequest(GetResponseEvent $event)
{
/* http://symfony.com/doc/2.3/cookbook/service_container/event_listener.html */
if (HttpKernel::MASTER_REQUEST !== $event->getRequestType())
{
// don't do anything if it's not the master request
return;
}
$request = $event->getRequest();
$route = $request->attributes->get('_route');
if ($route === '_wdt' || substr_compare($route, '_profiler', 0, 9) === 0)
{
// ignore development routes
return;
}
if (in_array($route, array('terms_and_conditions_force', 'terms_and_conditions_accept')))
{
// don't redirect into an infinite loop
return;
}
$token = $this->security->getToken();
$user = $token ? $token->getUser() : null;
$user_role = ($user instanceof User) ? $user->getRole() : null;
if ($user_role === 'ROLE_USER' && (is_null($user->getTermsAcceptedDate()) || $terms_are_newer_than_acceptance_date))
{
$url = $this->router->generate('terms_and_conditions_force');
$event->setResponse(new RedirectResponse($url));
}
}
}
The event listener service:
acme.wvml.event_listener.request.terms_and_conditions:
class: Acme\DemoBundle\Lib\TermsAndConditionsRequestListener
arguments: [#security.context, #router]
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
You will have to extend the symfony UserAuthenticationProvider. you'll probably want to add the check in the checkAuthentication function and if it fails return the error message regarding the terms and conditions.

Is it possible to add custom routes during compilation passes?

I prepare external bundle and I would like to add some routes during compilation passes.
Routes will be created on the main app/config/config.yml settings.
I was trying to get router from ContainerBuilder in my CustomCompilerPass via:
$definition = $container->getDefinition('router');
, but I got The service definition "router" does not exist.
Is it possible to add custom routes during compilation passes?
There's no way to add routes at compiler passes.
In order to dynamicly load routes (aware of container parameters) I'd use a custom route loader as given in my previous example
class MyLoader extends Loader
{
protected $params;
public function __construct($params)
{
$this->params = $params;
}
public function supports($resource, $type = null)
{
return $type === 'custom' && $this->params == 'YourLogic';
}
public function load($resource, $type = null)
{
// This method will only be called if it suits the parameters
$routes = new RouteCollection;
$resource = '#AcmeFooBundle/Resources/config/dynamic_routing.yml';
$type = 'yaml';
$routes->addCollection($this->import($resource, $type));
return $routes;
}
}
routing.yml
_custom_routes:
resource: .
type: custom
router is an alias, not a service. To get that from a ContainerBuilder, use ContainerBuilder::getAlias. To get the service ID, you need to cast that object to a string: (string) $container->getAlias('router'). Now, you can use that ID to get the service: $container->getDefinition($container->getAlias('router')). And then you get the Service which you can modify to add routes.
BTW, I'm not sure if this is really the thing you want. What about using the CmfRoutingBundle. Then, you use the Chain Router, so you can use both the Symfony2 router and the DynamicRouter. The DynamicRouter can be used with a custom route provider, in which you return the routes you want (you can get them from every resource you want).

Resources