symfony-cmf / routing-bundle

Symfony bundle to provide the CMF chain router to handle multiple routers, and the dynamic router to load routes from a database or other sources.
159 stars 78 forks source link

symfony-cmf doctrine ORM multi-language support #484

Closed chris-k-k closed 2 years ago

chris-k-k commented 2 years ago

Environment

PC - Windows 10 Xampp Apache/2.4.53 (Win64) OpenSSL/1.1.1n PHP/8.1.6 10.4.24-MariaDB database

Symfony packages

symfony/apache-pack                v1.0.1  v1.0.1  A pack for Apache support in Symfony
symfony/asset                      v6.1.0  v6.1.0  Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files
symfony/browser-kit                v6.1.0  v6.1.0  Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically
symfony/cache                      v6.1.1  v6.1.1  Provides an extended PSR-6, PSR-16 (and tags) implementation
symfony/cache-contracts            v3.1.0  v3.1.0  Generic abstractions related to caching
symfony/config                     v6.1.0  v6.1.0  Helps you find, load, combine, autofill and validate configuration values of any kind
symfony/console                    v6.1.1  v6.1.1  Eases the creation of beautiful and testable command line interfaces
symfony/css-selector               v6.1.0  v6.1.0  Converts CSS selectors to XPath expressions
symfony/debug-bundle               v6.1.0  v6.1.0  Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework
symfony/dependency-injection       v6.1.0  v6.1.0  Allows you to standardize and centralize the way objects are constructed in your application
symfony/deprecation-contracts      v3.1.0  v3.1.0  A generic function and convention to trigger deprecation notices
symfony/doctrine-bridge            v6.1.0  v6.1.0  Provides integration for Doctrine with various Symfony components
symfony/doctrine-messenger         v6.1.1  v6.1.1  Symfony Doctrine Messenger Bridge
symfony/dom-crawler                v6.1.0  v6.1.0  Eases DOM navigation for HTML and XML documents
symfony/dotenv                     v6.1.0  v6.1.0  Registers environment variables from a .env file
symfony/error-handler              v6.1.0  v6.1.0  Provides tools to manage errors and ease debugging PHP code
symfony/event-dispatcher           v6.1.0  v6.1.0  Provides tools that allow your application components to communicate with each other by dispatching events and listening to them
symfony/event-dispatcher-contracts v3.1.0  v3.1.0  Generic abstractions related to dispatching event
symfony/expression-language        v6.1.0  v6.1.0  Provides an engine that can compile and evaluate expressions
symfony/filesystem                 v6.1.0  v6.1.0  Provides basic utilities for the filesystem
symfony/finder                     v6.1.0  v6.1.0  Finds files and directories via an intuitive fluent interface
symfony/flex                       v2.2.2  v2.2.2  Composer plugin for Symfony
symfony/form                       v6.1.1  v6.1.1  Allows to easily create, process and reuse HTML forms
symfony/framework-bundle           v6.1.1  v6.1.1  Provides a tight integration between Symfony components and the Symfony full-stack framework
symfony/http-client                v6.1.1  v6.1.1  Provides powerful methods to fetch HTTP resources synchronously or asynchronously
symfony/http-client-contracts      v3.1.0  v3.1.0  Generic abstractions related to HTTP clients
symfony/http-foundation            v6.1.1  v6.1.1  Defines an object-oriented layer for the HTTP specification
symfony/http-kernel                v6.1.1  v6.1.1  Provides a structured process for converting a Request into a Response
symfony/intl                       v6.1.0  v6.1.0  Provides a PHP replacement layer for the C intl extension that includes additional data from the ICU library
symfony/mailer                     v6.1.1  v6.1.1  Helps sending emails
symfony/maker-bundle               v1.43.0 v1.43.0 Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.
symfony/messenger                  v6.1.0  v6.1.0  Helps applications send and receive messages to/from other applications or via message queues
symfony/mime                       v6.1.1  v6.1.1  Allows manipulating MIME messages
symfony/monolog-bridge             v6.1.1  v6.1.1  Provides integration for Monolog with various Symfony components
symfony/monolog-bundle             v3.8.0  v3.8.0  Symfony MonologBundle
symfony/notifier                   v6.1.0  v6.1.0  Sends notifications via one or more channels (email, SMS, ...)
symfony/options-resolver           v6.1.0  v6.1.0  Provides an improved replacement for the array_replace PHP function
symfony/password-hasher            v6.1.0  v6.1.0  Provides password hashing utilities
symfony/phpunit-bridge             v6.1.0  v6.1.0  Provides utilities for PHPUnit, especially user deprecation notices management
symfony/polyfill-intl-grapheme     v1.26.0 v1.26.0 Symfony polyfill for intl's grapheme_* functions
symfony/polyfill-intl-icu          v1.26.0 v1.26.0 Symfony polyfill for intl's ICU-related data and classes
symfony/polyfill-intl-idn          v1.26.0 v1.26.0 Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions
symfony/polyfill-intl-normalizer   v1.26.0 v1.26.0 Symfony polyfill for intl's Normalizer class and related functions
symfony/polyfill-mbstring          v1.26.0 v1.26.0 Symfony polyfill for the Mbstring extension
symfony/process                    v6.1.0  v6.1.0  Executes commands in sub-processes
symfony/property-access            v6.1.0  v6.1.0  Provides functions to read and write from/to an object or array using a simple string notation
symfony/property-info              v6.1.1  v6.1.1  Extracts information about PHP class' properties using metadata of popular sources
symfony/proxy-manager-bridge       v6.1.0  v6.1.0  Provides integration for ProxyManager with various Symfony components
symfony/routing                    v6.1.1  v6.1.1  Maps an HTTP request to a set of configuration variables
symfony/runtime                    v6.1.1  v6.1.1  Enables decoupling PHP applications from global state
symfony/security-bundle            v6.1.0  v6.1.0  Provides a tight integration of the Security component into the Symfony full-stack framework
symfony/security-core              v6.1.0  v6.1.0  Symfony Security Component - Core Library
symfony/security-csrf              v6.1.0  v6.1.0  Symfony Security Component - CSRF Library
symfony/security-http              v6.1.1  v6.1.1  Symfony Security Component - HTTP Integration
symfony/serializer                 v6.1.1  v6.1.1  Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.
symfony/service-contracts          v3.1.0  v3.1.0  Generic abstractions related to writing services
symfony/stopwatch                  v6.1.0  v6.1.0  Provides a way to profile code
symfony/string                     v6.1.0  v6.1.0  Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way
symfony/translation                v6.1.0  v6.1.0  Provides tools to internationalize your application
symfony/translation-contracts      v3.1.0  v3.1.0  Generic abstractions related to translation
symfony/twig-bridge                v6.1.0  v6.1.0  Provides integration for Twig with various Symfony components
symfony/twig-bundle                v6.1.1  v6.1.1  Provides a tight integration of Twig into the Symfony full-stack framework
symfony/validator                  v6.1.1  v6.1.1  Provides tools to validate values
symfony/var-dumper                 v6.1.0  v6.1.0  Provides mechanisms for walking through any arbitrary PHP variable
symfony/var-exporter               v6.1.1  v6.1.1  Allows exporting any serializable PHP data structure to plain PHP code
symfony/web-link                   v6.1.0  v6.1.0  Manages links between resources
symfony/web-profiler-bundle        v6.1.1  v6.1.1  Provides a development tool that gives detailed information about the execution of any request
symfony/yaml                       v6.1.0  v6.1.0  Loads and dumps YAML files

