diff --git a/README.md b/README.md index 52f5685..3ca9ddb 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ in turn determines the name of the route that will finally be used. `params` declared in an object route will be evaluated as [Symfony PropertyAccess](https://symfony.com/doc/current/components/property_access.html) expressions on the given object, and the resulting values will be passed on to the underlying router. +You can also use a configuration setting named `paramExpressions` for expression language support; see the section below. + `extraParams` can be given to the object router, and those will be passed-on to the underlying router as-is. ```php @@ -103,7 +105,25 @@ class Workshop { In this example, you could use the same Twig expression `object_path('detail', schedule_item)` to generate the right route for the `schedule_item` depending on whether it is a `Talk` or a `Workshop`, and the appropriate parameters (either the `id` or the `slug`) would be passed automatically as well. -# License +## Expression Language support + +In an `#[ObjectRoute]` declaration, you can also use the `paramExpressions` key to use [Symfony Expression Language](https://symfony.com/doc/current/reference/formats/expression_language.html) expressions. + +The expression gets access to two variables: `this` is the object that the route is generated on, and `params` gives access to all `extraParams` passed to the object router and the values that have been read from the object through property path expressions. + +The keys of `extraParams` indicate the parameter name. Prefixing the key with `?` means that the value should not be set if the expression evaluates to `null`. + +The motivating use case it that you might have a `BlogPost` object that can be archived. When you link to such a blog post, you need to include the post's year in an extra URL parameter: `?year=....`. + +This is not possible with Property Access paths alone, but can be done with Expression language support: + +``` +#[ObjectRoute(..., paramExpressions: ['?year' => 'this.isArchived ? this.year : null'])] +``` + +In this case, when the `BlogPost::isArchived()` method returns `true`, the value returned from `BlogPost::getYear()` will be included in the `year` parameter for the route. When it returns `false`, the `year` parameter is omitted. + +## License The code is released under the [Apache2 license](http://www.apache.org/licenses/LICENSE-2.0.html). diff --git a/composer.json b/composer.json index b55c14f..3bf034a 100644 --- a/composer.json +++ b/composer.json @@ -10,16 +10,17 @@ "require": { "php": ">= 8.1", "jms/metadata": "^2.6.1", + "symfony/expression-language": "^3.4|^4.0|^5.0|^6.0|^7.0", "symfony/property-access": "^3.4|^4.0|^5.0|^6.0|^7.0" }, "require-dev": { "doctrine/common": "^2.2", "phpunit/phpunit": "^9.6", + "symfony/phpunit-bridge": ">5.0", "symfony/routing": "^2.2|^3.0|^4.0", "symfony/yaml": "^3.0|^4.0|^5.0", - "twig/twig": "^2.0|^3.0", - "symfony/phpunit-bridge": ">5.0" + "twig/twig": "^2.0|^3.0" }, "conflict": { @@ -37,5 +38,4 @@ "JMS\\Tests": "tests/" } } - } diff --git a/src/JMS/ObjectRouting/Attribute/ObjectRoute.php b/src/JMS/ObjectRouting/Attribute/ObjectRoute.php index 087219c..0b738b9 100644 --- a/src/JMS/ObjectRouting/Attribute/ObjectRoute.php +++ b/src/JMS/ObjectRouting/Attribute/ObjectRoute.php @@ -30,10 +30,14 @@ final class ObjectRoute /** @var array */ public $params = []; - public function __construct(string $type, string $name, array $params = []) + /** @var array */ + public $paramExpressions = []; + + public function __construct(string $type, string $name, array $params = [], array $paramExpressions = []) { $this->type = $type; $this->name = $name; $this->params = $params; + $this->paramExpressions = $paramExpressions; } } diff --git a/src/JMS/ObjectRouting/Metadata/ClassMetadata.php b/src/JMS/ObjectRouting/Metadata/ClassMetadata.php index 2d1f701..7436113 100644 --- a/src/JMS/ObjectRouting/Metadata/ClassMetadata.php +++ b/src/JMS/ObjectRouting/Metadata/ClassMetadata.php @@ -25,11 +25,12 @@ class ClassMetadata extends MergeableClassMetadata { public $routes = []; - public function addRoute($type, $name, array $params = []) + public function addRoute($type, $name, array $params = [], array $paramExpressions = []) { $this->routes[$type] = [ 'name' => $name, 'params' => $params, + 'paramExpressions' => $paramExpressions, ]; } diff --git a/src/JMS/ObjectRouting/Metadata/Driver/AttributeDriver.php b/src/JMS/ObjectRouting/Metadata/Driver/AttributeDriver.php index 6a739d9..acca1cb 100644 --- a/src/JMS/ObjectRouting/Metadata/Driver/AttributeDriver.php +++ b/src/JMS/ObjectRouting/Metadata/Driver/AttributeDriver.php @@ -31,7 +31,7 @@ public function loadMetadataForClass(\ReflectionClass $class): ?ClassMetadata $hasMetadata = false; foreach ($this->fetchAttributes($class) as $attribute) { $hasMetadata = true; - $metadata->addRoute($attribute->type, $attribute->name, $attribute->params); + $metadata->addRoute($attribute->type, $attribute->name, $attribute->params, $attribute->paramExpressions); } return $hasMetadata ? $metadata : null; diff --git a/src/JMS/ObjectRouting/Metadata/Driver/XmlDriver.php b/src/JMS/ObjectRouting/Metadata/Driver/XmlDriver.php index d7ff896..bd0b4c2 100644 --- a/src/JMS/ObjectRouting/Metadata/Driver/XmlDriver.php +++ b/src/JMS/ObjectRouting/Metadata/Driver/XmlDriver.php @@ -71,7 +71,12 @@ protected function loadMetadataFromFile(\ReflectionClass $class, string $file): $params[(string) $p->attributes()] = (string) $p; } - $metadata->addRoute($type, $name, $params); + $paramExpressions = []; + foreach ($r->xpath('./paramExpression') as $p) { + $paramExpressions[(string) $p->attributes()] = (string) $p; + } + + $metadata->addRoute($type, $name, $params, $paramExpressions); } return $metadata; diff --git a/src/JMS/ObjectRouting/Metadata/Driver/YamlDriver.php b/src/JMS/ObjectRouting/Metadata/Driver/YamlDriver.php index 4c0029e..e627221 100644 --- a/src/JMS/ObjectRouting/Metadata/Driver/YamlDriver.php +++ b/src/JMS/ObjectRouting/Metadata/Driver/YamlDriver.php @@ -52,7 +52,12 @@ protected function loadMetadataFromFile(\ReflectionClass $class, string $file): if (!\array_key_exists('name', $value)) { throw new RuntimeException('Could not find key "type" inside yaml element.'); } - $metadata->addRoute($type, $value['name'], \array_key_exists('params', $value) ? $value['params'] : []); + $metadata->addRoute( + $type, + $value['name'], + \array_key_exists('params', $value) ? $value['params'] : [], + \array_key_exists('paramExpressions', $value) ? $value['paramExpressions'] : [] + ); } return $metadata; diff --git a/src/JMS/ObjectRouting/ObjectRouter.php b/src/JMS/ObjectRouting/ObjectRouter.php index 1229f14..55204c6 100644 --- a/src/JMS/ObjectRouting/ObjectRouter.php +++ b/src/JMS/ObjectRouting/ObjectRouter.php @@ -23,6 +23,8 @@ use Metadata\Driver\DriverChain; use Metadata\MetadataFactory; use Metadata\MetadataFactoryInterface; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\ParsedExpression; use Symfony\Component\PropertyAccess\PropertyAccessor; class ObjectRouter @@ -30,22 +32,25 @@ class ObjectRouter private $router; private $metadataFactory; private $accessor; + private $expressionLanguage; - public static function create(RouterInterface $router) + public static function create(RouterInterface $router, ?ExpressionLanguage $expressionLanguage = null) { return new self( $router, new MetadataFactory(new DriverChain([ new AttributeDriver(), - ])) + ])), + $expressionLanguage ); } - public function __construct(RouterInterface $router, MetadataFactoryInterface $metadataFactory) + public function __construct(RouterInterface $router, MetadataFactoryInterface $metadataFactory, ?ExpressionLanguage $expressionLanguage = null) { $this->router = $router; $this->metadataFactory = $metadataFactory; $this->accessor = new PropertyAccessor(); + $this->expressionLanguage = $expressionLanguage ?? new ExpressionLanguage(); } /** @@ -80,6 +85,22 @@ public function generate($type, $object, $absolute = false, array $extraParams = $params[$k] = $this->accessor->getValue($object, $path); } + foreach ($route['paramExpressions'] as $k => $expression) { + if (!$expression instanceof ParsedExpression) { + $expression = $this->expressionLanguage->parse($expression, ['this', 'params']); + $metadata->routes[$type]['paramExpressions'][$k] = $expression; + } + $evaluated = $this->expressionLanguage->evaluate($expression, ['this' => $object, 'params' => $params]); + if ('?' === $k[0]) { + if (null === $evaluated) { + continue; + } + $params[substr($k, 1)] = $evaluated; + } else { + $params[$k] = $evaluated; + } + } + return $this->router->generate($route['name'], $params, $absolute); } diff --git a/tests/JMS/Tests/ObjectRouting/Metadata/ClassMetadataTest.php b/tests/JMS/Tests/ObjectRouting/Metadata/ClassMetadataTest.php index b2b17f3..3dd2299 100644 --- a/tests/JMS/Tests/ObjectRouting/Metadata/ClassMetadataTest.php +++ b/tests/JMS/Tests/ObjectRouting/Metadata/ClassMetadataTest.php @@ -18,6 +18,6 @@ public function testMerge() $base->merge($merged); $this->assertEquals(self::class, $base->name); - $this->assertEquals(['test' => ['name' => 'merged-route', 'params' => []]], $base->routes); + $this->assertEquals(['test' => ['name' => 'merged-route', 'params' => [], 'paramExpressions' => []]], $base->routes); } } diff --git a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/AttributeDriverTest.php b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/AttributeDriverTest.php index 7755b0a..e26ac40 100644 --- a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/AttributeDriverTest.php +++ b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/AttributeDriverTest.php @@ -16,8 +16,8 @@ public function testLoad() $this->assertCount(2, $metadata->routes); $routes = [ - 'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']], - 'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']], + 'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']], + 'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []], ]; $this->assertEquals($routes, $metadata->routes); } diff --git a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/Fixture/BlogPostWithAttributes.php b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/Fixture/BlogPostWithAttributes.php index d8c9fbe..c42bc4a 100644 --- a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/Fixture/BlogPostWithAttributes.php +++ b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/Fixture/BlogPostWithAttributes.php @@ -4,19 +4,33 @@ use JMS\ObjectRouting\Attribute\ObjectRoute; -#[ObjectRoute(type: 'view', name: 'blog_post_view', params: ['slug' => 'slug'])] +#[ObjectRoute(type: 'view', name: 'blog_post_view', params: ['slug' => 'slug'], paramExpressions: ['?year' => 'this.isArchived ? this.year : null'])] #[ObjectRoute(type: 'edit', name: 'blog_post_edit', params: ['slug' => 'slug'])] class BlogPostWithAttributes { private $slug; + private $archived; + private $year; - public function __construct($slug) + public function __construct($slug, $archived, $year) { $this->slug = $slug; + $this->archived = $archived; + $this->year = $year; } public function getSlug() { return $this->slug; } + + public function isArchived() + { + return $this->archived; + } + + public function getYear() + { + return $this->year; + } } diff --git a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/PhpDriverTest.php b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/PhpDriverTest.php index efdd49c..0d6b3e6 100644 --- a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/PhpDriverTest.php +++ b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/PhpDriverTest.php @@ -17,8 +17,8 @@ public function testLoad() $this->assertCount(2, $metadata->routes); $routes = [ - 'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']], - 'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']], + 'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']], + 'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []], ]; $this->assertEquals($routes, $metadata->routes); } diff --git a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/XmlDriverTest.php b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/XmlDriverTest.php index 17143a3..fce5156 100644 --- a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/XmlDriverTest.php +++ b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/XmlDriverTest.php @@ -17,8 +17,8 @@ public function testLoad() $this->assertCount(2, $metadata->routes); $routes = [ - 'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']], - 'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']], + 'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']], + 'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []], ]; $this->assertEquals($routes, $metadata->routes); } diff --git a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/YamlDriverTest.php b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/YamlDriverTest.php index 8a7f576..a050347 100644 --- a/tests/JMS/Tests/ObjectRouting/Metadata/Driver/YamlDriverTest.php +++ b/tests/JMS/Tests/ObjectRouting/Metadata/Driver/YamlDriverTest.php @@ -17,8 +17,8 @@ public function testLoad() $this->assertCount(2, $metadata->routes); $routes = [ - 'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug']], - 'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug']], + 'view' => ['name' => 'blog_post_view', 'params' => ['slug' => 'slug'], 'paramExpressions' => ['?year' => 'this.isArchived ? this.year : null']], + 'edit' => ['name' => 'blog_post_edit', 'params' => ['slug' => 'slug'], 'paramExpressions' => []], ]; $this->assertEquals($routes, $metadata->routes); } diff --git a/tests/JMS/Tests/ObjectRouting/ObjectRouterTest.php b/tests/JMS/Tests/ObjectRouting/ObjectRouterTest.php index 9b3b689..64b125a 100644 --- a/tests/JMS/Tests/ObjectRouting/ObjectRouterTest.php +++ b/tests/JMS/Tests/ObjectRouting/ObjectRouterTest.php @@ -53,6 +53,67 @@ public function testGenerateWithParams() $this->assertEquals('/foobar', $this->router->generate('view', $object)); } + public function testGenerateWithParamExpression() + { + $metadata = new ClassMetadata('stdClass'); + $metadata->addRoute('view', 'view_name', [], ['foo' => 'this.bar']); + + $object = new \stdClass(); + $object->bar = 'baz'; + + $this->factory->expects($this->once()) + ->method('getMetadataForClass') + ->willReturn($metadata); + + $this->adapter->expects($this->once()) + ->method('generate') + ->with('view_name', ['foo' => 'baz'], false) + ->willReturn('/foobar'); + + $this->assertEquals('/foobar', $this->router->generate('view', $object)); + } + + public function testGenerateWithParamExpressionThatRefersToParam() + { + $metadata = new ClassMetadata('stdClass'); + $metadata->addRoute('view', 'view_name', ['foo' => 'bar'], ['concat' => 'params["foo"] ~ this.bar']); + + $object = new \stdClass(); + $object->bar = 'baz'; + + $this->factory->expects($this->once()) + ->method('getMetadataForClass') + ->willReturn($metadata); + + $this->adapter->expects($this->once()) + ->method('generate') + ->with('view_name', ['foo' => 'baz', 'concat' => 'bazbaz'], false) + ->willReturn('/foobar'); + + $this->assertEquals('/foobar', $this->router->generate('view', $object)); + } + + public function testGenerateWithNullableParamExpression() + { + $metadata = new ClassMetadata('stdClass'); + $metadata->addRoute('view', 'view_name', [], ['?foo' => 'this.bar', '?quux' => 'this.barbaz']); + + $object = new \stdClass(); + $object->bar = 'baz'; + $object->barbaz = null; + + $this->factory->expects($this->once()) + ->method('getMetadataForClass') + ->willReturn($metadata); + + $this->adapter->expects($this->once()) + ->method('generate') + ->with('view_name', ['foo' => 'baz'], false) + ->willReturn('/foobar'); + + $this->assertEquals('/foobar', $this->router->generate('view', $object)); + } + public function testGenerateNonExistentType() { $this->expectException(\RuntimeException::class); diff --git a/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.php b/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.php index 9bcb3a8..6d28507 100644 --- a/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.php +++ b/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.php @@ -2,7 +2,7 @@ $metadata = new JMS\ObjectRouting\Metadata\ClassMetadata('JMS\Tests\ObjectRouting\Metadata\Driver\Fixture\BlogPost'); -$metadata->addRoute('view', 'blog_post_view', ['slug' => 'slug']); +$metadata->addRoute('view', 'blog_post_view', ['slug' => 'slug'], ['?year' => 'this.isArchived ? this.year : null']); $metadata->addRoute('edit', 'blog_post_edit', ['slug' => 'slug']); return $metadata; diff --git a/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.xml b/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.xml index 653e00e..4ae1855 100644 --- a/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.xml +++ b/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.xml @@ -3,9 +3,10 @@ slug + this.isArchived ? this.year : null slug - \ No newline at end of file + diff --git a/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.yml b/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.yml index f6edd7a..ca1985f 100644 --- a/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.yml +++ b/tests/JMS/Tests/ObjectRouting/Resources/config/JMS.Tests.ObjectRouting.Metadata.Driver.Fixture.BlogPost.yml @@ -3,6 +3,8 @@ JMS\Tests\ObjectRouting\Metadata\Driver\Fixture\BlogPost: name: "blog_post_view" params: slug: "slug" + paramExpressions: + ?year: "this.isArchived ? this.year : null" edit: name: "blog_post_edit" params: