Skip to content

Commit

Permalink
Add compatibility for #[MapEntity] (Case 178313) (#16)
Browse files Browse the repository at this point in the history
Listen to `ControllerArgumentsEvent` instead of `ControllerEvent`. This
event was introduced in Symfony 3.1 and matches our needs better:
- We can get the controller action arguments in a more direct way and
iterate over them only (typically fewer than request attributes)
- We no longer need to care about the priority of our listener, as the
`ControllerArgumentsEvent` is dispatched only after the arguments have
been resolved
- This allows us to get arguments resolved in other ways, e.g. via a
`#[MapEntity]` attribute

Other improvements:
- Declare all clases final (none of them is supposed to be extended and
part of the bundle's API)
- Refactorings: rename parameters, remove redundant logic, rename method
to be more precise, refactor unit tests for conciseness
  • Loading branch information
MalteWunsch authored Jan 6, 2025
1 parent b3e71a1 commit b35a956
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 185 deletions.
44 changes: 20 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# Slug Validation Bundle #
# Slug Validation Bundle

[![Build Status](https://travis-ci.org/webfactory/slug-validation-bundle.svg?branch=master)](https://travis-ci.org/webfactory/slug-validation-bundle)
[![Coverage Status](https://coveralls.io/repos/github/webfactory/slug-validation-bundle/badge.svg?branch=master)](https://coveralls.io/github/webfactory/slug-validation-bundle?branch=master)
![Tests](https://github.com/webfactory/slug-validation-bundle/workflows/Tests/badge.svg)
![Dependencies](https://github.com/webfactory/slug-validation-bundle/workflows/Dependencies/badge.svg)

Do not clutter your controller actions with URL slug validation: This Symfony bundle helps
to validate object slugs in URLs transparently.

- Checks if a slug is valid (if provided at all)
- Redirects to the URL with the correct slug on failure (for example after a slug change)

## Motivation ##
## Motivation

Handling of URL Slugs is a part of many web applications. Although readable URLs are nice, they usually do not
contribute to your main functionality. Instead, slug validation and handling of redirects in case of failure generates
Expand All @@ -20,7 +20,7 @@ After facing these problems several times, we decided to create a system that ha
of the middleware, that keeps your controller actions clean and lets you concentrate on what is really important:
Your domain problems.

## Installation ##
## Installation

Install the bundle via [Composer](https://getcomposer.org):

Expand All @@ -37,25 +37,21 @@ Enable the bundle:
// ...
];

## Usage ##
## Usage

*Prerequisite*: In order to be able to use the slug validation provided by this bundle,
you have to load your sluggable objects outside of the controller action, e.g. via a
[param converter](http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html),
so that the object is provided as a parameter to the action method.
For Doctrine entities Symfony brings this capability out of the box.
### Prerequisite: Sluggable object as controller action parameter

### Request Your Entity via Param Converter ###

Declare your object as controller action parameter:
Declare your sluggable object as controller action parameter:

public function myAction(MyEntity $entity)
{
}

When using Doctrine entities, your route parameter ``entity`` must contain the entity ID to make this work.

### Implement Sluggable ###
And configure it to be resolved before the controller action is called, e.g. via
[`#[MapEntity]`](https://symfony.com/doc/current/doctrine.html#mapentity-options) or
[`@ParamConverter`](http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html) (deprecated).

### Implement Sluggable

Provide the hint that the entity has a slug that can be validated by implementing
``\Webfactory\SlugValidationBundle\Bridge\SluggableInterface``:
Expand All @@ -68,7 +64,7 @@ Provide the hint that the entity has a slug that can be validated by implementin
}
}

### Add Slug Parameter to Routes ###
### Add Slug Parameter to Routes

Declare a route that contains an ``entitySlug`` parameter and points to your action:

Expand All @@ -81,20 +77,20 @@ That's it! Whenever a sluggable entity is used together with a slug parameter in
step in and perform a validation. If a slug is invalid, then a redirect to the same route with the
corrected slug will be initiated.

### Additional Information ###
### Additional Information

Entity and slug parameters are matched by convention: The slug parameter must use the suffix ``Slug``.
For example the correct parameter name for a ``blogPost`` parameter is ``blogPostSlug``.

If a route contains a sluggable entity but no slug parameter, then nothing will happen, so the usual
Symfony behavior is not changed.

#### Slug Generation ####
#### Slug Generation

If you are not sure how to create your slugs, then you might find [cocur/slugify](https://github.com/cocur/slugify)
useful. A component that generates URL slugs from any string.

#### Simplified Routing ####
#### Simplified Routing

Passing slug values during route generation can be a tedious and error-prone task.
[webfactory/object-routing](https://github.com/webfactory/object-routing) and [webfactory/object-routing-bundle](https://github.com/webfactory/BGObjectRoutingBundle)
Expand All @@ -108,12 +104,12 @@ can ease that task by defining route construction rules directly with your entit
*/
class MyEntity implements SluggableInterface
{
public function getId()
public function getId(): int
{
// ...
}
public function getSlug()
public function getSlug(): ?string
{
// ...
}
Expand All @@ -125,7 +121,7 @@ When generating the URL, you don't have to deal with passing these parameters an

{{ object_path('my_object_route', myEntityInstance) }}

## Credits, Copyright and License ##
## Credits, Copyright and License

This project was started at webfactory GmbH, Bonn.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

class WebfactorySlugValidationExtension extends Extension
final class WebfactorySlugValidationExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
Expand Down
101 changes: 45 additions & 56 deletions src/EventListener/ValidateSlugListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,28 @@
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Webfactory\SlugValidationBundle\Bridge\SluggableInterface;

/**
* Checks if sluggable objects occur in the request attributes (which are mapped to action
* parameters) and validates corresponding slugs, if available.
* This listener is called after the arguments for a controller action are resolved.
*
* This listener must be registered *after* the ParamConverterListener, otherwise
* the validation cannot work.
* It checks these arguments for SluggableInterface implementations and if one is found,
* it checks it's slug against the slug in the route parameters. If the route parameter
* slug is invalid, a RedirectResponse to the URL with the correct slug is created.
*
* Slugs must be available as route parameter. The slug for a parameter "object" is
* expected as "objectSlug" parameter.
* The name of the slug parameter in the route paramters is expected to be the argument
* name + "Slug", e.g. named "objectSlug" for an argument named "object".
*/
class ValidateSlugListener implements EventSubscriberInterface
final class ValidateSlugListener implements EventSubscriberInterface
{
/**
* Priority of this listener. Will run after the param converter.
*/
public const PRIORITY_AFTER_PARAM_CONVERTER_LISTENER = -1;

public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER => ['onKernelController', self::PRIORITY_AFTER_PARAM_CONVERTER_LISTENER],
KernelEvents::CONTROLLER_ARGUMENTS => 'pepareRedirectIfAnInvalidSlugIsGiven',
];
}

Expand All @@ -40,71 +35,65 @@ public function __construct(
) {
}

/**
* Searches for sluggable objects in the route parameters and checks slugs if necessary.
*
* If an invalid slug is detected, then the user will be redirected to the URLs with the valid slug.
*/
public function onKernelController(ControllerEvent $event): void
public function pepareRedirectIfAnInvalidSlugIsGiven(ControllerArgumentsEvent $event): void
{
$attributes = $event->getRequest()->attributes;
foreach ($attributes as $name => $value) {
if ($this->hasValidSlug($attributes, $name)) {
continue;
foreach ($event->getNamedArguments() as $parameterName => $parameterValue) {
if ($this->hasInvalidSlug($attributes, $parameterName, $parameterValue)) {
$this->prepareRedirect($event, $parameterName, $parameterValue);
break;
}
$event->stopPropagation();
// Invalid slug passed. Redirect to a URL with valid slug.
$event->setController(function () use ($event, $name) {
return $this->createRedirectFor($event->getRequest(), $name);
});
break;
}
}

private function createRedirectFor(Request $request, string $objectParameterName): RedirectResponse
{
/* @var $object SluggableInterface */
$object = $request->attributes->get($objectParameterName);
$url = $this->urlGenerator->generate(
$request->get('_route'),
array_merge(
$request->attributes->get('_route_params', []),
[$this->getSlugParameterNameFor($objectParameterName) => $object->getSlug()]
)
);
private function prepareRedirect(
ControllerArgumentsEvent $event,
string $parameterName,
SluggableInterface $sluggable
): void {
$event->setController(function () use ($event, $parameterName, $sluggable) {
return new RedirectResponse(
$this->urlGenerator->generate(
$event->getRequest()->get('_route'),
array_merge(
$event->getRequest()->attributes->get('_route_params', []),
[$this->getSlugParameterNameFor($parameterName) => $sluggable->getSlug()]
)
),
Response::HTTP_MOVED_PERMANENTLY,
);
});

return new RedirectResponse($url, 301);
$event->stopPropagation();
}

/**
* @param string $name Name of the checked parameter.
*/
private function hasValidSlug(ParameterBag $attributes, string $name): bool
private function hasInvalidSlug(ParameterBag $attributes, string $parameterName, mixed $object): bool
{
$object = $attributes->get($name);
if (!($object instanceof SluggableInterface)) {
// Only sluggable objects are checked.
return true;
return false;
}
if (!$attributes->has($name.'Slug')) {

$slugParameterName = $this->getSlugParameterNameFor($parameterName);
if (!$attributes->has($slugParameterName)) {
// Seems as if no slug is used in the route.
return true;
return false;
}

if (null === $object->getSlug()) {
// Object has no slug (yet). Simply accept any slug to avoid
// getting into an endless redirect loop.
return true;
return false;
}
$slug = $attributes->get($this->getSlugParameterNameFor($name));

return $object->getSlug() === (string) $slug;
return $object->getSlug() !== (string) $attributes->get($slugParameterName);
}

/**
* Returns the name of the parameter that could contain the slug for $parameter.
* Returns the name of the parameter that could contain the slug for the object retrievable with the $parameterName.
*/
private function getSlugParameterNameFor(string $parameter): string
private function getSlugParameterNameFor(string $parameterName): string
{
return $parameter.'Slug';
return $parameterName.'Slug';
}
}
2 changes: 1 addition & 1 deletion src/WebfactorySlugValidationBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

use Symfony\Component\HttpKernel\Bundle\Bundle;

class WebfactorySlugValidationBundle extends Bundle
final class WebfactorySlugValidationBundle extends Bundle
{
}
16 changes: 16 additions & 0 deletions tests/EventListener/TestController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Webfactory\SlugValidationBundle\Tests\EventListener;

use Symfony\Component\HttpFoundation\Response;

/**
* Test controller with an action that has a named argument, so that e.g. the slug parameter name can be determined.
*/
final class TestController
{
public function testAction(mixed $object): Response
{
return new Response();
}
}
Loading

0 comments on commit b35a956

Please sign in to comment.