Skip to content

Commit

Permalink
feature #6702 When using pretty URLs, redirect ugly URLs automaticall…
Browse files Browse the repository at this point in the history
…y (javiereguiluz)

This PR was squashed before being merged into the 4.x branch.

Discussion
----------

When using pretty URLs, redirect ugly URLs automatically

Fixes #6701 #6698.

Commits
-------

c951246 When using pretty URLs, redirect ugly URLs automatically
  • Loading branch information
javiereguiluz committed Jan 12, 2025
2 parents b76eca4 + c951246 commit 8efaa68
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 10 deletions.
6 changes: 6 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@
->arg(4, service('router'))
->arg(5, service('cache.easyadmin'))
->arg(6, service(AdminRouteGenerator::class))
->arg(7, '%kernel.build_dir%')
->arg(8, service(CrudControllerRegistry::class))
->tag('kernel.event_subscriber')

->set(ControllerFactory::class)
Expand Down Expand Up @@ -212,9 +214,13 @@
->arg(0, tagged_iterator(EasyAdminExtension::TAG_DASHBOARD_CONTROLLER))
->arg(1, tagged_iterator(EasyAdminExtension::TAG_CRUD_CONTROLLER))
->arg(2, service('cache.easyadmin'))
->arg(3, service('filesystem'))
->arg(4, '%kernel.build_dir%')

->set(AdminRouteLoader::class)
->arg(0, service(AdminRouteGenerator::class))
->arg(1, service('filesystem'))
->arg(2, '%kernel.build_dir%')
->tag('routing.loader', ['type' => AdminRouteLoader::ROUTE_LOADER_TYPE])

->set(UrlSigner::class)
Expand Down
79 changes: 74 additions & 5 deletions src/EventListener/AdminRouterSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@

namespace EasyCorp\Bundle\EasyAdminBundle\EventListener;

use EasyCorp\Bundle\EasyAdminBundle\Cache\CacheWarmer;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\CrudControllerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\DashboardControllerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Router\AdminRouteGeneratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Factory\AdminContextFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\ControllerFactory;
use EasyCorp\Bundle\EasyAdminBundle\Registry\CrudControllerRegistry;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminRouteGenerator;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
Expand Down Expand Up @@ -40,8 +43,11 @@ class AdminRouterSubscriber implements EventSubscriberInterface
private RequestMatcherInterface $requestMatcher;
private CacheItemPoolInterface $cache;
private AdminRouteGeneratorInterface $adminRouteGenerator;
private CrudControllerRegistry $crudControllerRegistry;
private string $buildDir;
private bool $requestAlreadyProcessedAsPrettyUrl = false;

