diff --git a/Attribute/DeprecatedAlias.php b/Attribute/DeprecatedAlias.php new file mode 100644 index 00000000..ae5a6821 --- /dev/null +++ b/Attribute/DeprecatedAlias.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Attribute; + +/** + * This class is meant to be used in {@see Route} to define an alias for a route. + */ +class DeprecatedAlias +{ + public function __construct( + private string $aliasName, + private string $package, + private string $version, + private string $message = '', + ) { + } + + public function getMessage(): string + { + return $this->message; + } + + public function getAliasName(): string + { + return $this->aliasName; + } + + public function getPackage(): string + { + return $this->package; + } + + public function getVersion(): string + { + return $this->version; + } +} diff --git a/Attribute/Route.php b/Attribute/Route.php index 07abc556..003bbe64 100644 --- a/Attribute/Route.php +++ b/Attribute/Route.php @@ -22,23 +22,28 @@ class Route private array $localizedPaths = []; private array $methods; private array $schemes; + /** + * @var (string|DeprecatedAlias)[] + */ + private array $aliases = []; /** - * @param string|array|null $path The route path (i.e. "/user/login") - * @param string|null $name The route name (i.e. "app_user_login") - * @param array $requirements Requirements for the route attributes, @see https://symfony.com/doc/current/routing.html#parameters-validation - * @param array $options Options for the route (i.e. ['prefix' => '/api']) - * @param array $defaults Default values for the route attributes and query parameters - * @param string|null $host The host for which this route should be active (i.e. "localhost") - * @param string|string[] $methods The list of HTTP methods allowed by this route - * @param string|string[] $schemes The list of schemes allowed by this route (i.e. "https") - * @param string|null $condition An expression that must evaluate to true for the route to be matched, @see https://symfony.com/doc/current/routing.html#matching-expressions - * @param int|null $priority The priority of the route if multiple ones are defined for the same path - * @param string|null $locale The locale accepted by the route - * @param string|null $format The format returned by the route (i.e. "json", "xml") - * @param bool|null $utf8 Whether the route accepts UTF-8 in its parameters - * @param bool|null $stateless Whether the route is defined as stateless or stateful, @see https://symfony.com/doc/current/routing.html#stateless-routes - * @param string|null $env The env in which the route is defined (i.e. "dev", "test", "prod") + * @param string|array|null $path The route path (i.e. "/user/login") + * @param string|null $name The route name (i.e. "app_user_login") + * @param array $requirements Requirements for the route attributes, @see https://symfony.com/doc/current/routing.html#parameters-validation + * @param array $options Options for the route (i.e. ['prefix' => '/api']) + * @param array $defaults Default values for the route attributes and query parameters + * @param string|null $host The host for which this route should be active (i.e. "localhost") + * @param string|string[] $methods The list of HTTP methods allowed by this route + * @param string|string[] $schemes The list of schemes allowed by this route (i.e. "https") + * @param string|null $condition An expression that must evaluate to true for the route to be matched, @see https://symfony.com/doc/current/routing.html#matching-expressions + * @param int|null $priority The priority of the route if multiple ones are defined for the same path + * @param string|null $locale The locale accepted by the route + * @param string|null $format The format returned by the route (i.e. "json", "xml") + * @param bool|null $utf8 Whether the route accepts UTF-8 in its parameters + * @param bool|null $stateless Whether the route is defined as stateless or stateful, @see https://symfony.com/doc/current/routing.html#stateless-routes + * @param string|null $env The env in which the route is defined (i.e. "dev", "test", "prod") + * @param string|DeprecatedAlias|(string|DeprecatedAlias)[] $alias The list of aliases for this route */ public function __construct( string|array|null $path = null, @@ -56,6 +61,7 @@ public function __construct( ?bool $utf8 = null, ?bool $stateless = null, private ?string $env = null, + string|DeprecatedAlias|array $alias = [], ) { if (\is_array($path)) { $this->localizedPaths = $path; @@ -64,6 +70,7 @@ public function __construct( } $this->setMethods($methods); $this->setSchemes($schemes); + $this->setAliases($alias); if (null !== $locale) { $this->defaults['_locale'] = $locale; @@ -201,6 +208,22 @@ public function getEnv(): ?string { return $this->env; } + + /** + * @return (string|DeprecatedAlias)[] + */ + public function getAliases(): array + { + return $this->aliases; + } + + /** + * @param string|DeprecatedAlias|(string|DeprecatedAlias)[] $aliases + */ + public function setAliases(string|DeprecatedAlias|array $aliases): void + { + $this->aliases = \is_array($aliases) ? $aliases : [$aliases]; + } } if (!class_exists(\Symfony\Component\Routing\Annotation\Route::class, false)) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c461405..d21e550f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +7.3 +--- + + * Allow aliases and deprecations in `#[Route]` attribute + * Add the `Requirement::MONGODB_ID` constant to validate MongoDB ObjectIDs in hexadecimal format + 7.2 --- diff --git a/Loader/AttributeClassLoader.php b/Loader/AttributeClassLoader.php index 92471af0..f2bc668d 100644 --- a/Loader/AttributeClassLoader.php +++ b/Loader/AttributeClassLoader.php @@ -13,8 +13,10 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Loader\LoaderResolverInterface; -use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\Config\Resource\ReflectionClassResource; +use Symfony\Component\Routing\Attribute\DeprecatedAlias; use Symfony\Component\Routing\Attribute\Route as RouteAttribute; +use Symfony\Component\Routing\Exception\InvalidArgumentException; use Symfony\Component\Routing\Exception\LogicException; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -102,11 +104,20 @@ public function load(mixed $class, ?string $type = null): RouteCollection $globals = $this->getGlobals($class); $collection = new RouteCollection(); - $collection->addResource(new FileResource($class->getFileName())); + $collection->addResource(new ReflectionClassResource($class)); if ($globals['env'] && $this->env !== $globals['env']) { return $collection; } $fqcnAlias = false; + + if (!$class->hasMethod('__invoke')) { + foreach ($this->getAttributes($class) as $attr) { + if ($attr->getAliases()) { + throw new InvalidArgumentException(\sprintf('Route aliases cannot be used on non-invokable class "%s".', $class->getName())); + } + } + } + foreach ($class->getMethods() as $method) { $this->defaultRouteIndex = 0; $routeNamesBefore = array_keys($collection->all()); @@ -208,11 +219,11 @@ protected function addRoute(RouteCollection $collection, object $attr, array $gl continue; } foreach ($paths as $locale => $path) { - if (preg_match(\sprintf('/\{%s(?:<.*?>)?\}/', preg_quote($param->name)), $path)) { + if (preg_match(\sprintf('/\{(?|([^\}:<]++):%s(?:\.[^\}<]++)?|(%1$s))(?:<.*?>)?\}/', preg_quote($param->name)), $path, $matches)) { if (\is_scalar($defaultValue = $param->getDefaultValue()) || null === $defaultValue) { - $defaults[$param->name] = $defaultValue; + $defaults[$matches[1]] = $defaultValue; } elseif ($defaultValue instanceof \BackedEnum) { - $defaults[$param->name] = $defaultValue->value; + $defaults[$matches[1]] = $defaultValue->value; } break; } @@ -230,6 +241,19 @@ protected function addRoute(RouteCollection $collection, object $attr, array $gl } else { $collection->add($name, $route, $priority); } + foreach ($attr->getAliases() as $aliasAttribute) { + if ($aliasAttribute instanceof DeprecatedAlias) { + $alias = $collection->addAlias($aliasAttribute->getAliasName(), $name); + $alias->setDeprecated( + $aliasAttribute->getPackage(), + $aliasAttribute->getVersion(), + $aliasAttribute->getMessage() + ); + continue; + } + + $collection->addAlias($aliasAttribute, $name); + } } } diff --git a/Loader/AttributeDirectoryLoader.php b/Loader/AttributeDirectoryLoader.php index 8bb59823..fff003dd 100644 --- a/Loader/AttributeDirectoryLoader.php +++ b/Loader/AttributeDirectoryLoader.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Routing\Loader; -use Symfony\Component\Config\Resource\DirectoryResource; +use Symfony\Component\Config\Resource\GlobResource; use Symfony\Component\Routing\RouteCollection; /** @@ -33,7 +33,7 @@ public function load(mixed $path, ?string $type = null): ?RouteCollection } $collection = new RouteCollection(); - $collection->addResource(new DirectoryResource($dir, '/\.php$/')); + $collection->addResource(new GlobResource($dir, '/*.php', true)); $files = iterator_to_array(new \RecursiveIteratorIterator( new \RecursiveCallbackFilterIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS), diff --git a/Loader/AttributeFileLoader.php b/Loader/AttributeFileLoader.php index 3214d589..ae0bad7a 100644 --- a/Loader/AttributeFileLoader.php +++ b/Loader/AttributeFileLoader.php @@ -13,7 +13,7 @@ use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Config\Loader\FileLoader; -use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\Config\Resource\ReflectionClassResource; use Symfony\Component\Routing\RouteCollection; /** @@ -52,7 +52,7 @@ public function load(mixed $file, ?string $type = null): ?RouteCollection return null; } - $collection->addResource(new FileResource($path)); + $collection->addResource(new ReflectionClassResource($refl)); $collection->addCollection($this->loader->load($class, $type)); } diff --git a/Loader/Configurator/CollectionConfigurator.php b/Loader/Configurator/CollectionConfigurator.php index 4b83b0ff..d1553213 100644 --- a/Loader/Configurator/CollectionConfigurator.php +++ b/Loader/Configurator/CollectionConfigurator.php @@ -36,12 +36,12 @@ public function __construct( $this->route = new Route(''); } - public function __sleep(): array + public function __serialize(): array { throw new \BadMethodCallException('Cannot serialize '.__CLASS__); } - public function __wakeup(): void + public function __unserialize(array $data): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } diff --git a/Loader/Configurator/ImportConfigurator.php b/Loader/Configurator/ImportConfigurator.php index 45d1f6dc..b10bdf05 100644 --- a/Loader/Configurator/ImportConfigurator.php +++ b/Loader/Configurator/ImportConfigurator.php @@ -29,12 +29,12 @@ public function __construct( $this->route = $route; } - public function __sleep(): array + public function __serialize(): array { throw new \BadMethodCallException('Cannot serialize '.__CLASS__); } - public function __wakeup(): void + public function __unserialize(array $data): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } diff --git a/Loader/Configurator/Traits/PrefixTrait.php b/Loader/Configurator/Traits/PrefixTrait.php index 9777c649..9f2284c5 100644 --- a/Loader/Configurator/Traits/PrefixTrait.php +++ b/Loader/Configurator/Traits/PrefixTrait.php @@ -27,10 +27,17 @@ final protected function addPrefix(RouteCollection $routes, string|array $prefix foreach ($prefix as $locale => $localePrefix) { $prefix[$locale] = trim(trim($localePrefix), '/'); } + $aliases = []; + foreach ($routes->getAliases() as $name => $alias) { + $aliases[$alias->getId()][] = $name; + } foreach ($routes->all() as $name => $route) { if (null === $locale = $route->getDefault('_locale')) { $priority = $routes->getPriority($name) ?? 0; $routes->remove($name); + foreach ($aliases[$name] ?? [] as $aliasName) { + $routes->remove($aliasName); + } foreach ($prefix as $locale => $localePrefix) { $localizedRoute = clone $route; $localizedRoute->setDefault('_locale', $locale); @@ -38,6 +45,9 @@ final protected function addPrefix(RouteCollection $routes, string|array $prefix $localizedRoute->setDefault('_canonical_route', $name); $localizedRoute->setPath($localePrefix.(!$trailingSlashOnRoot && '/' === $route->getPath() ? '' : $route->getPath())); $routes->add($name.'.'.$locale, $localizedRoute, $priority); + foreach ($aliases[$name] ?? [] as $aliasName) { + $routes->addAlias($aliasName.'.'.$locale, $name.'.'.$locale); + } } } elseif (!isset($prefix[$locale])) { throw new \InvalidArgumentException(\sprintf('Route "%s" with locale "%s" is missing a corresponding prefix in its parent collection.', $name, $locale)); diff --git a/Loader/Psr4DirectoryLoader.php b/Loader/Psr4DirectoryLoader.php index 738b56f4..fb48da15 100644 --- a/Loader/Psr4DirectoryLoader.php +++ b/Loader/Psr4DirectoryLoader.php @@ -15,6 +15,7 @@ use Symfony\Component\Config\Loader\DirectoryAwareLoaderInterface; use Symfony\Component\Config\Loader\Loader; use Symfony\Component\Config\Resource\DirectoryResource; +use Symfony\Component\Routing\Exception\InvalidArgumentException; use Symfony\Component\Routing\RouteCollection; /** @@ -43,6 +44,10 @@ public function load(mixed $resource, ?string $type = null): ?RouteCollection return new RouteCollection(); } + if (!preg_match('/^(?:[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+\\\)++$/', trim($resource['namespace'], '\\').'\\')) { + throw new InvalidArgumentException(\sprintf('Namespace "%s" is not a valid PSR-4 prefix.', $resource['namespace'])); + } + return $this->loadFromDirectory($path, trim($resource['namespace'], '\\')); } diff --git a/Matcher/Dumper/CompiledUrlMatcherDumper.php b/Matcher/Dumper/CompiledUrlMatcherDumper.php index b719e755..d55b2d5f 100644 --- a/Matcher/Dumper/CompiledUrlMatcherDumper.php +++ b/Matcher/Dumper/CompiledUrlMatcherDumper.php @@ -219,7 +219,12 @@ private function compileStaticRoutes(array $staticRoutes, array &$conditions): a foreach ($staticRoutes as $url => $routes) { $compiledRoutes[$url] = []; foreach ($routes as $name => [$route, $hasTrailingSlash]) { - $compiledRoutes[$url][] = $this->compileRoute($route, $name, (!$route->compile()->getHostVariables() ? $route->getHost() : $route->compile()->getHostRegex()) ?: null, $hasTrailingSlash, false, $conditions); + if ($route->compile()->getHostVariables()) { + $host = $route->compile()->getHostRegex(); + } elseif ($host = $route->getHost()) { + $host = strtolower($host); + } + $compiledRoutes[$url][] = $this->compileRoute($route, $name, $host ?: null, $hasTrailingSlash, false, $conditions); } } diff --git a/Matcher/Dumper/CompiledUrlMatcherTrait.php b/Matcher/Dumper/CompiledUrlMatcherTrait.php index db754e6d..5177c269 100644 --- a/Matcher/Dumper/CompiledUrlMatcherTrait.php +++ b/Matcher/Dumper/CompiledUrlMatcherTrait.php @@ -57,7 +57,7 @@ public function match(string $pathinfo): array } finally { $this->context->setScheme($scheme); } - } elseif ('/' !== $trimmedPathinfo = rtrim($pathinfo, '/') ?: '/') { + } elseif ('' !== $trimmedPathinfo = rtrim($pathinfo, '/')) { $pathinfo = $trimmedPathinfo === $pathinfo ? $pathinfo.'/' : $trimmedPathinfo; if ($ret = $this->doMatch($pathinfo, $allow, $allowSchemes)) { return $this->redirect($pathinfo, $ret['_route']) + $ret; @@ -73,8 +73,8 @@ public function match(string $pathinfo): array private function doMatch(string $pathinfo, array &$allow = [], array &$allowSchemes = []): array { $allow = $allowSchemes = []; - $pathinfo = rawurldecode($pathinfo) ?: '/'; - $trimmedPathinfo = rtrim($pathinfo, '/') ?: '/'; + $pathinfo = '' === ($pathinfo = rawurldecode($pathinfo)) ? '/' : $pathinfo; + $trimmedPathinfo = '' === ($trimmedPathinfo = rtrim($pathinfo, '/')) ? '/' : $trimmedPathinfo; $context = $this->context; $requestMethod = $canonicalMethod = $context->getMethod(); diff --git a/Matcher/RedirectableUrlMatcher.php b/Matcher/RedirectableUrlMatcher.php index 8d1ad4f9..3e7b78b5 100644 --- a/Matcher/RedirectableUrlMatcher.php +++ b/Matcher/RedirectableUrlMatcher.php @@ -41,7 +41,7 @@ public function match(string $pathinfo): array } finally { $this->context->setScheme($scheme); } - } elseif ('/' === $trimmedPathinfo = rtrim($pathinfo, '/') ?: '/') { + } elseif ('' === $trimmedPathinfo = rtrim($pathinfo, '/')) { throw $e; } else { try { diff --git a/Matcher/TraceableUrlMatcher.php b/Matcher/TraceableUrlMatcher.php index 5dba38bc..5729df22 100644 --- a/Matcher/TraceableUrlMatcher.php +++ b/Matcher/TraceableUrlMatcher.php @@ -57,7 +57,7 @@ protected function matchCollection(string $pathinfo, RouteCollection $routes): a $method = 'GET'; } $supportsTrailingSlash = 'GET' === $method && $this instanceof RedirectableUrlMatcherInterface; - $trimmedPathinfo = rtrim($pathinfo, '/') ?: '/'; + $trimmedPathinfo = '' === ($trimmedPathinfo = rtrim($pathinfo, '/')) ? '/' : $trimmedPathinfo; foreach ($routes as $name => $route) { $compiledRoute = $route->compile(); diff --git a/Matcher/UrlMatcher.php b/Matcher/UrlMatcher.php index 36698d50..76bd309b 100644 --- a/Matcher/UrlMatcher.php +++ b/Matcher/UrlMatcher.php @@ -70,8 +70,9 @@ public function getContext(): RequestContext public function match(string $pathinfo): array { $this->allow = $this->allowSchemes = []; + $pathinfo = '' === ($pathinfo = rawurldecode($pathinfo)) ? '/' : $pathinfo; - if ($ret = $this->matchCollection(rawurldecode($pathinfo) ?: '/', $this->routes)) { + if ($ret = $this->matchCollection($pathinfo, $this->routes)) { return $ret; } @@ -114,7 +115,7 @@ protected function matchCollection(string $pathinfo, RouteCollection $routes): a $method = 'GET'; } $supportsTrailingSlash = 'GET' === $method && $this instanceof RedirectableUrlMatcherInterface; - $trimmedPathinfo = rtrim($pathinfo, '/') ?: '/'; + $trimmedPathinfo = '' === ($trimmedPathinfo = rtrim($pathinfo, '/')) ? '/' : $trimmedPathinfo; foreach ($routes as $name => $route) { $compiledRoute = $route->compile(); diff --git a/Requirement/Requirement.php b/Requirement/Requirement.php index fdc0009c..6de2fbc5 100644 --- a/Requirement/Requirement.php +++ b/Requirement/Requirement.php @@ -20,6 +20,7 @@ enum Requirement public const CATCH_ALL = '.+'; public const DATE_YMD = '[0-9]{4}-(?:0[1-9]|1[012])-(?:0[1-9]|[12][0-9]|(?getDefault('_route_mapping') ?? []; - $pattern = preg_replace_callback('#\{(!?)([\w\x80-\xFF]++)(:[\w\x80-\xFF]++)?(<.*?>)?(\?[^\}]*+)?\}#', function ($m) use (&$mapping) { - if (isset($m[5][0])) { - $this->setDefault($m[2], '?' !== $m[5] ? substr($m[5], 1) : null); + $pattern = preg_replace_callback('#\{(!?)([\w\x80-\xFF]++)(:([\w\x80-\xFF]++)(\.[\w\x80-\xFF]++)?)?(<.*?>)?(\?[^\}]*+)?\}#', function ($m) use (&$mapping) { + if (isset($m[7][0])) { + $this->setDefault($m[2], '?' !== $m[7] ? substr($m[7], 1) : null); } - if (isset($m[4][0])) { - $this->setRequirement($m[2], substr($m[4], 1, -1)); + if (isset($m[6][0])) { + $this->setRequirement($m[2], substr($m[6], 1, -1)); } - if (isset($m[3][0])) { - $mapping[$m[2]] = substr($m[3], 1); + if (isset($m[4][0])) { + $mapping[$m[2]] = isset($m[5][0]) ? [$m[4], substr($m[5], 1)] : $mapping[$m[2]] = [$m[4], $m[2]]; } return '{'.$m[1].$m[2].'}'; diff --git a/RouteCollection.php b/RouteCollection.php index 87e38985..7ca86cc3 100644 --- a/RouteCollection.php +++ b/RouteCollection.php @@ -228,7 +228,13 @@ public function addNamePrefix(string $prefix): void } foreach ($this->aliases as $name => $alias) { - $prefixedAliases[$prefix.$name] = $alias->withId($prefix.$alias->getId()); + $targetId = $alias->getId(); + + if (isset($this->routes[$targetId]) || isset($this->aliases[$targetId])) { + $targetId = $prefix.$targetId; + } + + $prefixedAliases[$prefix.$name] = $alias->withId($targetId); } $this->routes = $prefixedRoutes; diff --git a/Tests/Attribute/RouteTest.php b/Tests/Attribute/RouteTest.php index 2696991c..bbaa7563 100644 --- a/Tests/Attribute/RouteTest.php +++ b/Tests/Attribute/RouteTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Routing\Tests\Annotation; +namespace Symfony\Component\Routing\Tests\Attribute; use PHPUnit\Framework\TestCase; use Symfony\Component\Routing\Attribute\Route; @@ -40,6 +40,7 @@ public static function getValidParameters(): iterable ['methods', 'getMethods', ['GET', 'POST']], ['host', 'getHost', '{locale}.example.com'], ['condition', 'getCondition', 'context.getMethod() == \'GET\''], + ['alias', 'getAliases', ['alias', 'completely_different_name']], ]; } } diff --git a/Tests/Fixtures/AttributeFixtures/AliasClassController.php b/Tests/Fixtures/AttributeFixtures/AliasClassController.php new file mode 100644 index 00000000..c7e87128 --- /dev/null +++ b/Tests/Fixtures/AttributeFixtures/AliasClassController.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/hello', alias: ['alias', 'completely_different_name'])] +class AliasClassController +{ + #[Route('/world')] + public function actionWorld() + { + } + + #[Route('/symfony')] + public function actionSymfony() + { + } +} diff --git a/Tests/Fixtures/AttributeFixtures/AliasInvokableController.php b/Tests/Fixtures/AttributeFixtures/AliasInvokableController.php new file mode 100644 index 00000000..dac27b67 --- /dev/null +++ b/Tests/Fixtures/AttributeFixtures/AliasInvokableController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/path', name:'invokable_path', alias: ['alias', 'completely_different_name'])] +class AliasInvokableController +{ + public function __invoke() + { + } +} diff --git a/Tests/Fixtures/AttributeFixtures/AliasRouteController.php b/Tests/Fixtures/AttributeFixtures/AliasRouteController.php new file mode 100644 index 00000000..0b828576 --- /dev/null +++ b/Tests/Fixtures/AttributeFixtures/AliasRouteController.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Component\Routing\Attribute\Route; + +class AliasRouteController +{ + #[Route('/path', name: 'action_with_alias', alias: ['alias', 'completely_different_name'])] + public function action() + { + } +} diff --git a/Tests/Fixtures/AttributeFixtures/DefaultValueController.php b/Tests/Fixtures/AttributeFixtures/DefaultValueController.php index dc5d0c4e..5dcf5181 100644 --- a/Tests/Fixtures/AttributeFixtures/DefaultValueController.php +++ b/Tests/Fixtures/AttributeFixtures/DefaultValueController.php @@ -3,6 +3,7 @@ namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Tests\Fixtures\AttributedClasses\BarClass; use Symfony\Component\Routing\Tests\Fixtures\Enum\TestIntBackedEnum; use Symfony\Component\Routing\Tests\Fixtures\Enum\TestStringBackedEnum; @@ -30,4 +31,14 @@ public function stringEnumAction(TestStringBackedEnum $default = TestStringBacke public function intEnumAction(TestIntBackedEnum $default = TestIntBackedEnum::Diamonds) { } + + #[Route(path: '/defaultMappedParam/{libelle:bar}', name: 'defaultMappedParam_default')] + public function defaultMappedParam(?BarClass $bar = null) + { + } + + #[Route(path: '/defaultAdvancedMappedParam/{barLibelle:bar.libelle}', name: 'defaultAdvancedMappedParam_default')] + public function defaultAdvancedMappedParam(?BarClass $bar = null) + { + } } diff --git a/Tests/Fixtures/AttributeFixtures/DeprecatedAliasCustomMessageRouteController.php b/Tests/Fixtures/AttributeFixtures/DeprecatedAliasCustomMessageRouteController.php new file mode 100644 index 00000000..08b1afbd --- /dev/null +++ b/Tests/Fixtures/AttributeFixtures/DeprecatedAliasCustomMessageRouteController.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Component\Routing\Attribute\DeprecatedAlias; +use Symfony\Component\Routing\Attribute\Route; + +class DeprecatedAliasCustomMessageRouteController +{ + + #[Route('/path', name: 'action_with_deprecated_alias', alias: new DeprecatedAlias('my_other_alias_deprecated', 'MyBundleFixture', '1.0', message: '%alias_id% alias is deprecated.'))] + public function action() + { + } +} diff --git a/Tests/Fixtures/AttributeFixtures/DeprecatedAliasRouteController.php b/Tests/Fixtures/AttributeFixtures/DeprecatedAliasRouteController.php new file mode 100644 index 00000000..06577cd7 --- /dev/null +++ b/Tests/Fixtures/AttributeFixtures/DeprecatedAliasRouteController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Component\Routing\Attribute\DeprecatedAlias; +use Symfony\Component\Routing\Attribute\Route; + +class DeprecatedAliasRouteController +{ + #[Route('/path', name: 'action_with_deprecated_alias', alias: new DeprecatedAlias('my_other_alias_deprecated', 'MyBundleFixture', '1.0'))] + public function action() + { + } +} diff --git a/Tests/Fixtures/AttributeFixtures/FooController.php b/Tests/Fixtures/AttributeFixtures/FooController.php index adbd038a..ba822865 100644 --- a/Tests/Fixtures/AttributeFixtures/FooController.php +++ b/Tests/Fixtures/AttributeFixtures/FooController.php @@ -55,4 +55,9 @@ public function host() public function condition() { } + + #[Route(alias: ['alias', 'completely_different_name'])] + public function alias() + { + } } diff --git a/Tests/Fixtures/AttributeFixtures/MultipleDeprecatedAliasRouteController.php b/Tests/Fixtures/AttributeFixtures/MultipleDeprecatedAliasRouteController.php new file mode 100644 index 00000000..93662d38 --- /dev/null +++ b/Tests/Fixtures/AttributeFixtures/MultipleDeprecatedAliasRouteController.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Component\Routing\Attribute\DeprecatedAlias; +use Symfony\Component\Routing\Attribute\Route; + +class MultipleDeprecatedAliasRouteController +{ + #[Route('/path', name: 'action_with_multiple_deprecated_alias', alias: [ + new DeprecatedAlias('my_first_alias_deprecated', 'MyFirstBundleFixture', '1.0'), + new DeprecatedAlias('my_second_alias_deprecated', 'MySecondBundleFixture', '2.0'), + new DeprecatedAlias('my_third_alias_deprecated', 'SurprisedThirdBundleFixture', '3.0'), + ])] + public function action() + { + } +} diff --git a/Tests/Loader/AttributeClassLoaderTest.php b/Tests/Loader/AttributeClassLoaderTest.php index afad8731..022e0c9f 100644 --- a/Tests/Loader/AttributeClassLoaderTest.php +++ b/Tests/Loader/AttributeClassLoaderTest.php @@ -16,8 +16,13 @@ use Symfony\Component\Routing\Exception\LogicException; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AbstractClassController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\ActionPathController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AliasClassController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AliasInvokableController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AliasRouteController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\BazClass; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\DefaultValueController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\DeprecatedAliasCustomMessageRouteController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\DeprecatedAliasRouteController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\EncodingClass; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\ExplicitLocalizedActionPathController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\ExtendedRouteOnClassController; @@ -35,6 +40,7 @@ use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MethodActionControllers; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MethodsAndSchemes; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MissingRouteNameController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MultipleDeprecatedAliasRouteController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\NothingButNameController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\PrefixedActionLocalizedRouteController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\PrefixedActionPathController; @@ -165,12 +171,16 @@ public function testLocalizedPathRoutesWithExplicitPathPropety() public function testDefaultValuesForMethods() { $routes = $this->loader->load(DefaultValueController::class); - $this->assertCount(5, $routes); + $this->assertCount(7, $routes); $this->assertEquals('/{default}/path', $routes->get('action')->getPath()); $this->assertEquals('value', $routes->get('action')->getDefault('default')); $this->assertEquals('Symfony', $routes->get('hello_with_default')->getDefault('name')); $this->assertEquals('World', $routes->get('hello_without_default')->getDefault('name')); $this->assertEquals('diamonds', $routes->get('string_enum_action')->getDefault('default')); + $this->assertArrayHasKey('libelle', $routes->get('defaultMappedParam_default')->getDefaults()); + $this->assertNull($routes->get('defaultMappedParam_default')->getDefault('libelle')); + $this->assertArrayHasKey('barLibelle', $routes->get('defaultAdvancedMappedParam_default')->getDefaults()); + $this->assertNull($routes->get('defaultAdvancedMappedParam_default')->getDefault('barLibelle')); $this->assertEquals(20, $routes->get('int_enum_action')->getDefault('default')); } @@ -364,4 +374,98 @@ public function testDefaultRouteName() $this->assertSame('symfony_component_routing_tests_fixtures_attributefixtures_encodingclass_routeàction', $defaultName); } + + public function testAliasesOnMethod() + { + $routes = $this->loader->load(AliasRouteController::class); + $route = $routes->get('action_with_alias'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + $this->assertEquals(new Alias('action_with_alias'), $routes->getAlias('alias')); + $this->assertEquals(new Alias('action_with_alias'), $routes->getAlias('completely_different_name')); + } + + public function testThrowsWithAliasesOnClass() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Route aliases cannot be used on non-invokable class "Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AliasClassController".'); + + $this->loader->load(AliasClassController::class); + } + + public function testAliasesOnInvokableClass() + { + $routes = $this->loader->load(AliasInvokableController::class); + $route = $routes->get('invokable_path'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + $this->assertEquals(new Alias('invokable_path'), $routes->getAlias('alias')); + $this->assertEquals(new Alias('invokable_path'), $routes->getAlias('completely_different_name')); + } + + public function testDeprecatedAlias() + { + $routes = $this->loader->load(DeprecatedAliasRouteController::class); + $route = $routes->get('action_with_deprecated_alias'); + $expected = (new Alias('action_with_deprecated_alias')) + ->setDeprecated( + 'MyBundleFixture', + '1.0', + 'The "%alias_id%" route alias is deprecated. You should stop using it, as it will be removed in the future.' + ); + $actual = $routes->getAlias('my_other_alias_deprecated'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + $this->assertEquals($expected, $actual); + } + + public function testDeprecatedAliasWithCustomMessage() + { + $routes = $this->loader->load(DeprecatedAliasCustomMessageRouteController::class); + $route = $routes->get('action_with_deprecated_alias'); + $expected = (new Alias('action_with_deprecated_alias')) + ->setDeprecated( + 'MyBundleFixture', + '1.0', + '%alias_id% alias is deprecated.' + ); + $actual = $routes->getAlias('my_other_alias_deprecated'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + $this->assertEquals($expected, $actual); + } + + public function testMultipleDeprecatedAlias() + { + $routes = $this->loader->load(MultipleDeprecatedAliasRouteController::class); + $route = $routes->get('action_with_multiple_deprecated_alias'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + + $dataset = [ + 'my_first_alias_deprecated' => [ + 'package' => 'MyFirstBundleFixture', + 'version' => '1.0', + ], + 'my_second_alias_deprecated' => [ + 'package' => 'MySecondBundleFixture', + 'version' => '2.0', + ], + 'my_third_alias_deprecated' => [ + 'package' => 'SurprisedThirdBundleFixture', + 'version' => '3.0', + ], + ]; + + foreach ($dataset as $aliasName => $aliasData) { + $expected = (new Alias('action_with_multiple_deprecated_alias')) + ->setDeprecated( + $aliasData['package'], + $aliasData['version'], + 'The "%alias_id%" route alias is deprecated. You should stop using it, as it will be removed in the future.' + ); + $actual = $routes->getAlias($aliasName); + $this->assertEquals($expected, $actual); + } + } } diff --git a/Tests/Loader/Configurator/Traits/PrefixTraitTest.php b/Tests/Loader/Configurator/Traits/PrefixTraitTest.php new file mode 100644 index 00000000..aa63af31 --- /dev/null +++ b/Tests/Loader/Configurator/Traits/PrefixTraitTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Loader\Configurator\Traits; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\Loader\Configurator\Traits\PrefixTrait; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +class PrefixTraitTest extends TestCase +{ + public function testAddLocalizedPrefixUpdatesAliases() + { + $collection = new RouteCollection(); + $collection->add('app_route', new Route('/path')); + $collection->addAlias('app_alias', 'app_route'); + + $trait = new class { + use PrefixTrait; + + public function add(RouteCollection $c, array $p) + { + $this->addPrefix($c, $p, false); + } + }; + + $trait->add($collection, ['en' => '/en', 'fr' => '/fr']); + + $this->assertNull($collection->get('app_route')); + + $this->assertNotNull($collection->get('app_route.en')); + $this->assertNotNull($collection->get('app_route.fr')); + + $this->assertNull($collection->getAlias('app_alias'), 'The original alias should be removed as its target no longer exists'); + + $aliasEn = $collection->getAlias('app_alias.en'); + $this->assertNotNull($aliasEn, 'Localized alias for EN should exist'); + $this->assertEquals('app_route.en', $aliasEn->getId()); + + $aliasFr = $collection->getAlias('app_alias.fr'); + $this->assertNotNull($aliasFr, 'Localized alias for FR should exist'); + $this->assertEquals('app_route.fr', $aliasFr->getId()); + } +} diff --git a/Tests/Loader/Psr4DirectoryLoaderTest.php b/Tests/Loader/Psr4DirectoryLoaderTest.php index 81515b86..0720caca 100644 --- a/Tests/Loader/Psr4DirectoryLoaderTest.php +++ b/Tests/Loader/Psr4DirectoryLoaderTest.php @@ -15,6 +15,7 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\DelegatingLoader; use Symfony\Component\Config\Loader\LoaderResolver; +use Symfony\Component\Routing\Exception\InvalidArgumentException; use Symfony\Component\Routing\Loader\AttributeClassLoader; use Symfony\Component\Routing\Loader\Psr4DirectoryLoader; use Symfony\Component\Routing\Route; @@ -90,6 +91,34 @@ public static function provideNamespacesThatNeedTrimming(): array ]; } + /** + * @dataProvider provideInvalidPsr4Namespaces + */ + public function testInvalidPsr4Namespace(string $namespace, string $expectedExceptionMessage) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->getLoader()->load( + ['path' => 'Psr4Controllers', 'namespace' => $namespace], + 'attribute' + ); + } + + public static function provideInvalidPsr4Namespaces(): array + { + return [ + 'slash instead of back-slash' => [ + 'namespace' => 'App\Application/Controllers', + 'expectedExceptionMessage' => 'Namespace "App\Application/Controllers" is not a valid PSR-4 prefix.', + ], + 'invalid namespace' => [ + 'namespace' => 'App\Contro llers', + 'expectedExceptionMessage' => 'Namespace "App\Contro llers" is not a valid PSR-4 prefix.', + ], + ]; + } + private function loadPsr4Controllers(): RouteCollection { return $this->getLoader()->load( diff --git a/Tests/Matcher/CompiledUrlMatcherTest.php b/Tests/Matcher/CompiledUrlMatcherTest.php index fd8e694e..fa812813 100644 --- a/Tests/Matcher/CompiledUrlMatcherTest.php +++ b/Tests/Matcher/CompiledUrlMatcherTest.php @@ -13,11 +13,33 @@ use Symfony\Component\Routing\Matcher\CompiledUrlMatcher; use Symfony\Component\Routing\Matcher\Dumper\CompiledUrlMatcherDumper; +use Symfony\Component\Routing\Matcher\UrlMatcher; use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; class CompiledUrlMatcherTest extends UrlMatcherTest { + public function testStaticHostIsCaseInsensitive() + { + $collection = new RouteCollection(); + $collection->add('static_host_route', new Route('/test', [], [], [], 'API.example.com')); + + $context = new RequestContext('/test', 'GET', 'api.example.com'); + $matcher = new UrlMatcher($collection, $context); + + $result = $matcher->match('/test'); + $this->assertEquals('static_host_route', $result['_route'], 'UrlMatcher should match case-insensitive host'); + + $dumper = new CompiledUrlMatcherDumper($collection); + $compiledRoutes = $dumper->getCompiledRoutes(); + + $compiledMatcher = new CompiledUrlMatcher($compiledRoutes, $context); + + $result = $compiledMatcher->match('/test'); + $this->assertEquals('static_host_route', $result['_route'], 'CompiledUrlMatcher should match case-insensitive host'); + } + protected function getUrlMatcher(RouteCollection $routes, ?RequestContext $context = null) { $dumper = new CompiledUrlMatcherDumper($routes); diff --git a/Tests/Matcher/UrlMatcherTest.php b/Tests/Matcher/UrlMatcherTest.php index d8b14c31..eb5dd47a 100644 --- a/Tests/Matcher/UrlMatcherTest.php +++ b/Tests/Matcher/UrlMatcherTest.php @@ -22,6 +22,17 @@ class UrlMatcherTest extends TestCase { + public function testZero() + { + $coll = new RouteCollection(); + $coll->add('index', new Route('/')); + + $matcher = $this->getUrlMatcher($coll); + + $this->expectException(ResourceNotFoundException::class); + $matcher->match('0'); + } + public function testNoMethodSoAllowed() { $coll = new RouteCollection(); @@ -1035,7 +1046,30 @@ public function testMapping() '_route' => 'a', 'slug' => 'vienna-2024', '_route_mapping' => [ - 'slug' => 'conference', + 'slug' => [ + 'conference', + 'slug', + ], + ], + ]; + $this->assertEquals($expected, $matcher->match('/conference/vienna-2024')); + } + + public function testMappingwithAlias() + { + $collection = new RouteCollection(); + $collection->add('a', new Route('/conference/{conferenceSlug:conference.slug}')); + + $matcher = $this->getUrlMatcher($collection); + + $expected = [ + '_route' => 'a', + 'conferenceSlug' => 'vienna-2024', + '_route_mapping' => [ + 'conferenceSlug' => [ + 'conference', + 'slug', + ], ], ]; $this->assertEquals($expected, $matcher->match('/conference/vienna-2024')); diff --git a/Tests/Requirement/RequirementTest.php b/Tests/Requirement/RequirementTest.php index 814deadd..d7e0ba07 100644 --- a/Tests/Requirement/RequirementTest.php +++ b/Tests/Requirement/RequirementTest.php @@ -137,6 +137,32 @@ public function testDigitsKO(string $digits) ); } + /** + * @testWith ["67c8b7d295c70befc3070bf2"] + * ["000000000000000000000000"] + */ + public function testMongoDbIdOK(string $id) + { + $this->assertMatchesRegularExpression( + (new Route('/{id}', [], ['id' => Requirement::MONGODB_ID]))->compile()->getRegex(), + '/'.$id, + ); + } + + /** + * @testWith ["67C8b7D295C70BEFC3070BF2"] + * ["67c8b7d295c70befc3070bg2"] + * ["67c8b7d295c70befc3070bf2a"] + * ["67c8b7d295c70befc3070bf"] + */ + public function testMongoDbIdKO(string $id) + { + $this->assertDoesNotMatchRegularExpression( + (new Route('/{id}', [], ['id' => Requirement::MONGODB_ID]))->compile()->getRegex(), + '/'.$id, + ); + } + /** * @testWith ["1"] * ["42"] diff --git a/Tests/RouteCollectionTest.php b/Tests/RouteCollectionTest.php index 7625bcf5..16ab7f6f 100644 --- a/Tests/RouteCollectionTest.php +++ b/Tests/RouteCollectionTest.php @@ -385,4 +385,18 @@ public function testAddWithPriorityAndPrefix() $this->assertSame($expected, $collection3->all()); } + + public function testAddNamePrefixDoesNotBreakExternalAliases() + { + $collection = new RouteCollection(); + $collection->add('local_route', new Route('/local')); + $collection->addAlias('alias_to_local', 'local_route'); + $collection->addAlias('alias_to_external', 'external_route'); + $collection->addNamePrefix('prefix_'); + + $aliases = $collection->getAliases(); + + $this->assertEquals('prefix_local_route', $aliases['prefix_alias_to_local']->getId(), 'Alias to local route should have its target prefixed'); + $this->assertEquals('external_route', $aliases['prefix_alias_to_external']->getId(), 'Alias to external route should NOT have its target prefixed'); + } } diff --git a/Tests/RouteTest.php b/Tests/RouteTest.php index b58358a3..34728042 100644 --- a/Tests/RouteTest.php +++ b/Tests/RouteTest.php @@ -226,37 +226,48 @@ public function testSerialize() $this->assertNotSame($route, $unserialized); } - public function testInlineDefaultAndRequirement() + /** + * @dataProvider provideInlineDefaultAndRequirementCases + */ + public function testInlineDefaultAndRequirement(Route $route, string $expectedPath, string $expectedHost, array $expectedDefaults, array $expectedRequirements) + { + self::assertSame($expectedPath, $route->getPath()); + self::assertSame($expectedHost, $route->getHost()); + self::assertSame($expectedDefaults, $route->getDefaults()); + self::assertSame($expectedRequirements, $route->getRequirements()); + } + + public static function provideInlineDefaultAndRequirementCases(): iterable { - $this->assertEquals((new Route('/foo/{bar}'))->setDefault('bar', null), new Route('/foo/{bar?}')); - $this->assertEquals((new Route('/foo/{bar}'))->setDefault('bar', 'baz'), new Route('/foo/{bar?baz}')); - $this->assertEquals((new Route('/foo/{bar}'))->setDefault('bar', 'baz'), new Route('/foo/{bar?baz}')); - $this->assertEquals((new Route('/foo/{!bar}'))->setDefault('bar', 'baz'), new Route('/foo/{!bar?baz}')); - $this->assertEquals((new Route('/foo/{bar}'))->setDefault('bar', 'baz'), new Route('/foo/{bar?}', ['bar' => 'baz'])); - - $this->assertEquals((new Route('/foo/{bar}'))->setRequirement('bar', '.*'), new Route('/foo/{bar<.*>}')); - $this->assertEquals((new Route('/foo/{bar}'))->setRequirement('bar', '>'), new Route('/foo/{bar<>>}')); - $this->assertEquals((new Route('/foo/{bar}'))->setRequirement('bar', '\d+'), new Route('/foo/{bar<.*>}', [], ['bar' => '\d+'])); - $this->assertEquals((new Route('/foo/{bar}'))->setRequirement('bar', '[a-z]{2}'), new Route('/foo/{bar<[a-z]{2}>}')); - $this->assertEquals((new Route('/foo/{!bar}'))->setRequirement('bar', '\d+'), new Route('/foo/{!bar<\d+>}')); - - $this->assertEquals((new Route('/foo/{bar}'))->setDefault('bar', null)->setRequirement('bar', '.*'), new Route('/foo/{bar<.*>?}')); - $this->assertEquals((new Route('/foo/{bar}'))->setDefault('bar', '<>')->setRequirement('bar', '>'), new Route('/foo/{bar<>>?<>}')); - - $this->assertEquals((new Route('/{foo}/{!bar}'))->setDefaults(['bar' => '<>', 'foo' => '\\'])->setRequirements(['bar' => '\\', 'foo' => '.']), new Route('/{foo<.>?\}/{!bar<\>?<>}')); - - $this->assertEquals((new Route('/'))->setHost('{bar}')->setDefault('bar', null), (new Route('/'))->setHost('{bar?}')); - $this->assertEquals((new Route('/'))->setHost('{bar}')->setDefault('bar', 'baz'), (new Route('/'))->setHost('{bar?baz}')); - $this->assertEquals((new Route('/'))->setHost('{bar}')->setDefault('bar', 'baz'), (new Route('/'))->setHost('{bar?baz}')); - $this->assertEquals((new Route('/'))->setHost('{bar}')->setDefault('bar', null), (new Route('/', ['bar' => 'baz']))->setHost('{bar?}')); - - $this->assertEquals((new Route('/'))->setHost('{bar}')->setRequirement('bar', '.*'), (new Route('/'))->setHost('{bar<.*>}')); - $this->assertEquals((new Route('/'))->setHost('{bar}')->setRequirement('bar', '>'), (new Route('/'))->setHost('{bar<>>}')); - $this->assertEquals((new Route('/'))->setHost('{bar}')->setRequirement('bar', '.*'), (new Route('/', [], ['bar' => '\d+']))->setHost('{bar<.*>}')); - $this->assertEquals((new Route('/'))->setHost('{bar}')->setRequirement('bar', '[a-z]{2}'), (new Route('/'))->setHost('{bar<[a-z]{2}>}')); - - $this->assertEquals((new Route('/'))->setHost('{bar}')->setDefault('bar', null)->setRequirement('bar', '.*'), (new Route('/'))->setHost('{bar<.*>?}')); - $this->assertEquals((new Route('/'))->setHost('{bar}')->setDefault('bar', '<>')->setRequirement('bar', '>'), (new Route('/'))->setHost('{bar<>>?<>}')); + yield [new Route('/foo/{bar?}'), '/foo/{bar}', '', ['bar' => null], []]; + yield [new Route('/foo/{bar?baz}'), '/foo/{bar}', '', ['bar' => 'baz'], []]; + yield [new Route('/foo/{bar?baz}'), '/foo/{bar}', '', ['bar' => 'baz'], []]; + yield [new Route('/foo/{!bar?baz}'), '/foo/{!bar}', '', ['bar' => 'baz'], []]; + yield [new Route('/foo/{bar?}', ['bar' => 'baz']), '/foo/{bar}', '', ['bar' => 'baz'], []]; + + yield [new Route('/foo/{bar<.*>}'), '/foo/{bar}', '', [], ['bar' => '.*']]; + yield [new Route('/foo/{bar<>>}'), '/foo/{bar}', '', [], ['bar' => '>']]; + yield [new Route('/foo/{bar<.*>}', [], ['bar' => '\d+']), '/foo/{bar}', '', [], ['bar' => '\d+']]; + yield [new Route('/foo/{bar<[a-z]{2}>}'), '/foo/{bar}', '', [], ['bar' => '[a-z]{2}']]; + yield [new Route('/foo/{!bar<\d+>}'), '/foo/{!bar}', '', [], ['bar' => '\d+']]; + + yield [new Route('/foo/{bar<.*>?}'), '/foo/{bar}', '', ['bar' => null], ['bar' => '.*']]; + yield [new Route('/foo/{bar<>>?<>}'), '/foo/{bar}', '', ['bar' => '<>'], ['bar' => '>']]; + + yield [new Route('/{foo<.>?\}/{!bar<\>?<>}'), '/{foo}/{!bar}', '', ['foo' => '\\', 'bar' => '<>'], ['foo' => '.', 'bar' => '\\']]; + + yield [new Route('/', host: '{bar?}'), '/', '{bar}', ['bar' => null], []]; + yield [new Route('/', host: '{bar?baz}'), '/', '{bar}', ['bar' => 'baz'], []]; + yield [new Route('/', host: '{bar?baz}'), '/', '{bar}', ['bar' => 'baz'], []]; + yield [new Route('/', ['bar' => 'baz'], host: '{bar?}'), '/', '{bar}', ['bar' => null], []]; + + yield [new Route('/', host: '{bar<.*>}'), '/', '{bar}', [], ['bar' => '.*']]; + yield [new Route('/', host: '{bar<>>}'), '/', '{bar}', [], ['bar' => '>']]; + yield [new Route('/', [], ['bar' => '\d+'], host: '{bar<.*>}'), '/', '{bar}', [], ['bar' => '.*']]; + yield [new Route('/', host: '{bar<[a-z]{2}>}'), '/', '{bar}', [], ['bar' => '[a-z]{2}']]; + + yield [new Route('/', host: '{bar<.*>?}'), '/', '{bar}', ['bar' => null], ['bar' => '.*']]; + yield [new Route('/', host: '{bar<>>?<>}'), '/', '{bar}', ['bar' => '<>'], ['bar' => '>']]; } /**