Symfony CMF packages

symfony-cmf/routing        3.0.0 3.0.0 Extends the Symfony routing component for dynamic routes and chaining several routers
symfony-cmf/routing-bundle 3.0.1 3.0.1 Symfony RoutingBundle

Subject

It looks like the multi-language support when using the doctrine ORM variant isn't working correctly. When setting the 'locales' parameter in 'cmf_routing.yaml' the matcher is unable to find a match when the url contains the locale (e.g. '/de/my-content').

I was able to pin down the source of this issue - at least, what I think the issue may be: The getCandidates method of class Candidates correctly removes the the locale from the URL string and adds it to the list of possible candidates for a match. The getRouteCollectionForRequest method of class RouteProvider is able to retrieve the route from the database (w/o the locale).

But this all seems to be ignored in the finalMatch method of class UrlMatcher: the RouteCollection derived from the candidates list is injected, but the method tries to find a match with $request->getPathInfo() which still contains the url including the locale prefix. (E.g. instead of trying to match with the static prefix '/test-page' w/o locale, it tries to match with '/de/test-page'.

A quick hack to demonstrate a work-around (it works but it's kinda ugly - I'm not yet familiar enough with Symfony to find an elegant solution ;) ):

public function finalMatch(RouteCollection $collection, Request $request): array
    {
        $this->routes = $collection;
        $context = new RequestContext();
        $context->fromRequest($request);
        $this->setContext($context);

        // somehow retrieve the static prefix of a route in the route collection
        // since it's the finalMatch method, it ought to be the first route in the collection  
        // (not iterrate over the whole collection likein this example)...
        $staticPrefix = '';
        if (0 !== count($this->routes)) {
            foreach ($this->routes as $routeName => $route) {
                $staticPrefix = $route->getStaticPrefix();
            }
        }
        // and use it as path to find a match with a dynamic route from the database
        return $this->match($staticPrefix);

        //return $this->match($request->getPathInfo());
    }