public function __construct(AdminContextFactory $adminContextFactory, ControllerFactory $controllerFactory, ControllerResolverInterface $controllerResolver, UrlGeneratorInterface $urlGenerator, RequestMatcherInterface $requestMatcher, CacheItemPoolInterface $cache, AdminRouteGenerator $adminRouteGenerator)
public function __construct(AdminContextFactory $adminContextFactory, ControllerFactory $controllerFactory, ControllerResolverInterface $controllerResolver, UrlGeneratorInterface $urlGenerator, RequestMatcherInterface $requestMatcher, CacheItemPoolInterface $cache, AdminRouteGenerator $adminRouteGenerator, string $buildDir, CrudControllerRegistry $crudControllerRegistry)
{
$this->adminContextFactory = $adminContextFactory;
$this->controllerFactory = $controllerFactory;
Expand All @@ -50,6 +56,8 @@ public function __construct(AdminContextFactory $adminContextFactory, Controller
$this->requestMatcher = $requestMatcher;
$this->cache = $cache;
$this->adminRouteGenerator = $adminRouteGenerator;
$this->buildDir = $buildDir;
$this->crudControllerRegistry = $crudControllerRegistry;
}

public static function getSubscribedEvents(): array
Expand All @@ -67,21 +75,58 @@ public static function getSubscribedEvents(): array
public function onKernelRequestPrettyUrls(RequestEvent $event): void
{
$request = $event->getRequest();
if (false === $request->attributes->has(EA::ROUTE_CREATED_BY_EASYADMIN)) {
return;
if (false === $request->attributes->getBoolean(EA::ROUTE_CREATED_BY_EASYADMIN)) {
// at this point, the incoming request can be:
// (1) A dashboard URL (they don't have the EasyAdmin route attributes) of an app using pretty URLs
// (2) An ugly URL from EasyAdmin (they all use the main dashboard URL and select the action to execute via query params)
// (3) a regular Symfony request (not related to EasyAdmin)

// check if the URL includes the 'crudControllerFqcn' query parameter
// if it does, this is case (2) and we don't handle it as a pretty URL
if ($request->query->has(EA::CRUD_CONTROLLER_FQCN)) {
return;
}

// this can be case (1) or (3). Sadly, the dashboard routes don't include
// the EasyAdmin route attributes because they are regular Symfony routes.
// I can't find any way (inside our custom route loader, in a compiler pass, etc.) to add the
// custom EasyAdmin route defaults/attributes to other existing Symfony routes. So, we have to
// check if the route of the current request matches any of the cached dashboard routes
$dashboardRoutesCachePath = $this->buildDir.'/'.CacheWarmer::DASHBOARD_ROUTES_CACHE;
$dashboardControllerRoutes = [];
if (file_exists($dashboardRoutesCachePath)) {
try {
$dashboardControllerRoutes = require $dashboardRoutesCachePath;
} catch (\Throwable) {
}
}

// this is not a cached dashboard route, so this is case (3) a regular Symfony request
if (!\array_key_exists($request->attributes->get('_route'), $dashboardControllerRoutes)) {
return;
}
}

// edge-case: in some scenarios, admin routes are generated by the custom route loader
// and their information is cached but then removed from the cache (e.g. when running
// 'rm -fr var/cache/* && bin/console cache:clear'). If that's the case, regenerate the
// admin routes to force saving them in the cache again.
// admin routes (only if the app uses pretty URLs) to force saving them in the cache again.
// see https://github.com/EasyCorp/EasyAdminBundle/issues/6680
$adminRoutes = $this->cache->getItem(AdminRouteGenerator::CACHE_KEY_ROUTE_TO_FQCN)->get();
if (null === $adminRoutes) {
if (null === $adminRoutes && $this->adminRouteGenerator->usesPrettyUrls()) {
$this->adminRouteGenerator->generateAll();
}

$dashboardControllerFqcn = $request->attributes->get(EA::DASHBOARD_CONTROLLER_FQCN);
if (null === $dashboardControllerFqcn) {
if (!isset($dashboardControllerRoutes[$request->attributes->get('_route')])) {
return;
}

$dashboardCallableAsString = $dashboardControllerRoutes[$request->attributes->get('_route')];
[$dashboardControllerFqcn, ] = explode('::', $dashboardCallableAsString);
}

if (null === $dashboardControllerInstance = $this->getDashboardControllerInstance($dashboardControllerFqcn, $request)) {
return;
}
Expand All @@ -97,6 +142,7 @@ public function onKernelRequestPrettyUrls(RequestEvent $event): void
}

$request->attributes->set(EA::CONTEXT_REQUEST_ATTRIBUTE, $adminContext);
$this->requestAlreadyProcessedAsPrettyUrl = true;
}

/**
Expand All @@ -105,11 +151,34 @@ public function onKernelRequestPrettyUrls(RequestEvent $event): void
*/
public function onKernelRequest(RequestEvent $event): void
{
if ($this->requestAlreadyProcessedAsPrettyUrl) {
return;
}

// return early if this is not a URL associated with EasyAdmin
$request = $event->getRequest();
if (null === $dashboardControllerFqcn = $this->getDashboardControllerFqcn($request)) {
return;
}

// if this is a ugly URL from legacy EasyAdmin versions and the application
// uses pretty URLs, redirect to the equivalent pretty URL
if ($this->adminRouteGenerator->usesPrettyUrls()) {
$entityFqcnOrCrudControllerFqcn = $request->query->get(EA::CRUD_CONTROLLER_FQCN);
if (is_subclass_of($entityFqcnOrCrudControllerFqcn, CrudControllerInterface::class)) {
$crudControllerFqcn = $entityFqcnOrCrudControllerFqcn;
} else {
$crudControllerFqcn = $this->crudControllerRegistry->findCrudFqcnByEntityFqcn($entityFqcnOrCrudControllerFqcn);
}

$prettyUrlRoute = $this->adminRouteGenerator->findRouteName($dashboardControllerFqcn, $crudControllerFqcn, $request->query->get(EA::CRUD_ACTION, ''));
$request->query->remove(EA::CRUD_CONTROLLER_FQCN);

$event->setResponse(new RedirectResponse($this->urlGenerator->generate($prettyUrlRoute, $request->query->all())));

return;
}

if (null === $dashboardControllerInstance = $this->getDashboardControllerInstance($dashboardControllerFqcn, $request)) {
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/Router/AdminRouteGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Router\AdminRouteGeneratorInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

Expand Down Expand Up @@ -59,10 +60,14 @@ final class AdminRouteGenerator implements AdminRouteGeneratorInterface
],
];

private ?bool $applicationUsesPrettyUrls = null;

public function __construct(
private iterable $dashboardControllers,
private iterable $crudControllers,
private CacheItemPoolInterface $cache,
private Filesystem $filesystem,
private string $buildDir,
) {
}

Expand All @@ -86,9 +91,7 @@ public function generateAll(): RouteCollection
// TODO: remove this method in EasyAdmin 5.x
public function usesPrettyUrls(): bool
{
$cachedAdminRoutes = $this->cache->getItem(self::CACHE_KEY_FQCN_TO_ROUTE)->get();

return null !== $cachedAdminRoutes && [] !== $cachedAdminRoutes;
return $this->applicationUsesPrettyUrls ??= $this->filesystem->exists(sprintf('%s/%s', $this->buildDir, AdminRouteLoader::PRETTY_URLS_CONTEXT_FILE_NAME));
}

public function findRouteName(string $dashboardFqcn, string $crudControllerFqcn, string $actionName): ?string
Expand Down
23 changes: 23 additions & 0 deletions src/Router/AdminRouteLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use EasyCorp\Bundle\EasyAdminBundle\Contracts\Router\AdminRouteGeneratorInterface;
use Symfony\Component\Config\Loader\Loader;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Routing\RouteCollection;

/**
Expand All @@ -12,9 +13,13 @@
final class AdminRouteLoader extends Loader
{
public const ROUTE_LOADER_TYPE = 'easyadmin.routes';
/** @internal don't use this in your application */
public const PRETTY_URLS_CONTEXT_FILE_NAME = 'easyadmin/application_uses_pretty_urls.txt';

public function __construct(
private AdminRouteGeneratorInterface $adminRouteGenerator,
private Filesystem $filesystem,
private string $buildDir,
) {
parent::__construct(null);
}
Expand All @@ -26,6 +31,24 @@ public function supports($resource, ?string $type = null): bool

public function load($resource, ?string $type = null): RouteCollection
{
// this is ugly, but I can't find any other way of solving this problem.
// Details about the problem to solve: EasyAdmin must support both ugly and
// pretty URLs and the user must not configure anything to enable pretty URLs.
// In som parts of this bundle, we need to know if the custom route loader was
// run to decide if pretty URLs should be used or not.
//
// This can't be solved in any of these ways:
// * the router doesn't provide any feature to know if a route loader was run
// * we can't set a container parameter dynamically because ParameterBag is frozen
// * we can't store something in the cache because it's not reliable (see https://github.com/symfony/symfony/issues/59445)
// * we can't create a "marker service" set inside the custom route loader because
// this only works when the route loader is run the first time (next times, the routes are cached)
// * etc.
//
// The only reliable way is to create a "marker file" in the cache that is not deleted.
// All this will be greatly simplified in EasyAdmin 5.x when pretty URLs will be mandatory.
$this->filesystem->dumpFile(sprintf('%s/%s', $this->buildDir, self::PRETTY_URLS_CONTEXT_FILE_NAME), '');

return $this->adminRouteGenerator->generateAll();
}
}
4 changes: 3 additions & 1 deletion templates/layout.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@
{% block search %}
{% set formActionUrl = null %}
{% if ea.usePrettyUrls %}
{% set formActionUrl = ea_url().setController(ea.request.attributes.get('crudControllerFqcn')).setAction('index').set('page', 1) %}
{# even if the app uses pretty URLs, the user might be using an ugly URL, so the controller might be defined in the query string #}
{% set crudController = ea.request.attributes.get('crudControllerFqcn') ?? ea.request.query.get('crudControllerFqcn') %}
{% set formActionUrl = ea_url().setController(crudController).setAction('index').set('page', 1) %}
{% endif %}
<form class="form-action-search" method="get" {% if formActionUrl %}action="{{ formActionUrl }}"{% endif %}>
{% block search_form %}
Expand Down
36 changes: 36 additions & 0 deletions tests/Controller/PrettyUrls/PrettyUrlsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Controller\PrettyUrls;

use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use EasyCorp\Bundle\EasyAdminBundle\Tests\PrettyUrlsTestApplication\Controller\BlogPostCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Tests\PrettyUrlsTestApplication\Controller\CategoryCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Tests\PrettyUrlsTestApplication\Controller\DashboardController;
use EasyCorp\Bundle\EasyAdminBundle\Tests\PrettyUrlsTestApplication\Entity\Category;
use EasyCorp\Bundle\EasyAdminBundle\Tests\PrettyUrlsTestApplication\Kernel;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

Expand Down Expand Up @@ -278,6 +281,26 @@ public function testAdminUrlGeneratorUsePrettyUrls()
$this->assertSame('http://localhost/admin/pretty/urls/blog-post', $blogPostIndexUrl);
}

/**
* @dataProvider provideUglyUrlRedirects
*/
public function testUglyUrlsAreRedirectedToPrettyUrls(string $crudControllerFqcn, string $crudAction, ?int $entityId, array $extraQueryParameters, string $expectedPrettyUrlRedirect)
{
$client = static::createClient();
$client->followRedirects(false);

$queryParameters = array_merge([
EA::CRUD_CONTROLLER_FQCN => $crudControllerFqcn,
EA::CRUD_ACTION => $crudAction,
EA::ENTITY_ID => $entityId,
], $extraQueryParameters);

$uglyUrl = sprintf('/admin/pretty/urls?%s', http_build_query($queryParameters));
$client->request('GET', $uglyUrl);

$this->assertSame($expectedPrettyUrlRedirect, $client->getResponse()->headers->get('Location'));
}

public static function provideActiveMenuUrls(): iterable
{
yield ['/admin/pretty/urls/category'];
Expand All @@ -292,4 +315,17 @@ public static function provideDefaultPageUrls(): iterable
yield 'Read' => ['/admin/pretty/urls/category/1'];
yield 'Update' => ['/admin/pretty/urls/category/1/edit'];
}

public static function provideUglyUrlRedirects(): iterable
{
yield [CategoryCrudController::class, Action::INDEX, null, [], '/admin/pretty/urls/category'];
yield [Category::class, Action::INDEX, null, [], '/admin/pretty/urls/category'];
yield [Category::class, Action::INDEX, null, ['page' => 2, 'foo' => 'bar'], '/admin/pretty/urls/category?page=2&foo=bar'];
yield [CategoryCrudController::class, Action::DETAIL, 1, [], '/admin/pretty/urls/category/1'];
yield [Category::class, Action::DETAIL, 1, [], '/admin/pretty/urls/category/1'];
yield [CategoryCrudController::class, Action::NEW, null, [], '/admin/pretty/urls/category/new'];
yield [Category::class, Action::NEW, null, [], '/admin/pretty/urls/category/new'];
yield [CategoryCrudController::class, Action::EDIT, 1, [], '/admin/pretty/urls/category/1/edit'];
yield [Category::class, Action::EDIT, 1, [], '/admin/pretty/urls/category/1/edit'];
}
}
3 changes: 2 additions & 1 deletion tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Filesystem\Filesystem;

// needed to avoid encoding issues when running tests on different platforms
setlocale(\LC_ALL, 'en_US.UTF-8');
Expand Down Expand Up @@ -45,7 +46,7 @@
}

// delete the existing cache directory to avoid issues
(new Symfony\Component\Filesystem\Filesystem())->remove($kernel->getCacheDir());
(new Filesystem())->remove($kernel->getCacheDir());

$application = new Application($kernel);
$application->setAutoExit(false);
Expand Down

0 comments on commit 8efaa68

Please sign in to comment.