diff --git a/src/Illuminate/Routing/PendingResourceRegistration.php b/src/Illuminate/Routing/PendingResourceRegistration.php index a190538e9536..4a1ad192194f 100644 --- a/src/Illuminate/Routing/PendingResourceRegistration.php +++ b/src/Illuminate/Routing/PendingResourceRegistration.php @@ -241,6 +241,22 @@ public function where($wheres) return $this; } + /** + * Add metadata to the registered resource routes. + * + * @param array $metadata + * @return $this + */ + public function metadata(array $metadata) + { + $this->options['metadata'] = RouteGroup::mergeMetadata( + $this->options['metadata'] ?? [], + $metadata + ); + + return $this; + } + /** * Indicate that the resource routes should have "shallow" nesting. * diff --git a/src/Illuminate/Routing/PendingSingletonResourceRegistration.php b/src/Illuminate/Routing/PendingSingletonResourceRegistration.php index 2c20e3c3168f..c9ff6c7539e8 100644 --- a/src/Illuminate/Routing/PendingSingletonResourceRegistration.php +++ b/src/Illuminate/Routing/PendingSingletonResourceRegistration.php @@ -265,6 +265,22 @@ public function where($wheres) return $this; } + /** + * Add metadata to the registered singleton resource routes. + * + * @param array $metadata + * @return $this + */ + public function metadata(array $metadata) + { + $this->options['metadata'] = RouteGroup::mergeMetadata( + $this->options['metadata'] ?? [], + $metadata + ); + + return $this; + } + /** * Register the singleton resource route. * diff --git a/src/Illuminate/Routing/ResourceRegistrar.php b/src/Illuminate/Routing/ResourceRegistrar.php index 07ff5412832c..968c2e0fc68d 100644 --- a/src/Illuminate/Routing/ResourceRegistrar.php +++ b/src/Illuminate/Routing/ResourceRegistrar.php @@ -656,6 +656,13 @@ protected function getResourceAction($resource, $controller, $method, $options) $action['missing'] = $options['missing']; } + if (isset($options['metadata'])) { + $action['metadata'] = RouteGroup::mergeMetadata( + $action['metadata'] ?? [], + $options['metadata'] + ); + } + return $action; } diff --git a/src/Illuminate/Routing/Route.php b/src/Illuminate/Routing/Route.php index 13fd57b1a164..f8a97c92fd9a 100755 --- a/src/Illuminate/Routing/Route.php +++ b/src/Illuminate/Routing/Route.php @@ -999,6 +999,49 @@ public function getAction($key = null) return Arr::get($this->action, $key); } + /** + * Add metadata to the route. + * + * @param array $metadata + * @return $this + */ + public function metadata(array $metadata) + { + $this->action['metadata'] = RouteGroup::mergeMetadata( + $this->action['metadata'] ?? [], + $metadata + ); + + return $this; + } + + /** + * Set the metadata for the route, replacing any existing metadata. + * + * @param array $metadata + * @return $this + */ + public function setMetadata(array $metadata) + { + $this->action['metadata'] = $metadata; + + return $this; + } + + /** + * Get metadata for the route. + * + * @param string|null $key + * @param mixed $default + * @return mixed + */ + public function getMetadata($key = null, $default = null) + { + $metadata = $this->action['metadata'] ?? []; + + return is_null($key) ? $metadata : Arr::get($metadata, $key, $default); + } + /** * Set the action array for the route. * diff --git a/src/Illuminate/Routing/RouteGroup.php b/src/Illuminate/Routing/RouteGroup.php index cca24b29234d..6624e3396980 100644 --- a/src/Illuminate/Routing/RouteGroup.php +++ b/src/Illuminate/Routing/RouteGroup.php @@ -24,17 +24,76 @@ public static function merge($new, $old, $prependExistingPrefix = true) unset($old['controller']); } + $metadata = static::formatMetadata($new, $old); + + unset($new['metadata']); + $new = array_merge(static::formatAs($new, $old), [ 'namespace' => static::formatNamespace($new, $old), 'prefix' => static::formatPrefix($new, $old, $prependExistingPrefix), 'where' => static::formatWhere($new, $old), ]); + if ($metadata !== []) { + $new['metadata'] = $metadata; + } + return array_merge_recursive(Arr::except( - $old, ['namespace', 'prefix', 'where', 'as'] + $old, ['metadata', 'namespace', 'prefix', 'where', 'as'] ), $new); } + /** + * Associative array values are merged recursively, while all other + * values, including lists, replace the existing value entirely. + * + * @param array $old + * @param array $new + * @return array + */ + public static function mergeMetadata(array $old, array $new) + { + foreach ($new as $key => $value) { + if (isset($old[$key]) && static::mergesMetadata($old[$key], $value)) { + $value = static::mergeMetadata($old[$key], $value); + } + + $old[$key] = $value; + } + + return $old; + } + + /** + * Determine if the given metadata values should be merged. + * + * @param mixed $old + * @param mixed $new + * @return bool + */ + protected static function mergesMetadata($old, $new) + { + return is_array($old) && + is_array($new) && + Arr::isAssoc($old) && + Arr::isAssoc($new); + } + + /** + * Format the metadata for the new group attributes. + * + * @param array $new + * @param array $old + * @return array + */ + protected static function formatMetadata($new, $old) + { + return static::mergeMetadata( + $old['metadata'] ?? [], + $new['metadata'] ?? [] + ); + } + /** * Format the namespace for the new group attributes. * diff --git a/src/Illuminate/Routing/RouteRegistrar.php b/src/Illuminate/Routing/RouteRegistrar.php index 6da02f501fef..5cdc7f39d671 100644 --- a/src/Illuminate/Routing/RouteRegistrar.php +++ b/src/Illuminate/Routing/RouteRegistrar.php @@ -22,6 +22,7 @@ * @method \Illuminate\Routing\RouteRegistrar can(\UnitEnum|string $ability, array|string $models = []) * @method \Illuminate\Routing\RouteRegistrar controller(string $controller) * @method \Illuminate\Routing\RouteRegistrar domain(\BackedEnum|string $value) + * @method \Illuminate\Routing\RouteRegistrar metadata(array $metadata) * @method \Illuminate\Routing\RouteRegistrar middleware(array|string|null $middleware) * @method \Illuminate\Routing\RouteRegistrar missing(\Closure $missing) * @method \Illuminate\Routing\RouteRegistrar name(\BackedEnum|string $value) @@ -72,6 +73,7 @@ class RouteRegistrar 'can', 'controller', 'domain', + 'metadata', 'middleware', 'missing', 'name', @@ -128,6 +130,16 @@ public function attribute($key, $value) } } + if ($key === 'metadata') { + if (! is_array($value)) { + throw new InvalidArgumentException('Attribute [metadata] expects an array.'); + } + + $value = RouteGroup::mergeMetadata( + $this->attributes['metadata'] ?? [], $value + ); + } + $attributeKey = Arr::get($this->aliases, $key, $key); if ($key === 'withoutMiddleware') { @@ -149,6 +161,17 @@ public function attribute($key, $value) return $this; } + /** + * Add metadata to routes registered by the registrar. + * + * @param array $metadata + * @return $this + */ + public function metadata(array $metadata) + { + return $this->attribute('metadata', $metadata); + } + /** * Route a resource to a controller. * @@ -272,7 +295,18 @@ protected function compileAction($action) ]; } - return array_merge($this->attributes, $action); + $metadata = RouteGroup::mergeMetadata( + $this->attributes['metadata'] ?? [], + $action['metadata'] ?? [] + ); + + $action = array_merge($this->attributes, $action); + + if ($metadata !== []) { + $action['metadata'] = $metadata; + } + + return $action; } /** diff --git a/src/Illuminate/Support/Facades/Route.php b/src/Illuminate/Support/Facades/Route.php index 7b0e1c0c5280..f6a8232d7a0c 100755 --- a/src/Illuminate/Support/Facades/Route.php +++ b/src/Illuminate/Support/Facades/Route.php @@ -83,6 +83,7 @@ * @method static mixed macroCall(string $method, array $parameters) * @method static \Illuminate\Support\HigherOrderTapProxy|\Illuminate\Routing\Router tap(callable|null $callback = null) * @method static \Illuminate\Routing\RouteRegistrar attribute(string $key, mixed $value) + * @method static \Illuminate\Routing\RouteRegistrar metadata(array $metadata) * @method static \Illuminate\Routing\RouteRegistrar whereAlpha(array|string $parameters) * @method static \Illuminate\Routing\RouteRegistrar whereAlphaNumeric(array|string $parameters) * @method static \Illuminate\Routing\RouteRegistrar whereNumber(array|string $parameters) diff --git a/tests/Routing/RouteCollectionTest.php b/tests/Routing/RouteCollectionTest.php index f84dae01a10e..d495ccc3b1ca 100644 --- a/tests/Routing/RouteCollectionTest.php +++ b/tests/Routing/RouteCollectionTest.php @@ -3,9 +3,12 @@ namespace Illuminate\Tests\Routing; use ArrayIterator; +use Illuminate\Container\Container; +use Illuminate\Events\Dispatcher; use Illuminate\Http\Request; use Illuminate\Routing\Route; use Illuminate\Routing\RouteCollection; +use Illuminate\Routing\Router; use LogicException; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; @@ -284,6 +287,23 @@ public function testCannotCacheDuplicateRouteNames() $this->routeCollection->compile(); } + public function testCompiledRouteCollectionPreservesRouteMetadata() + { + $this->routeCollection->add( + new Route('GET', 'users', [ + 'uses' => 'UsersController@index', + 'as' => 'users', + 'metadata' => ['head' => ['title' => 'Users']], + ]) + ); + + $route = $this->routeCollection + ->toCompiledRouteCollection(new Router(new Dispatcher, new Container), new Container) + ->getByName('users'); + + $this->assertSame(['title' => 'Users'], $route->getMetadata('head')); + } + public function testRouteCollectionDontMatchNonMatchingDoubleSlashes() { $this->expectException(NotFoundHttpException::class); diff --git a/tests/Routing/RouteRegistrarTest.php b/tests/Routing/RouteRegistrarTest.php index f94ca8e55c54..4194d691bbf3 100644 --- a/tests/Routing/RouteRegistrarTest.php +++ b/tests/Routing/RouteRegistrarTest.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Http\Request; use Illuminate\Routing\Router; +use Illuminate\Routing\RouteRegistrar; use Mockery as m; use PHPUnit\Framework\TestCase; use Stringable; @@ -572,6 +573,147 @@ public function testCanSetScopeBindingsOnGroup() $this->assertTrue($route->enforcesScopedBindings()); } + public function testCanSetRouteMetadata() + { + $route = $this->router + ->metadata(['head' => ['title' => 'Users']]) + ->get('users', function () { + return 'all-users'; + }) + ->metadata(['head' => ['description' => 'All users.']]); + + $this->assertSame([ + 'title' => 'Users', + 'description' => 'All users.', + ], $route->getMetadata('head')); + $this->assertSame('Users', $route->getMetadata('head.title')); + } + + public function testCanSetRouteMetadataOnGroup() + { + $this->router + ->metadata(['head' => ['robots' => ['noindex', 'nofollow']]]) + ->group(function ($router) { + $router + ->metadata(['head' => ['title' => 'Users']]) + ->get('users', function () { + return 'all-users'; + }); + }); + + $route = $this->router->getRoutes()->getRoutes()[0]; + + $this->assertSame([ + 'robots' => ['noindex', 'nofollow'], + 'title' => 'Users', + ], $route->getMetadata('head')); + } + + public function testRouteMetadataListValuesReplaceParentValues() + { + $this->router + ->metadata(['head' => ['robots' => ['index', 'follow']]]) + ->group(function ($router) { + $router + ->metadata(['head' => ['robots' => ['noindex']]]) + ->get('users', function () { + return 'all-users'; + }); + }); + + $route = $this->router->getRoutes()->getRoutes()[0]; + + $this->assertSame(['noindex'], $route->getMetadata('head.robots')); + } + + public function testCanSetRouteMetadataOnGroupUsingArraySyntax() + { + $this->router->group(['metadata' => ['head' => ['title' => 'Users']]], function ($router) { + $router->get('users', function () { + return 'all-users'; + }); + }); + + $route = $this->router->getRoutes()->getRoutes()[0]; + + $this->assertSame(['title' => 'Users'], $route->getMetadata('head')); + } + + public function testEmptyRouteMetadataArrayReplacesParentValue() + { + $this->router + ->metadata(['head' => ['title' => 'Users']]) + ->group(function ($router) { + $router + ->metadata(['head' => []]) + ->get('users', function () { + return 'all-users'; + }); + }); + + $route = $this->router->getRoutes()->getRoutes()[0]; + + $this->assertSame([], $route->getMetadata('head')); + } + + public function testRouteMetadataAttributeRequiresArray() + { + $this->expectExceptionObject(new \InvalidArgumentException('Attribute [metadata] expects an array.')); + + (new RouteRegistrar($this->router))->attribute('metadata', 'invalid'); + } + + public function testRouteMetadataDoesNotCollideWithRouteActions() + { + $route = $this->router + ->middleware('web') + ->metadata(['middleware' => 'metadata']) + ->get('users', function () { + return 'all-users'; + }); + + $this->assertSame('metadata', $route->getMetadata('middleware')); + $this->assertSame(['web'], $route->getAction('middleware')); + } + + public function testRouteMetadataMergesThroughDeeplyNestedGroups() + { + $this->router + ->metadata(['head' => ['title' => 'Outer', 'description' => 'Outer description']]) + ->group(function ($router) { + $router + ->metadata(['head' => ['title' => 'Middle', 'author' => 'Taylor']]) + ->group(function ($router) { + $router + ->metadata(['head' => ['title' => 'Inner']]) + ->get('users', function () { + return 'all-users'; + }); + }); + }); + + $route = $this->router->getRoutes()->getRoutes()[0]; + + $this->assertSame([ + 'title' => 'Inner', + 'description' => 'Outer description', + 'author' => 'Taylor', + ], $route->getMetadata('head')); + } + + public function testSetMetadataReplacesExistingMetadata() + { + $route = $this->router + ->metadata(['head' => ['title' => 'Original', 'description' => 'Goes away']]) + ->get('users', function () { + return 'all-users'; + }); + + $route->setMetadata(['head' => ['title' => 'Replaced']]); + + $this->assertSame(['head' => ['title' => 'Replaced']], $route->getMetadata()); + } + public function testCanRegisterResource() { $this->router->middleware('resource-middleware') @@ -581,6 +723,40 @@ public function testCanRegisterResource() $this->seeMiddleware('resource-middleware'); } + public function testCanSetRouteMetadataOnResource() + { + $this->router->resource('users', RouteRegistrarControllerStub::class) + ->metadata(['head' => ['title' => 'Users']]); + + $this->assertSame( + ['title' => 'Users'], + $this->router->getRoutes()->getByName('users.index')->getMetadata('head') + ); + } + + public function testCanSetRouteMetadataOnResourceGroup() + { + $this->router + ->metadata(['head' => ['title' => 'Users']]) + ->resource('users', RouteRegistrarControllerStub::class); + + $this->assertSame( + ['title' => 'Users'], + $this->router->getRoutes()->getByName('users.index')->getMetadata('head') + ); + } + + public function testCanSetRouteMetadataOnApiResource() + { + $this->router->apiResource('users', RouteRegistrarControllerStub::class) + ->metadata(['head' => ['title' => 'Users']]); + + $this->assertSame( + ['title' => 'Users'], + $this->router->getRoutes()->getByName('users.index')->getMetadata('head') + ); + } + public function testCanRegisterResourcesWithExceptOption() { $this->router->resources([ @@ -1350,6 +1526,17 @@ public function testCanRegisterSingleton() $this->assertTrue($this->router->getRoutes()->hasNamedRoute('user.update')); } + public function testCanSetRouteMetadataOnSingleton() + { + $this->router->singleton('user', RouteRegistrarControllerStub::class) + ->metadata(['head' => ['title' => 'User']]); + + $this->assertSame( + ['title' => 'User'], + $this->router->getRoutes()->getByName('user.show')->getMetadata('head') + ); + } + public function testCanRegisterApiSingleton() { $this->router->apiSingleton('user', RouteRegistrarControllerStub::class); @@ -1360,6 +1547,17 @@ public function testCanRegisterApiSingleton() $this->assertTrue($this->router->getRoutes()->hasNamedRoute('user.update')); } + public function testCanSetRouteMetadataOnApiSingleton() + { + $this->router->apiSingleton('user', RouteRegistrarControllerStub::class) + ->metadata(['head' => ['title' => 'User']]); + + $this->assertSame( + ['title' => 'User'], + $this->router->getRoutes()->getByName('user.show')->getMetadata('head') + ); + } + public function testCanRegisterCreatableSingleton() { $this->router->singleton('user', RouteRegistrarControllerStub::class)->creatable();