Another solution might be to remove the locale from the context path info and pass $context->getPathInfo() to the match method:

// after the locale has been removed via $context->setPathInfo()
return $this->match($context->getPathInfo());

Or it might be I'm wrong and just missing something here - perhaps a faulty config setting...

Steps to reproduce

Expected results

Requests which contain a locale prefix ('de, 'en') ought to match with the static prefix of a dynamic route (_match_implicitlocale set to default/true): /de/my-content -> /my-content /en/my-content -> /my-content /my-content -> /my-content

(with the controller/template handling the translation)

Actual results

Adding a locale prefix to the url leads to a ResourceNotFound exception even though the route with its static prefix is present in the database.

Symfony\Component\Routing\Exception\ResourceNotFoundException:
None of the routers in the chain matched this request
GET /test-project/public/de/my-content HTTP/1.1
Accept:                    text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding:           gzip, deflate, br
Accept-Language:           de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Connection:                keep-alive
Cookie:                    _ga=GA1.1.268010310.1652883282; _gcl_au=1.1.66907003.1655271385; _ga_4WEEX52EKE=GS1.1.1655271385.1.1.1655272348.0; cookieconsent_status=allow
Host:                      localhost
Sec-Ch-Ua:                 " Not A;Brand";v="99", "Chromium";v="102", "Google Chrome";v="102"
Sec-Ch-Ua-Mobile:          ?0
Sec-Ch-Ua-Platform:        "Windows"
Sec-Fetch-Dest:            document
Sec-Fetch-Mode:            navigate
Sec-Fetch-Site:            none
Sec-Fetch-User:            ?1
Upgrade-Insecure-Requests: 1
User-Agent:                Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36
X-Php-Ob-Level:            1
Cookie: _ga=GA1.1.268010310.1652883282; _gcl_au=1.1.66907003.1655271385; _ga_4WEEX52EKE=GS1.1.1655271385.1.1.1655272348.0; cookieconsent_status=allow

  at C:\xampp\htdocs\test-project\vendor\symfony-cmf\routing\src\ChainRouter.php:180
  at Symfony\Cmf\Component\Routing\ChainRouter->doMatch('/de/my-content', object(Request))
     (C:\xampp\htdocs\test-project\vendor\symfony-cmf\routing\src\ChainRouter.php:134)
  at Symfony\Cmf\Component\Routing\ChainRouter->matchRequest(object(Request))
     (C:\xampp\htdocs\test-project\vendor\symfony\http-kernel\EventListener\RouterListener.php:106)
  at Symfony\Component\HttpKernel\EventListener\RouterListener->onKernelRequest(object(RequestEvent), 'kernel.request', object(TraceableEventDispatcher))
     (C:\xampp\htdocs\test-project\vendor\symfony\event-dispatcher\Debug\WrappedListener.php:115)
  at Symfony\Component\EventDispatcher\Debug\WrappedListener->__invoke(object(RequestEvent), 'kernel.request', object(TraceableEventDispatcher))
     (C:\xampp\htdocs\test-project\vendor\symfony\event-dispatcher\EventDispatcher.php:230)
  at Symfony\Component\EventDispatcher\EventDispatcher->callListeners(array(object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener)), 'kernel.request', object(RequestEvent))
     (C:\xampp\htdocs\test-project\vendor\symfony\event-dispatcher\EventDispatcher.php:59)
  at Symfony\Component\EventDispatcher\EventDispatcher->dispatch(object(RequestEvent), 'kernel.request')
     (C:\xampp\htdocs\test-project\vendor\symfony\event-dispatcher\Debug\TraceableEventDispatcher.php:153)
  at Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher->dispatch(object(RequestEvent), 'kernel.request')
     (C:\xampp\htdocs\test-project\vendor\symfony\http-kernel\HttpKernel.php:128)
  at Symfony\Component\HttpKernel\HttpKernel->handleRaw(object(Request), 1)
     (C:\xampp\htdocs\test-project\vendor\symfony\http-kernel\HttpKernel.php:74)
  at Symfony\Component\HttpKernel\HttpKernel->handle(object(Request), 1, true)
     (C:\xampp\htdocs\test-project\vendor\symfony\http-kernel\Kernel.php:202)
  at Symfony\Component\HttpKernel\Kernel->handle(object(Request))
     (C:\xampp\htdocs\test-project\vendor\symfony\runtime\Runner\Symfony\HttpKernelRunner.php:35)
  at Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner->run()
     (C:\xampp\htdocs\test-project\vendor\autoload_runtime.php:29)
  at require_once('C:\\xampp\\htdocs\\test-project\\vendor\\autoload_runtime.php')
     (C:\xampp\htdocs\test-project\public\index.php:5)    
