Skip to content

Commit

Permalink
When using pretty URLs, redirect ugly URLs automatically
Browse files Browse the repository at this point in the history
  • Loading branch information
javiereguiluz committed Jan 12, 2025
1 parent b76eca4 commit c951246
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 c951246

Please sign in to comment.