dbu commented 2 years ago

thanks for the detailed error report. i don't have time right now to dig through the code to see what exactly happens. but looking at your report, i see that you seem run the application in a sub-path of your server /test-project/public/de/my-content. if things are set up correctly and work when you do not use a locale, it might be that we have a bug when the locale is not the first piece of the path (afaik in the beginning we messed up with assuming the application always runs at /. we fixed things about that but maybe we missed something).

could you please check if this is the reason? do things work if the application runs at the root path? (it should be possible to test that using symfony serve if you use the symfony binary, or php -S localhost:8888 (in the public folder) otherwise.)

chris-k-k commented 2 years ago

Thank you for your quick reply! :) Unfortunately, running the application at the root path doesn't fix the issue.

Symfony\Component\HttpKernel\Exception\NotFoundHttpException:
No route found for "GET http://127.0.0.1:8000/en/testpath"

DEBUG08:58:58 | app | Router Symfony\Cmf\Bundle\RoutingBundle\Routing\DynamicRouter was not able to match, message "No routes found for "/en/testpath"."

The $pathinfo variable that is passed to the matchCollection method of class UrlMatcher only contains the relative path (e.g. '/testpath' or '/de/testpath'). Removing the leading locale part from $pathinfo within this method makes it work: it tries to find a match by comparing $trimmedpathinfo to the static prefix stored in the route first:

// check the static prefix of the URL first. Only use the more expensive preg_match when it matches
if ('' !== $staticPrefix && !str_starts_with($trimmedPathinfo, $staticPrefix)) {
    continue;
}

... and then comparing the route's regular expression with $pathinfo second:

if (!preg_match($regex, $pathinfo, $matches)) {
    continue;
}

When 'sanitizing' the url string/removing the leading locale from $pathinfo, everything works as expected. Crude brute-force example just for testing purposes:

protected function matchCollection(string $pathinfo, RouteCollection $routes): array
    {
        $pathinfo = str_replace('/de/', '/', $pathinfo);
        $pathinfo = str_replace('/en/', '/', $pathinfo);
        ...
    }

Log messages:

1) Matched route "test-10".
request Hide context
[
  "route" => "test-10"
  "route_parameters" => [
    "_content_id" => "App\Entity\TestEntity:36"
    "_route" => "test-10"
    "_controller" => "App\Controller\TestEntityController::index"
  ]
  "request_uri" => "http://127.0.0.1:8000/testpath"
  "method" => "GET"
]

2) Matched route "test-10".
[
  "route" => "test-10"
  "route_parameters" => [
    "_content_id" => "App\Entity\TestEntity:36"
    "_route" => "test-10"
    "_controller" => "App\Controller\TestEntityController::index"
  ]
  "request_uri" => "http://127.0.0.1:8000/de/testpath"
  "method" => "GET"
]

3) Matched route "test-10".
[
  "route" => "test-10"
  "route_parameters" => [
    "_content_id" => "App\Entity\TestEntity:36"
    "_route" => "test-10"
    "_controller" => "App\Controller\TestEntityController::index"
  ]
  "request_uri" => "http://127.0.0.1:8000/en/testpath"
  "method" => "GET"
]
chris-k-k commented 2 years ago

Hey there! :) I think I found a more elegant fix/workaround for this issue I ran into. With 2 small changes I get the desired result:

\symfony-cmf\routing\src\Candidates\Candidates.php

//////////
/**
 * Get the locales that are supported by this strategy.
 *
 * @param string[] $locales The locales that are supported
 */
public function getLocales(): array
{
    return $this->locales;
}
//////////

Adding a getter for $locales to Candidates class makes it possible to retrieve the locales array in the RouteProvider class:

\symfony-cmf\routing-bundle\src\Doctrine\Orm\RouteProvider.php

public function getRouteCollectionForRequest(Request $request): RouteCollection
{
    $collection = new RouteCollection();

    $candidates = $this->candidatesStrategy->getCandidates($request);
    if (0 === \count($candidates)) {
        return $collection;
    }
    $routes = $this->getRouteRepository()->findByStaticPrefix($candidates, ['position' => 'ASC']);
    /** @var $route Route */

    //////////
    $locales = $this->candidatesStrategy->getLocales(); // <-- get the strategy's locales

    foreach ($routes as $route) {
        $collection->add($route->getName(), $route); // <-- add the 'untouched' route to the route collection
        if (0 !== count($locales)) { // <-- check if locales are set
            foreach ($locales as $locale) {
                $multilangRoute = clone $route; // <-- clone the route 
                $multilangRoute->setName($locale . '-' . $route->getName()); // <-- modify the route's name
                $multilangRoute->setStaticPrefix('/' . $locale . $route->getStaticPrefix()); // <-- modify the route's static prefix
                $collection->add($multilangRoute->getName(), $multilangRoute); // <-- add the cloned route to the collection
           }
        }
    }
    //////////

    return $collection;
}

This essentially adds a new route for every locale set in the configuration. After that, the matchCollection method of Class UrlMatcher iterates over all routes in the collection and returns a final match (correctly incorporating the locale prefix).

These 2 changes have the added benefit of being able to generate the url with the locale prefix in a template:

<!-- always returns a path to the unmodified route i.e. without any locale prefix -->
<!-- request uri: '/en/testpath' -> generated path: '/testpath' -->
<!-- myRoute = $contentDocument from the controller -->
<a href="{{ path('cmf_routing_object', {_route_object: myRoute}) }}">Read on</a> 

<!-- the route document contains the modified static prefix i.e. the locale will be added to the path -->
<!-- request uri: '/en/testpath' -> generated path: '/en/testpatch' -->
<!-- myRouteDocument = $routeDocument from the controller -->
<a href="{{ path('cmf_routing_object', {_route_object: myRouteDocument}) }}">Read on</a> 

This workaround accomplishes what I'm looking for w/o the need to change any core files of Symfony, only 2 files in the Cmf Routing Bundle. Unfortunately, I can't tell what repercussions this will have considering the Cmf Routing Bundle as a whole - that's way over my head ;) But if this issue indeed is to be considered a bug, perhaps my workaround will give some pointers where to look for a solid solution. Cheers!

EDIT: Obviously, you could prevent yourself from running into this issue completely by simply storing one route for every language your app is supporting in the database - perhaps in the end, this is even preferable. But I wanted to have the option of only storing one route for all languages with the controller or the template handling the translation...

dbu commented 2 years ago

sorry, for the delay, finally found time to dig into this. looking at the code, i suspect that you create your routes without the add_locale_pattern flag. when you want the url schema to be /{locale}/path/to/content you need to create the route with that flag. that will make it expect the locale as first path element. then the matching should work without changing the code. does that work?

i think i should convert the "Locales" block in https://symfony.com/bundles/CMFRoutingBundle/current/routing-bundle/dynamic.html to explain things better.

chris-k-k commented 2 years ago

EDIT: sry, bad internet connection, hence the double/triple/w.e. post

thank you very much for the reply! no worries about the delay :)

i did as you asked and set this option when creating a route:

    #[Route('/test/{routename}', name: 'app_test')]
    public function index(EntityManagerInterface $manager, ContentRepository $contentRepository, string $routename = 'testroute'): Response
    {
        $post = new TestEntity();
        $post->setName('My Content');
        $manager->persist($post);
        $manager->flush(); // flush to be able to use the generated id

        $route = new CmfRoute();
        $route->setName($routename);
        $route->setStaticPrefix('/' . $routename);
+       $route->setOption('add_locale_pattern', true);
        $route->setDefault(RouteObjectInterface::CONTENT_ID, $contentRepository->getContentId($post));
        $route->setContent($post);

        $post->addRoute($route); // Create the backlink from content to route

        $manager->persist($post);
        $manager->flush();

        return $this->render('test/index.html.twig', [
            'controller_name' => 'TestController',
        ]);
    }

i was completely unaware of that option/flag, sorry. it does produce the intended result! with the minor drawback that the url scheme with locale (/{locale}/pathtocontent) is now enforced ie a url with missing locale prefix gets a 404 response. would be nice to have an option that makes urls w/o locale still find a match as long as the static prefix matches - so one could decide at a later point to either offer the content in the default language or let the 404 response stand - something like [match_implicit_locale] but in reverse if that makes sense... unless this option exists and i'm - again - unaware of it ;)

please consider this issue resolved since the code works as intended. Tyvm again for your help! :)

dbu commented 2 years ago

the documentation was lacking in that part. i added a bunch of documentation in https://github.com/symfony-cmf/routing-docs/pull/4

for supporting no locale: i checked a bit and found some old stackoverflow discussions. you can not have a default before required parts, so setting a default value for _locale on your route entity won't help i think.

however, you normally would not want the same content available under /en/test and /test. my approach to this would be to write an event listener that listens to exceptions, checks if the exception was a NotFoundHttpException and in that case check if the requested url did not start with one of your locales and change the response to a redirect to the requested path with the default locale prepended.