├── LICENSE ├── composer.json ├── config └── services.yaml └── src ├── CacheWarmer └── RouterCacheWarmer.php ├── Command └── DebugRouterCommand.php ├── Console ├── Descriptor │ ├── Descriptor.php │ ├── JsonDescriptor.php │ ├── MarkdownDescriptor.php │ ├── TextDescriptor.php │ └── XmlDescriptor.php └── Helper │ └── DescriptorHelper.php ├── DependencyInjection ├── CompilerPass │ └── RoutingResolverPass.php ├── Configuration.php └── GosPubSubRouterExtension.php ├── Exception ├── InvalidArgumentException.php ├── InvalidParameterException.php ├── MissingMandatoryParametersException.php ├── PubSubRouterException.php ├── ResourceNotFoundException.php └── RouterException.php ├── Generator ├── CompiledGenerator.php ├── Dumper │ ├── CompiledGeneratorDumper.php │ ├── GeneratorDumper.php │ ├── GeneratorDumperInterface.php │ └── PhpGeneratorDumper.php ├── Generator.php └── GeneratorInterface.php ├── GosPubSubRouterBundle.php ├── Loader ├── ClosureLoader.php ├── CompatibilityFileLoader.php ├── CompatibilityLoader.php ├── Configurator │ ├── CollectionConfigurator.php │ ├── ImportConfigurator.php │ ├── RouteConfigurator.php │ ├── RoutingConfigurator.php │ └── Traits │ │ ├── AddTrait.php │ │ └── RouteTrait.php ├── ContainerLoader.php ├── GlobFileLoader.php ├── ObjectLoader.php ├── PhpFileLoader.php ├── RouteLoaderInterface.php ├── XmlFileLoader.php ├── YamlFileLoader.php └── schema │ └── routing │ └── routing-1.0.xsd ├── Matcher ├── CompiledMatcher.php ├── Dumper │ ├── CompiledMatcherDumper.php │ ├── MatcherDumper.php │ ├── MatcherDumperInterface.php │ ├── PhpMatcherDumper.php │ └── StaticPrefixCollection.php ├── Matcher.php └── MatcherInterface.php ├── Request └── PubSubRequest.php └── Router ├── CompiledRoute.php ├── Route.php ├── RouteCollection.php ├── RouteCompiler.php ├── RouteCompilerInterface.php ├── Router.php ├── RouterInterface.php └── RouterRegistry.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Johann Saunier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gos/pubsub-router-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Symfony PubSub Router Bundle", 5 | "keywords": ["PubSub Bundle", "PubSub", "Bundle", "Redis", "WAMP", "ZMQ"], 6 | "homepage": "https://github.com/GeniusesOfSymfony/PubSubRouterBundle", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Johann Saunier", 11 | "email": "johann_27@hotmail.fr" 12 | } 13 | ], 14 | "require": { 15 | "php": "^7.2 || ^8.0", 16 | "symfony/config": "^4.4.42 || ^5.4 || ^6.0", 17 | "symfony/console": "^4.4.42 || ^5.4 || ^6.0", 18 | "symfony/dependency-injection": "^4.4.42 || ^5.4 || ^6.0", 19 | "symfony/deprecation-contracts": "^2.1 || ^3.0", 20 | "symfony/http-foundation": "^4.4.42 || ^5.4 || ^6.0", 21 | "symfony/http-kernel": "^4.4.42 || ^5.4 || ^6.0", 22 | "symfony/polyfill-php80": "^1.22", 23 | "symfony/yaml": "^4.4.42 || ^5.4 || ^6.0" 24 | }, 25 | "require-dev": { 26 | "matthiasnoback/symfony-dependency-injection-test": "^4.1.2", 27 | "phpstan/extension-installer": "^1.1", 28 | "phpstan/phpstan": "1.8.5", 29 | "phpstan/phpstan-phpunit": "1.1.1", 30 | "phpstan/phpstan-symfony": "1.2.13", 31 | "phpunit/phpunit": "^8.5 || ^9.3", 32 | "psr/container": "^1.0 || ^2.0", 33 | "symfony/phpunit-bridge": "^5.4 || ^6.0" 34 | }, 35 | "autoload": { 36 | "psr-4": { "Gos\\Bundle\\PubSubRouterBundle\\": "src/" } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { "Gos\\Bundle\\PubSubRouterBundle\\Tests\\": "tests/" } 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "phpstan/extension-installer": true 44 | } 45 | }, 46 | "minimum-stability": "dev" 47 | } 48 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | gos_pubsub_router.cache_warmer.router: 3 | class: Gos\Bundle\PubSubRouterBundle\CacheWarmer\RouterCacheWarmer 4 | public: false 5 | arguments: 6 | - '@Psr\Container\ContainerInterface' 7 | tags: 8 | - { name: container.service_subscriber, id: 'gos_pubsub_router.router_registry' } 9 | - { name: kernel.cache_warmer } 10 | 11 | gos_pubsub_router.command.debug_router: 12 | class: Gos\Bundle\PubSubRouterBundle\Command\DebugRouterCommand 13 | arguments: 14 | - '@gos_pubsub_router.router_registry' 15 | tags: 16 | - { name: console.command } 17 | 18 | gos_pubsub_router.loader.closure: 19 | class: Gos\Bundle\PubSubRouterBundle\Loader\ClosureLoader 20 | public: false 21 | tags: 22 | - { name: gos_pubsub_router.routing.loader } 23 | 24 | gos_pubsub_router.loader.container: 25 | class: Gos\Bundle\PubSubRouterBundle\Loader\ContainerLoader 26 | public: false 27 | arguments: 28 | - !tagged_locator { tag: 'gos_pubsub_router.routing.route_loader' } 29 | tags: 30 | - { name: gos_pubsub_router.routing.loader } 31 | 32 | gos_pubsub_router.loader.glob: 33 | class: Gos\Bundle\PubSubRouterBundle\Loader\GlobFileLoader 34 | public: false 35 | arguments: 36 | - '@file_locator' 37 | tags: 38 | - { name: gos_pubsub_router.routing.loader } 39 | 40 | gos_pubsub_router.loader.php: 41 | class: Gos\Bundle\PubSubRouterBundle\Loader\PhpFileLoader 42 | public: false 43 | arguments: 44 | - '@file_locator' 45 | tags: 46 | - { name: gos_pubsub_router.routing.loader } 47 | 48 | gos_pubsub_router.loader.xml: 49 | class: Gos\Bundle\PubSubRouterBundle\Loader\XmlFileLoader 50 | public: false 51 | arguments: 52 | - '@file_locator' 53 | tags: 54 | - { name: gos_pubsub_router.routing.loader } 55 | 56 | gos_pubsub_router.loader.yaml: 57 | class: Gos\Bundle\PubSubRouterBundle\Loader\YamlFileLoader 58 | public: false 59 | arguments: 60 | - '@file_locator' 61 | tags: 62 | - { name: gos_pubsub_router.routing.loader } 63 | 64 | gos_pubsub_router.router_registry: 65 | class: Gos\Bundle\PubSubRouterBundle\Router\RouterRegistry 66 | public: true 67 | tags: 68 | - { name: container.private, package: 'gos/pubsub-router-bundle', version: '2.6' } 69 | 70 | gos_pubsub_router.routing.loader: 71 | class: Symfony\Component\Config\Loader\DelegatingLoader 72 | public: false 73 | arguments: 74 | - '@gos_pubsub_router.routing.resolver' 75 | 76 | gos_pubsub_router.routing.resolver: 77 | class: Symfony\Component\Config\Loader\LoaderResolver 78 | public: false 79 | -------------------------------------------------------------------------------- /src/CacheWarmer/RouterCacheWarmer.php: -------------------------------------------------------------------------------- 1 | container = $container; 25 | } 26 | 27 | /** 28 | * Warms up the cache. 29 | * 30 | * @param string $cacheDir The cache directory 31 | * 32 | * @return string[] A list of classes to preload on PHP 7.4+ 33 | */ 34 | public function warmUp($cacheDir) 35 | { 36 | /** @var RouterRegistry $registry */ 37 | $registry = $this->container->get('gos_pubsub_router.router_registry'); 38 | 39 | $classes = []; 40 | 41 | foreach ($registry->getRouters() as $router) { 42 | if ($router instanceof WarmableInterface) { 43 | $classes = array_merge( 44 | $classes, 45 | (array) $router->warmUp($cacheDir) 46 | ); 47 | } 48 | } 49 | 50 | return array_unique($classes); 51 | } 52 | 53 | public function isOptional(): bool 54 | { 55 | return true; 56 | } 57 | 58 | /** 59 | * @phpstan-return array 60 | */ 61 | public static function getSubscribedServices(): array 62 | { 63 | return [ 64 | 'gos_pubsub_router.router_registry' => RouterRegistry::class, 65 | ]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Command/DebugRouterCommand.php: -------------------------------------------------------------------------------- 1 | registry = $registry; 36 | } 37 | 38 | protected function configure(): void 39 | { 40 | $this 41 | ->setAliases(['gos:prouter:debug']) 42 | ->addArgument('router', InputArgument::OPTIONAL, 'The router to show information about') 43 | ->addArgument('route', InputArgument::OPTIONAL, 'An optional route name from the router to describe') 44 | ->addOption('router_name', 'r', InputOption::VALUE_REQUIRED, 'Router name') 45 | ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (json, md, txt, or xml)', 'txt') 46 | ->setDescription('Display current routes for a pubsub router'); 47 | } 48 | 49 | protected function execute(InputInterface $input, OutputInterface $output): int 50 | { 51 | $io = new SymfonyStyle($input, $output); 52 | 53 | /** @var string|null $routerArgument */ 54 | $routerArgument = $input->getArgument('router'); 55 | 56 | /** @var string|null $routerOption */ 57 | $routerOption = $input->getOption('router_name'); 58 | 59 | /** @var string|null $routeArgument */ 60 | $routeArgument = $input->getArgument('route'); 61 | 62 | if (null !== $routerArgument) { 63 | if (null !== $routerOption) { 64 | $routerName = $routerOption; 65 | $routeName = $routerArgument; 66 | } else { 67 | $routerName = $routerArgument; 68 | $routeName = $routeArgument; 69 | } 70 | } elseif (null !== $routerOption) { 71 | trigger_deprecation('gos/pubsub-router-bundle', '2.5', 'The "router_name" option of the "gos:prouter:debug" command is deprecated and will be removed in 3.0, use the router argument instead.'); 72 | 73 | $routerName = $routerOption; 74 | $routeName = null; 75 | } else { 76 | $io->error('A router must be provided.'); 77 | 78 | return 1; 79 | } 80 | 81 | if (!$this->registry->hasRouter($routerName)) { 82 | $io->error( 83 | sprintf( 84 | 'Unknown router %s, available routers are [ %s ]', 85 | $routerName, 86 | implode(', ', array_keys($this->registry->getRouters())) 87 | ) 88 | ); 89 | 90 | return 1; 91 | } 92 | 93 | $router = $this->registry->getRouter($routerName); 94 | 95 | $helper = new DescriptorHelper(); 96 | 97 | if ($routeName) { 98 | $route = $router->getCollection()->get($routeName); 99 | 100 | if (null === $route) { 101 | $io->error(sprintf('The "%s" route does not exist on the "%s" router.', $routeName, $routerName)); 102 | 103 | return 1; 104 | } 105 | 106 | $helper->describe( 107 | $io, 108 | $route, 109 | [ 110 | 'format' => $input->getOption('format'), 111 | 'name' => $routeName, 112 | 'output' => $io, 113 | ] 114 | ); 115 | } else { 116 | $helper->describe( 117 | $io, 118 | $router->getCollection(), 119 | [ 120 | 'format' => $input->getOption('format'), 121 | 'output' => $io, 122 | ] 123 | ); 124 | } 125 | 126 | return 0; 127 | } 128 | 129 | /** 130 | * @throws InvalidArgumentException if an invalid router is provided 131 | */ 132 | public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void 133 | { 134 | if ($input->mustSuggestArgumentValuesFor('router') || $input->mustSuggestOptionValuesFor('router_name')) { 135 | $suggestions->suggestValues(array_keys($this->registry->getRouters())); 136 | 137 | return; 138 | } 139 | 140 | if ($input->mustSuggestArgumentValuesFor('route')) { 141 | /** @var string|null $routerName */ 142 | $routerName = $input->getArgument('router'); 143 | 144 | if (null !== $routerName) { 145 | if (!$this->registry->hasRouter($routerName)) { 146 | throw new InvalidArgumentException(sprintf('Unknown router %s, available routers are [ %s ]', $routerName, implode(', ', array_keys($this->registry->getRouters())))); 147 | } 148 | 149 | $router = $this->registry->getRouter($routerName); 150 | 151 | $suggestions->suggestValues(array_keys($router->getCollection()->all())); 152 | 153 | return; 154 | } 155 | } 156 | 157 | if ($input->mustSuggestOptionValuesFor('format')) { 158 | $suggestions->suggestValues((new DescriptorHelper())->getFormats()); 159 | 160 | return; 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Console/Descriptor/Descriptor.php: -------------------------------------------------------------------------------- 1 | output = $output; 28 | 29 | switch (true) { 30 | case $object instanceof RouteCollection: 31 | $this->describeRouteCollection($object, $options); 32 | 33 | break; 34 | 35 | case $object instanceof Route: 36 | $this->describeRoute($object, $options); 37 | 38 | break; 39 | 40 | default: 41 | throw new \InvalidArgumentException(sprintf('Object of type "%s" is not describable.', get_debug_type($object))); 42 | } 43 | } 44 | 45 | protected function getOutput(): OutputInterface 46 | { 47 | return $this->output; 48 | } 49 | 50 | protected function write(string $content, bool $decorated = false): void 51 | { 52 | $this->output->write($content, false, $decorated ? OutputInterface::OUTPUT_NORMAL : OutputInterface::OUTPUT_RAW); 53 | } 54 | 55 | /** 56 | * Formats a value as string. 57 | * 58 | * @param mixed $value 59 | */ 60 | protected function formatValue($value): string 61 | { 62 | if (\is_object($value)) { 63 | return sprintf('object(%s)', \get_class($value)); 64 | } 65 | 66 | if (\is_string($value)) { 67 | return $value; 68 | } 69 | 70 | return preg_replace("/\n\s*/s", '', var_export($value, true)) ?: ''; 71 | } 72 | 73 | abstract protected function describeRouteCollection(RouteCollection $routes, array $options = []): void; 74 | 75 | abstract protected function describeRoute(Route $route, array $options = []): void; 76 | } 77 | -------------------------------------------------------------------------------- /src/Console/Descriptor/JsonDescriptor.php: -------------------------------------------------------------------------------- 1 | all() as $name => $route) { 19 | $data[$name] = $this->getRouteData($route); 20 | } 21 | 22 | $this->writeData($data, $options); 23 | } 24 | 25 | protected function describeRoute(Route $route, array $options = []): void 26 | { 27 | $this->writeData($this->getRouteData($route), $options); 28 | } 29 | 30 | /** 31 | * @return array 32 | */ 33 | private function getRouteData(Route $route): array 34 | { 35 | return [ 36 | 'pattern' => $route->getPattern(), 37 | 'patternRegex' => $route->compile()->getRegex(), 38 | 'callback' => $this->formatRouteCallback($route), 39 | 'requirements' => $route->getRequirements() ?: 'NO CUSTOM', 40 | 'class' => \get_class($route), 41 | 'defaults' => $route->getDefaults(), 42 | 'options' => $route->getOptions(), 43 | ]; 44 | } 45 | 46 | /** 47 | * @param callable $callable 48 | */ 49 | private function formatCallable($callable): string 50 | { 51 | if (\is_array($callable)) { 52 | if (\is_object($callable[0])) { 53 | return sprintf('%s::%s()', \get_class($callable[0]), $callable[1]); 54 | } 55 | 56 | return sprintf('%s::%s()', $callable[0], $callable[1]); 57 | } 58 | 59 | if (\is_string($callable)) { 60 | return sprintf('%s()', $callable); 61 | } 62 | 63 | if ($callable instanceof \Closure) { 64 | $r = new \ReflectionFunction($callable); 65 | 66 | if (false !== strpos($r->name, '{closure}')) { 67 | return 'Closure()'; 68 | } 69 | 70 | if ($class = $r->getClosureScopeClass()) { 71 | return sprintf('%s::%s()', $class->name, $r->name); 72 | } 73 | 74 | return $r->name.'()'; 75 | } 76 | 77 | if (\is_object($callable) && method_exists($callable, '__invoke')) { 78 | return sprintf('%s::__invoke()', \get_class($callable)); 79 | } 80 | 81 | throw new \InvalidArgumentException('Callable is not describable.'); 82 | } 83 | 84 | private function formatRouteCallback(Route $route): string 85 | { 86 | if (\is_array($route->getCallback())) { 87 | return implode(', ', $route->getCallback()); 88 | } 89 | 90 | if (\is_callable($route->getCallback())) { 91 | return $this->formatCallable($route->getCallback()); 92 | } 93 | 94 | return $route->getCallback(); 95 | } 96 | 97 | private function writeData(array $data, array $options): void 98 | { 99 | $flags = $options['json_encoding'] ?? 0; 100 | 101 | $this->write(json_encode($data, $flags | \JSON_PRETTY_PRINT)."\n"); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Console/Descriptor/MarkdownDescriptor.php: -------------------------------------------------------------------------------- 1 | all() as $name => $route) { 19 | if ($first) { 20 | $first = false; 21 | } else { 22 | $this->write("\n\n"); 23 | } 24 | 25 | $this->describeRoute($route, ['name' => $name]); 26 | } 27 | 28 | $this->write("\n"); 29 | } 30 | 31 | protected function describeRoute(Route $route, array $options = []): void 32 | { 33 | $output = '- Pattern: '.$route->getPattern() 34 | ."\n".'- Pattern Regex: '.$route->compile()->getRegex() 35 | ."\n".'- Callback: '.$this->formatRouteCallback($route) 36 | ."\n".'- Requirements: '.($route->getRequirements() ? $this->formatRouterConfig($route->getRequirements()) : 'NO CUSTOM') 37 | ."\n".'- Class: '.\get_class($route) 38 | ."\n".'- Defaults: '.$this->formatRouterConfig($route->getDefaults()) 39 | ."\n".'- Options: '.$this->formatRouterConfig($route->getOptions()); 40 | 41 | $this->write(isset($options['name']) 42 | ? $options['name']."\n".str_repeat('-', \strlen($options['name']))."\n\n".$output 43 | : $output); 44 | $this->write("\n"); 45 | } 46 | 47 | /** 48 | * @param callable $callable 49 | */ 50 | private function formatCallable($callable): string 51 | { 52 | if (\is_array($callable)) { 53 | if (\is_object($callable[0])) { 54 | return sprintf('%s::%s()', \get_class($callable[0]), $callable[1]); 55 | } 56 | 57 | return sprintf('%s::%s()', $callable[0], $callable[1]); 58 | } 59 | 60 | if (\is_string($callable)) { 61 | return sprintf('%s()', $callable); 62 | } 63 | 64 | if ($callable instanceof \Closure) { 65 | $r = new \ReflectionFunction($callable); 66 | 67 | if (false !== strpos($r->name, '{closure}')) { 68 | return 'Closure()'; 69 | } 70 | 71 | if ($class = $r->getClosureScopeClass()) { 72 | return sprintf('%s::%s()', $class->name, $r->name); 73 | } 74 | 75 | return $r->name.'()'; 76 | } 77 | 78 | if (\is_object($callable) && method_exists($callable, '__invoke')) { 79 | return sprintf('%s::__invoke()', \get_class($callable)); 80 | } 81 | 82 | throw new \InvalidArgumentException('Callable is not describable.'); 83 | } 84 | 85 | private function formatRouteCallback(Route $route): string 86 | { 87 | if (\is_array($route->getCallback())) { 88 | return implode(', ', $route->getCallback()); 89 | } 90 | 91 | if (\is_callable($route->getCallback())) { 92 | return $this->formatCallable($route->getCallback()); 93 | } 94 | 95 | return $route->getCallback(); 96 | } 97 | 98 | private function formatRouterConfig(array $array): string 99 | { 100 | if (!$array) { 101 | return 'NONE'; 102 | } 103 | 104 | $string = ''; 105 | ksort($array); 106 | 107 | foreach ($array as $name => $value) { 108 | $string .= "\n".' - `'.$name.'`: '.$this->formatValue($value); 109 | } 110 | 111 | return $string; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Console/Descriptor/TextDescriptor.php: -------------------------------------------------------------------------------- 1 | all() as $name => $route) { 20 | $tableRows[] = [$name, $route->getPattern(), $this->formatRouteCallback($route)]; 21 | } 22 | 23 | if (isset($options['output'])) { 24 | $options['output']->table($tableHeaders, $tableRows); 25 | } else { 26 | (new Table($this->getOutput())) 27 | ->setHeaders($tableHeaders) 28 | ->setRows($tableRows) 29 | ->render(); 30 | } 31 | } 32 | 33 | protected function describeRoute(Route $route, array $options = []): void 34 | { 35 | $tableHeaders = ['Property', 'Value']; 36 | 37 | $tableRows = [ 38 | ['Route Name', $options['name'] ?? ''], 39 | ['Pattern', $route->getPattern()], 40 | ['Pattern Regex', $route->compile()->getRegex()], 41 | ['Callback', $this->formatRouteCallback($route)], 42 | ['Requirements', ($route->getRequirements() ? $this->formatRouterConfig($route->getRequirements()) : 'NO CUSTOM')], 43 | ['Class', \get_class($route)], 44 | ['Defaults', $this->formatRouterConfig($route->getDefaults())], 45 | ['Options', $this->formatRouterConfig($route->getOptions())], 46 | ]; 47 | 48 | if (isset($options['output'])) { 49 | $options['output']->table($tableHeaders, $tableRows); 50 | } else { 51 | (new Table($this->getOutput())) 52 | ->setHeaders($tableHeaders) 53 | ->setRows($tableRows) 54 | ->render(); 55 | } 56 | } 57 | 58 | /** 59 | * @param callable $callable 60 | */ 61 | private function formatCallable($callable): string 62 | { 63 | if (\is_array($callable)) { 64 | if (\is_object($callable[0])) { 65 | return sprintf('%s::%s()', \get_class($callable[0]), $callable[1]); 66 | } 67 | 68 | return sprintf('%s::%s()', $callable[0], $callable[1]); 69 | } 70 | 71 | if (\is_string($callable)) { 72 | return sprintf('%s()', $callable); 73 | } 74 | 75 | if ($callable instanceof \Closure) { 76 | $r = new \ReflectionFunction($callable); 77 | 78 | if (false !== strpos($r->name, '{closure}')) { 79 | return 'Closure()'; 80 | } 81 | 82 | if ($class = $r->getClosureScopeClass()) { 83 | return sprintf('%s::%s()', $class->name, $r->name); 84 | } 85 | 86 | return $r->name.'()'; 87 | } 88 | 89 | if (\is_object($callable) && method_exists($callable, '__invoke')) { 90 | return sprintf('%s::__invoke()', \get_class($callable)); 91 | } 92 | 93 | throw new \InvalidArgumentException('Callable is not describable.'); 94 | } 95 | 96 | private function formatRouteCallback(Route $route): string 97 | { 98 | if (\is_array($route->getCallback())) { 99 | return implode(', ', $route->getCallback()); 100 | } 101 | 102 | if (\is_callable($route->getCallback())) { 103 | return $this->formatCallable($route->getCallback()); 104 | } 105 | 106 | return $route->getCallback(); 107 | } 108 | 109 | private function formatRouterConfig(array $config): string 110 | { 111 | if (empty($config)) { 112 | return 'NONE'; 113 | } 114 | 115 | ksort($config); 116 | 117 | $configAsString = ''; 118 | 119 | foreach ($config as $key => $value) { 120 | $configAsString .= sprintf("\n%s: %s", $key, $this->formatValue($value)); 121 | } 122 | 123 | return trim($configAsString); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Console/Descriptor/XmlDescriptor.php: -------------------------------------------------------------------------------- 1 | writeDocument($this->getRouteCollectionDocument($routes)); 17 | } 18 | 19 | protected function describeRoute(Route $route, array $options = []): void 20 | { 21 | $this->writeDocument($this->getRouteDocument($route, $options['name'] ?? null)); 22 | } 23 | 24 | private function getRouteCollectionDocument(RouteCollection $routes): \DOMDocument 25 | { 26 | $dom = new \DOMDocument('1.0', 'UTF-8'); 27 | $dom->appendChild($routesXML = $dom->createElement('routes')); 28 | 29 | foreach ($routes->all() as $name => $route) { 30 | $routeXML = $this->getRouteDocument($route, $name); 31 | $routesXML->appendChild($routesXML->ownerDocument->importNode($routeXML->childNodes->item(0), true)); 32 | } 33 | 34 | return $dom; 35 | } 36 | 37 | private function getRouteDocument(Route $route, string $name = null): \DOMDocument 38 | { 39 | $dom = new \DOMDocument('1.0', 'UTF-8'); 40 | $dom->appendChild($routeXML = $dom->createElement('route')); 41 | 42 | if ($name) { 43 | $routeXML->setAttribute('name', $name); 44 | } 45 | 46 | $routeXML->setAttribute('class', \get_class($route)); 47 | $routeXML->setAttribute('callback', $this->formatRouteCallback($route)); 48 | 49 | $routeXML->appendChild($patternXML = $dom->createElement('path')); 50 | $patternXML->setAttribute('regex', $route->compile()->getRegex()); 51 | $patternXML->appendChild(new \DOMText($route->getPattern())); 52 | 53 | if ($route->getDefaults()) { 54 | $routeXML->appendChild($defaultsXML = $dom->createElement('defaults')); 55 | 56 | foreach ($route->getDefaults() as $attribute => $value) { 57 | $defaultsXML->appendChild($defaultXML = $dom->createElement('default')); 58 | $defaultXML->setAttribute('key', $attribute); 59 | $defaultXML->appendChild(new \DOMText($this->formatValue($value))); 60 | } 61 | } 62 | 63 | if ($route->getRequirements()) { 64 | $routeXML->appendChild($requirementsXML = $dom->createElement('requirements')); 65 | 66 | foreach ($route->getRequirements() as $attribute => $pattern) { 67 | $requirementsXML->appendChild($requirementXML = $dom->createElement('requirement')); 68 | $requirementXML->setAttribute('key', $attribute); 69 | $requirementXML->appendChild(new \DOMText($pattern)); 70 | } 71 | } 72 | 73 | if ($route->getOptions()) { 74 | $routeXML->appendChild($optionsXML = $dom->createElement('options')); 75 | 76 | foreach ($route->getOptions() as $name => $value) { 77 | $optionsXML->appendChild($optionXML = $dom->createElement('option')); 78 | $optionXML->setAttribute('key', $name); 79 | $optionXML->appendChild(new \DOMText($this->formatValue($value))); 80 | } 81 | } 82 | 83 | return $dom; 84 | } 85 | 86 | /** 87 | * @param callable $callable 88 | */ 89 | private function formatCallable($callable): string 90 | { 91 | if (\is_array($callable)) { 92 | if (\is_object($callable[0])) { 93 | return sprintf('%s::%s()', \get_class($callable[0]), $callable[1]); 94 | } 95 | 96 | return sprintf('%s::%s()', $callable[0], $callable[1]); 97 | } 98 | 99 | if (\is_string($callable)) { 100 | return sprintf('%s()', $callable); 101 | } 102 | 103 | if ($callable instanceof \Closure) { 104 | $r = new \ReflectionFunction($callable); 105 | 106 | if (false !== strpos($r->name, '{closure}')) { 107 | return 'Closure()'; 108 | } 109 | 110 | if ($class = $r->getClosureScopeClass()) { 111 | return sprintf('%s::%s()', $class->name, $r->name); 112 | } 113 | 114 | return $r->name.'()'; 115 | } 116 | 117 | if (\is_object($callable) && method_exists($callable, '__invoke')) { 118 | return sprintf('%s::__invoke()', \get_class($callable)); 119 | } 120 | 121 | throw new \InvalidArgumentException('Callable is not describable.'); 122 | } 123 | 124 | private function formatRouteCallback(Route $route): string 125 | { 126 | if (\is_array($route->getCallback())) { 127 | return implode(', ', $route->getCallback()); 128 | } 129 | 130 | if (\is_callable($route->getCallback())) { 131 | return $this->formatCallable($route->getCallback()); 132 | } 133 | 134 | return $route->getCallback(); 135 | } 136 | 137 | private function writeDocument(\DOMDocument $dom): void 138 | { 139 | $dom->formatOutput = true; 140 | $this->write($dom->saveXML()); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Console/Helper/DescriptorHelper.php: -------------------------------------------------------------------------------- 1 | register('json', new JsonDescriptor()) 20 | ->register('md', new MarkdownDescriptor()) 21 | ->register('txt', new TextDescriptor()) 22 | ->register('xml', new XmlDescriptor()) 23 | ; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/DependencyInjection/CompilerPass/RoutingResolverPass.php: -------------------------------------------------------------------------------- 1 | resolverServiceId = $resolverServiceId; 32 | $this->loaderTag = $loaderTag; 33 | } 34 | 35 | public function process(ContainerBuilder $container): void 36 | { 37 | if (false === $container->hasDefinition($this->resolverServiceId)) { 38 | return; 39 | } 40 | 41 | $definition = $container->getDefinition($this->resolverServiceId); 42 | 43 | foreach ($this->findAndSortTaggedServices($this->loaderTag, $container) as $id) { 44 | $definition->addMethodCall('addLoader', [new Reference($id)]); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 13 | * @final 14 | */ 15 | class Configuration implements ConfigurationInterface 16 | { 17 | public function getConfigTreeBuilder(): TreeBuilder 18 | { 19 | $treeBuilder = new TreeBuilder('gos_pubsub_router'); 20 | 21 | $rootNode = $treeBuilder->getRootNode(); 22 | 23 | $rootNode 24 | ->addDefaultsIfNotSet() 25 | ->children() 26 | ->scalarNode('matcher_class')->defaultValue(CompiledMatcher::class)->end() 27 | ->scalarNode('generator_class')->defaultValue(CompiledGenerator::class)->end() 28 | ->scalarNode('router_class')->defaultValue(Router::class)->end() 29 | ->arrayNode('routers') 30 | ->useAttributeAsKey('name') 31 | ->requiresAtLeastOneElement() 32 | ->prototype('array') 33 | ->children() 34 | ->arrayNode('resources') 35 | ->beforeNormalization() 36 | ->ifTrue(static function ($v) { 37 | foreach ($v as $resource) { 38 | if (!\is_array($resource)) { 39 | return true; 40 | } 41 | } 42 | 43 | return false; 44 | }) 45 | ->then(static function ($v) { 46 | $resources = []; 47 | 48 | foreach ($v as $resource) { 49 | if (\is_array($resource)) { 50 | $resources[] = $resource; 51 | } else { 52 | $resources[] = [ 53 | 'resource' => $resource, 54 | ]; 55 | } 56 | } 57 | 58 | return $resources; 59 | }) 60 | ->end() 61 | ->prototype('array') 62 | ->children() 63 | ->scalarNode('resource') 64 | ->cannotBeEmpty() 65 | ->isRequired() 66 | ->end() 67 | ->enumNode('type') 68 | ->values(['closure', 'container', 'glob', 'php', 'xml', 'yaml', null]) 69 | ->defaultNull() 70 | ->end() 71 | ->end() 72 | ->end() 73 | ->end() 74 | ->end() 75 | ->end() 76 | ->end() 77 | ->end() 78 | ->end(); 79 | 80 | return $treeBuilder; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/DependencyInjection/GosPubSubRouterExtension.php: -------------------------------------------------------------------------------- 1 | 18 | * @final 19 | */ 20 | class GosPubSubRouterExtension extends ConfigurableExtension 21 | { 22 | /** 23 | * @throws InvalidArgumentException if a configured router uses a reserved name 24 | */ 25 | protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void 26 | { 27 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../../config')); 28 | $loader->load('services.yaml'); 29 | 30 | $container->registerForAutoconfiguration(RouteLoaderInterface::class) 31 | ->addTag('gos_pubsub_router.routing.route_loader'); 32 | 33 | $routerOptions = [ 34 | 'cache_dir' => $container->getParameter('kernel.cache_dir'), 35 | 'debug' => $container->getParameter('kernel.debug'), 36 | 'generator_class' => $mergedConfig['generator_class'], 37 | 'generator_dumper_class' => CompiledGeneratorDumper::class, 38 | 'matcher_class' => $mergedConfig['matcher_class'], 39 | 'matcher_dumper_class' => CompiledMatcherDumper::class, 40 | ]; 41 | 42 | $registryDefinition = $container->getDefinition('gos_pubsub_router.router_registry'); 43 | 44 | foreach ($mergedConfig['routers'] as $routerName => $routerConfig) { 45 | $lowerRouterName = strtolower($routerName); 46 | 47 | $serviceId = 'gos_pubsub_router.router.'.$lowerRouterName; 48 | 49 | $definition = new Definition( 50 | $mergedConfig['router_class'], 51 | [ 52 | $lowerRouterName, 53 | new Reference('gos_pubsub_router.routing.loader'), 54 | $routerConfig['resources'], 55 | $routerOptions, 56 | ] 57 | ); 58 | $definition->addMethodCall('setConfigCacheFactory', [new Reference('config_cache_factory')]); 59 | 60 | // Register router to the container 61 | $container->setDefinition($serviceId, $definition); 62 | 63 | // Register router to the registry 64 | $registryDefinition->addMethodCall('addRouter', [new Reference($serviceId)]); 65 | } 66 | } 67 | 68 | public function getAlias(): string 69 | { 70 | return 'gos_pubsub_router'; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private $compiledRoutes; 13 | 14 | /** 15 | * @param array $compiledRoutes 16 | */ 17 | public function __construct(array $compiledRoutes) 18 | { 19 | $this->compiledRoutes = $compiledRoutes; 20 | } 21 | 22 | /** 23 | * @throws ResourceNotFoundException if the given route name does not exist 24 | */ 25 | public function generate(string $routeName, array $parameters = []): string 26 | { 27 | if (!isset($this->compiledRoutes[$routeName])) { 28 | throw new ResourceNotFoundException(sprintf('Unable to generate a path for the named route "%s" as such route does not exist.', $routeName)); 29 | } 30 | 31 | [$variables, $defaults, $requirements, $tokens] = $this->compiledRoutes[$routeName]; 32 | 33 | return $this->doGenerate($variables, $defaults, $requirements, $tokens, $parameters, $routeName); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Generator/Dumper/CompiledGeneratorDumper.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function getCompiledRoutes(): array 13 | { 14 | $compiledRoutes = []; 15 | 16 | foreach ($this->getRoutes()->all() as $name => $route) { 17 | $compiledRoute = $route->compile(); 18 | 19 | $compiledRoutes[$name] = [ 20 | $compiledRoute->getVariables(), 21 | $route->getDefaults(), 22 | $route->getRequirements(), 23 | $compiledRoute->getTokens(), 24 | ]; 25 | } 26 | 27 | return $compiledRoutes; 28 | } 29 | 30 | public function dump(array $options = []): string 31 | { 32 | return <<generateDeclaredRoutes()} 36 | ]; 37 | EOF; 38 | } 39 | 40 | /** 41 | * Generates PHP code representing an array of defined routes together with the routes properties (e.g. requirements). 42 | */ 43 | private function generateDeclaredRoutes(): string 44 | { 45 | $routes = ''; 46 | 47 | foreach ($this->getCompiledRoutes() as $name => $properties) { 48 | $routes .= sprintf("\n '%s' => %s,", $name, CompiledMatcherDumper::export($properties)); 49 | } 50 | 51 | return $routes; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Generator/Dumper/GeneratorDumper.php: -------------------------------------------------------------------------------- 1 | routes = $routes; 17 | } 18 | 19 | public function getRoutes(): RouteCollection 20 | { 21 | return $this->routes; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Generator/Dumper/GeneratorDumperInterface.php: -------------------------------------------------------------------------------- 1 | 'ProjectGenerator', 28 | 'base_class' => Generator::class, 29 | ], 30 | $options 31 | ); 32 | 33 | return <<generateDeclaredRoutes()}; 50 | } 51 | } 52 | 53 | {$this->generateGenerateMethod()} 54 | } 55 | 56 | EOF; 57 | } 58 | 59 | private function generateDeclaredRoutes(): string 60 | { 61 | $routes = "array(\n"; 62 | foreach ($this->getRoutes()->all() as $name => $route) { 63 | $compiledRoute = $route->compile(); 64 | 65 | $properties = []; 66 | $properties[] = $compiledRoute->getVariables(); 67 | $properties[] = $route->getDefaults(); 68 | $properties[] = $route->getRequirements(); 69 | $properties[] = $compiledRoute->getTokens(); 70 | 71 | $routes .= sprintf(" '%s' => %s,\n", $name, PhpMatcherDumper::export($properties)); 72 | } 73 | $routes .= ' )'; 74 | 75 | return $routes; 76 | } 77 | 78 | /** 79 | * Generates PHP code representing the `generate` method that implements the GeneratorInterface. 80 | */ 81 | private function generateGenerateMethod(): string 82 | { 83 | return <<<'EOF' 84 | public function generate(string $name, array $parameters = []): string 85 | { 86 | if (!isset(self::$declaredRoutes[$name])) { 87 | throw new ResourceNotFoundException(sprintf('Unable to generate a path for the named route "%s" as such route does not exist.', $name)); 88 | } 89 | 90 | list($variables, $defaults, $requirements, $tokens) = self::$declaredRoutes[$name]; 91 | 92 | return $this->doGenerate($variables, $defaults, $requirements, $tokens, $parameters, $name); 93 | } 94 | EOF; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Generator/Generator.php: -------------------------------------------------------------------------------- 1 | routes = $routes; 20 | } 21 | 22 | /** 23 | * @throws ResourceNotFoundException if the given route name does not exist 24 | */ 25 | public function generate(string $routeName, array $parameters = []): string 26 | { 27 | if (null === $route = $this->routes->get($routeName)) { 28 | throw new ResourceNotFoundException(sprintf('Unable to generate a path for the named route "%s" as such route does not exist.', $routeName)); 29 | } 30 | 31 | // the Route has a cache of its own and is not recompiled as long as it does not get modified 32 | $compiledRoute = $route->compile(); 33 | 34 | return $this->doGenerate($compiledRoute->getVariables(), $route->getDefaults(), $route->getRequirements(), $compiledRoute->getTokens(), $parameters, $routeName); 35 | } 36 | 37 | /** 38 | * @throws MissingMandatoryParametersException when some parameters are missing that are mandatory for the route 39 | * @throws InvalidParameterException when a parameter value for a placeholder is not correct because it does not match the requirement 40 | */ 41 | protected function doGenerate(array $variables, array $defaults, array $requirements, array $tokens, array $parameters, string $routeName): string 42 | { 43 | $variables = array_flip($variables); 44 | $mergedParams = array_replace($defaults, $parameters); 45 | 46 | // all params must be given 47 | if ($diff = array_diff_key($variables, $mergedParams)) { 48 | throw new MissingMandatoryParametersException(sprintf('Some mandatory parameters are missing ("%s") to generate a path for route "%s".', implode('", "', array_keys($diff)), $routeName)); 49 | } 50 | 51 | $url = ''; 52 | $optional = true; 53 | $message = 'Parameter "{parameter}" for route "{route}" must match "{expected}" ("{given}" given) to generate a corresponding path.'; 54 | 55 | foreach ($tokens as $token) { 56 | if ('variable' === $token[0]) { 57 | $varName = $token[3]; 58 | // variable is not important by default 59 | $important = $token[5] ?? false; 60 | 61 | if (!$optional || $important || !\array_key_exists($varName, $defaults) || (null !== $mergedParams[$varName] && (string) $mergedParams[$varName] !== (string) $defaults[$varName])) { 62 | // check requirement 63 | if (!preg_match('#^'.preg_replace('/\(\?(?:=|<=|!| $varName, '{route}' => $routeName, '{expected}' => $token[2], '{given}' => $mergedParams[$varName]])); 65 | } 66 | 67 | $url = $token[1].$mergedParams[$varName].$url; 68 | $optional = false; 69 | } 70 | } else { 71 | // static text 72 | $url = $token[1].$url; 73 | $optional = false; 74 | } 75 | } 76 | 77 | return $url; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Generator/GeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 13 | * @final 14 | */ 15 | class GosPubSubRouterBundle extends Bundle 16 | { 17 | public function build(ContainerBuilder $container): void 18 | { 19 | parent::build($container); 20 | 21 | $container->addCompilerPass(new RoutingResolverPass()); 22 | } 23 | 24 | public function getContainerExtension(): ?ExtensionInterface 25 | { 26 | if (null === $this->extension) { 27 | $this->extension = new GosPubSubRouterExtension(); 28 | } 29 | 30 | return parent::getContainerExtension(); 31 | } 32 | 33 | public function getPath(): string 34 | { 35 | return \dirname(__DIR__); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Loader/ClosureLoader.php: -------------------------------------------------------------------------------- 1 | doLoad($resource, $type); 25 | } 26 | 27 | /** 28 | * @param mixed $resource 29 | */ 30 | abstract protected function doLoad($resource, string $type = null): RouteCollection; 31 | 32 | /** 33 | * @param mixed $resource 34 | * @param string|null $type 35 | */ 36 | public function supports($resource, $type = null): bool 37 | { 38 | return $this->doSupports($resource, $type); 39 | } 40 | 41 | /** 42 | * @param mixed $resource 43 | */ 44 | abstract protected function doSupports($resource, string $type = null): bool; 45 | } 46 | } else { 47 | /** 48 | * Compatibility file loader for Symfony 5.0 and later. 49 | * 50 | * @internal To be removed when dropping support for Symfony 4.4 and earlier 51 | */ 52 | abstract class CompatibilityFileLoader extends FileLoader 53 | { 54 | /** 55 | * @param mixed $resource 56 | */ 57 | public function load($resource, string $type = null): RouteCollection 58 | { 59 | return $this->doLoad($resource, $type); 60 | } 61 | 62 | /** 63 | * @param mixed $resource 64 | */ 65 | abstract protected function doLoad($resource, string $type = null): RouteCollection; 66 | 67 | /** 68 | * @param mixed $resource 69 | */ 70 | public function supports($resource, string $type = null): bool 71 | { 72 | return $this->doSupports($resource, $type); 73 | } 74 | 75 | /** 76 | * @param mixed $resource 77 | */ 78 | abstract protected function doSupports($resource, string $type = null): bool; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Loader/CompatibilityLoader.php: -------------------------------------------------------------------------------- 1 | doLoad($resource, $type); 25 | } 26 | 27 | /** 28 | * @param mixed $resource 29 | */ 30 | abstract protected function doLoad($resource, string $type = null): RouteCollection; 31 | 32 | /** 33 | * @param mixed $resource 34 | * @param string|null $type 35 | */ 36 | public function supports($resource, $type = null): bool 37 | { 38 | return $this->doSupports($resource, $type); 39 | } 40 | 41 | /** 42 | * @param mixed $resource 43 | */ 44 | abstract protected function doSupports($resource, string $type = null): bool; 45 | } 46 | } else { 47 | /** 48 | * Compatibility file loader for Symfony 5.0 and later. 49 | * 50 | * @internal To be removed when dropping support for Symfony 4.4 and earlier 51 | */ 52 | abstract class CompatibilityLoader extends Loader 53 | { 54 | /** 55 | * @param mixed $resource 56 | */ 57 | public function load($resource, string $type = null): RouteCollection 58 | { 59 | return $this->doLoad($resource, $type); 60 | } 61 | 62 | /** 63 | * @param mixed $resource 64 | */ 65 | abstract protected function doLoad($resource, string $type = null): RouteCollection; 66 | 67 | /** 68 | * @param mixed $resource 69 | */ 70 | public function supports($resource, string $type = null): bool 71 | { 72 | return $this->doSupports($resource, $type); 73 | } 74 | 75 | /** 76 | * @param mixed $resource 77 | */ 78 | abstract protected function doSupports($resource, string $type = null): bool; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Loader/Configurator/CollectionConfigurator.php: -------------------------------------------------------------------------------- 1 | parent = $parent; 26 | $this->name = $name; 27 | $this->collection = new RouteCollection(); 28 | $this->route = new Route('', 'strlen'); 29 | $this->parentConfigurator = $parentConfigurator; // for GC control 30 | } 31 | 32 | public function __sleep() 33 | { 34 | throw new \BadMethodCallException('Cannot serialize '.self::class); 35 | } 36 | 37 | public function __wakeup() 38 | { 39 | throw new \BadMethodCallException('Cannot unserialize '.self::class); 40 | } 41 | 42 | public function __destruct() 43 | { 44 | $this->parent->addCollection($this->collection); 45 | } 46 | 47 | /** 48 | * Creates a sub-collection. 49 | */ 50 | public function collection(string $name = ''): self 51 | { 52 | return new self($this->collection, $name, $this); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Loader/Configurator/ImportConfigurator.php: -------------------------------------------------------------------------------- 1 | parent = $parent; 19 | $this->route = $route; 20 | } 21 | 22 | public function __sleep() 23 | { 24 | throw new \BadMethodCallException('Cannot serialize '.self::class); 25 | } 26 | 27 | public function __wakeup() 28 | { 29 | throw new \BadMethodCallException('Cannot unserialize '.self::class); 30 | } 31 | 32 | public function __destruct() 33 | { 34 | $this->parent->addCollection($this->route); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Loader/Configurator/RouteConfigurator.php: -------------------------------------------------------------------------------- 1 | collection = $collection; 24 | $this->route = $route; 25 | $this->name = $name; 26 | $this->parentConfigurator = $parentConfigurator; // for GC control 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Loader/Configurator/RoutingConfigurator.php: -------------------------------------------------------------------------------- 1 | collection = $collection; 30 | $this->loader = $loader; 31 | $this->path = $path; 32 | $this->file = $file; 33 | } 34 | 35 | /** 36 | * @param mixed $resource 37 | * @param string|string[]|null $exclude Glob patterns to exclude from the import 38 | */ 39 | public function import($resource, string $type = null, bool $ignoreErrors = false, $exclude = null): ImportConfigurator 40 | { 41 | $this->loader->setCurrentDir(\dirname($this->path)); 42 | 43 | $imported = $this->loader->import($resource, $type, $ignoreErrors, $this->file, $exclude) ?: []; 44 | 45 | if (!\is_array($imported)) { 46 | return new ImportConfigurator($this->collection, $imported); 47 | } 48 | 49 | $mergedCollection = new RouteCollection(); 50 | 51 | foreach ($imported as $subCollection) { 52 | $mergedCollection->addCollection($subCollection); 53 | } 54 | 55 | return new ImportConfigurator($this->collection, $mergedCollection); 56 | } 57 | 58 | public function collection(string $name = ''): CollectionConfigurator 59 | { 60 | return new CollectionConfigurator($this->collection, $name); 61 | } 62 | 63 | public function withPath(string $path): self 64 | { 65 | $clone = clone $this; 66 | $clone->path = $clone->file = $path; 67 | 68 | return $clone; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Loader/Configurator/Traits/AddTrait.php: -------------------------------------------------------------------------------- 1 | add($name, $pattern, $callback); 30 | } 31 | 32 | /** 33 | * Adds a route. 34 | * 35 | * @param callable|string $callback A callable function that handles this route or a string to be used with a service locator 36 | */ 37 | public function add(string $name, string $pattern, $callback): RouteConfigurator 38 | { 39 | $parentConfigurator = $this instanceof CollectionConfigurator ? $this : ($this instanceof RouteConfigurator ? $this->parentConfigurator : null); 40 | $route = $this->createRoute($this->collection, $name, $pattern, $callback); 41 | 42 | return new RouteConfigurator($this->collection, $route, $this->name, $parentConfigurator); 43 | } 44 | 45 | /** 46 | * Creates a routes. 47 | * 48 | * @param callable|string $callback A callable function that handles this route or a string to be used with a service locator 49 | */ 50 | final protected function createRoute(RouteCollection $collection, string $name, string $pattern, $callback): RouteCollection 51 | { 52 | $routes = new RouteCollection(); 53 | $routes->add($name, $route = new Route($pattern, $callback)); 54 | $collection->add($name, $route); 55 | 56 | return $routes; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Loader/Configurator/Traits/RouteTrait.php: -------------------------------------------------------------------------------- 1 | route->addDefaults($defaults); 23 | 24 | return $this; 25 | } 26 | 27 | /** 28 | * Adds requirements. 29 | * 30 | * @return $this 31 | */ 32 | final public function requirements(array $requirements): self 33 | { 34 | $this->route->addRequirements($requirements); 35 | 36 | return $this; 37 | } 38 | 39 | /** 40 | * Adds options. 41 | * 42 | * @return $this 43 | */ 44 | final public function options(array $options): self 45 | { 46 | $this->route->addOptions($options); 47 | 48 | return $this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Loader/ContainerLoader.php: -------------------------------------------------------------------------------- 1 | container = $container; 17 | } 18 | 19 | /** 20 | * @param mixed $resource 21 | */ 22 | protected function doSupports($resource, string $type = null): bool 23 | { 24 | return 'service' === $type; 25 | } 26 | 27 | protected function getObject(string $id): object 28 | { 29 | return $this->container->get($id); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Loader/GlobFileLoader.php: -------------------------------------------------------------------------------- 1 | glob($resource, false, $globResource) as $path => $info) { 17 | $collection->addCollection($this->import($path)); 18 | } 19 | 20 | $collection->addResource($globResource); 21 | 22 | return $collection; 23 | } 24 | 25 | /** 26 | * @param mixed $resource 27 | */ 28 | protected function doSupports($resource, string $type = null): bool 29 | { 30 | return 'glob' === $type; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Loader/ObjectLoader.php: -------------------------------------------------------------------------------- 1 | getObject($parts[0]); 29 | 30 | if (!\is_callable([$loaderObject, $method])) { 31 | throw new \BadMethodCallException(sprintf('Method "%s" not found on "%s" when importing routing resource "%s"', $method, \get_class($loaderObject), $resource)); 32 | } 33 | 34 | $routeCollection = $loaderObject->$method($this); 35 | 36 | if (!$routeCollection instanceof RouteCollection) { 37 | $type = \is_object($routeCollection) ? \get_class($routeCollection) : \gettype($routeCollection); 38 | 39 | throw new \LogicException(sprintf('The %s::%s method must return a RouteCollection: %s returned', \get_class($loaderObject), $method, $type)); 40 | } 41 | 42 | $this->addClassResource(new \ReflectionClass($loaderObject), $routeCollection); 43 | 44 | return $routeCollection; 45 | } 46 | 47 | /** 48 | * @param \ReflectionClass $class 49 | */ 50 | private function addClassResource(\ReflectionClass $class, RouteCollection $collection): void 51 | { 52 | do { 53 | if (false !== $class->getFileName() && is_file($class->getFileName())) { 54 | $collection->addResource(new FileResource($class->getFileName())); 55 | } 56 | } while ($class = $class->getParentClass()); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Loader/PhpFileLoader.php: -------------------------------------------------------------------------------- 1 | locator->locate($resource); 22 | $this->setCurrentDir(\dirname($path)); 23 | 24 | // The closure forbids access to the private scope in the included file 25 | $loader = $this; 26 | $load = \Closure::bind(static function ($file) use ($loader) { 27 | return include $file; 28 | }, null, $this->createProtectedLoader()); 29 | 30 | $result = $load($path); 31 | 32 | if (\is_callable($result)) { 33 | $collection = $this->callConfigurator($result, $path, $resource); 34 | } elseif ($result instanceof RouteCollection) { 35 | $collection = $result; 36 | } else { 37 | throw new \LogicException(sprintf('The %s file must return a callback or a RouteCollection: %s returned', $path, get_debug_type($result))); 38 | } 39 | 40 | $collection->addResource(new FileResource($path)); 41 | 42 | return $collection; 43 | } 44 | 45 | /** 46 | * @param mixed $resource 47 | */ 48 | protected function doSupports($resource, string $type = null): bool 49 | { 50 | return \is_string($resource) && 'php' === pathinfo($resource, PATHINFO_EXTENSION) && (!$type || 'php' === $type); 51 | } 52 | 53 | private function callConfigurator(callable $result, string $path, string $file): RouteCollection 54 | { 55 | $collection = new RouteCollection(); 56 | 57 | $result(new RoutingConfigurator($collection, $this, $path, $file)); 58 | 59 | return $collection; 60 | } 61 | 62 | private function createProtectedLoader(): self 63 | { 64 | return new class($this->getLocator()) extends PhpFileLoader {}; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Loader/RouteLoaderInterface.php: -------------------------------------------------------------------------------- 1 | locator->locate($resource); 21 | 22 | $xml = $this->loadFile($path); 23 | 24 | $collection = new RouteCollection(); 25 | $collection->addResource(new FileResource($path)); 26 | 27 | // process routes and imports 28 | foreach ($xml->documentElement->childNodes as $node) { 29 | if (!$node instanceof \DOMElement) { 30 | continue; 31 | } 32 | 33 | $this->parseNode($collection, $node, $path, $resource); 34 | } 35 | 36 | return $collection; 37 | } 38 | 39 | /** 40 | * @throws \InvalidArgumentException When the XML is invalid 41 | */ 42 | private function parseNode(RouteCollection $collection, \DOMElement $node, string $path, string $file): void 43 | { 44 | if (self::NAMESPACE_URI !== $node->namespaceURI) { 45 | return; 46 | } 47 | 48 | switch ($node->localName) { 49 | case 'route': 50 | $this->parseRoute($collection, $node, $path); 51 | break; 52 | 53 | case 'import': 54 | $this->parseImport($collection, $node, $path, $file); 55 | break; 56 | 57 | default: 58 | throw new \InvalidArgumentException(sprintf('Unknown tag "%s" used in file "%s". Expected "route" or "import".', $node->localName, $path)); 59 | } 60 | } 61 | 62 | /** 63 | * @param mixed $resource 64 | */ 65 | protected function doSupports($resource, string $type = null): bool 66 | { 67 | return \is_string($resource) && 'xml' === pathinfo($resource, PATHINFO_EXTENSION) && (!$type || 'xml' === $type); 68 | } 69 | 70 | /** 71 | * @throws \InvalidArgumentException When the XML is invalid 72 | */ 73 | private function parseRoute(RouteCollection $collection, \DOMElement $node, string $filepath): void 74 | { 75 | if ('' === $id = $node->getAttribute('id')) { 76 | throw new \InvalidArgumentException(sprintf('The element in file "%s" must have an "id" attribute.', $filepath)); 77 | } 78 | 79 | if ($node->hasAttribute('pattern') && $node->hasAttribute('channel')) { 80 | throw new \InvalidArgumentException(sprintf('The routing file "%s" requires that both the "pattern" attribute and the "channel" attribute cannot be set for route ID "%s".', $filepath, $id)); 81 | } elseif ($node->hasAttribute('channel')) { 82 | trigger_deprecation('gos/pubsub-router-bundle', '2.4', 'The routing file "%s" uses the deprecated "channel" attribute for route ID "%s" and will not be supported in 3.0, use the "pattern" key instead.', $filepath, $id); 83 | } elseif (!$node->hasAttribute('pattern')) { 84 | throw new \InvalidArgumentException(sprintf('The routing file "%s" requires the "pattern" attribute for route ID "%s".', $filepath, $id)); 85 | } 86 | 87 | if ($node->hasAttribute('pattern')) { 88 | $pattern = $node->getAttribute('pattern'); 89 | } else { 90 | $pattern = $node->getAttribute('channel'); 91 | } 92 | 93 | if (!$node->hasAttribute('callback')) { 94 | throw new \InvalidArgumentException(sprintf('The routing file "%s" requires the "callback" attribute for route ID "%s".', $filepath, $id)); 95 | } 96 | 97 | $callback = $node->getAttribute('callback'); 98 | 99 | [$defaults, $requirements, $options] = $this->parseConfigs($node, $filepath); 100 | 101 | $route = new Route($pattern, $callback); 102 | $route->addDefaults($defaults); 103 | $route->addRequirements($requirements); 104 | $route->addOptions($options); 105 | 106 | $collection->add($id, $route); 107 | } 108 | 109 | /** 110 | * @throws \InvalidArgumentException When the XML is invalid 111 | */ 112 | private function parseImport(RouteCollection $collection, \DOMElement $node, string $path, string $file): void 113 | { 114 | if ('' === $resource = $node->getAttribute('resource')) { 115 | throw new \InvalidArgumentException(sprintf('The element in file "%s" must have a "resource" attribute.', $path)); 116 | } 117 | 118 | $type = $node->getAttribute('type'); 119 | 120 | [$defaults, $requirements, $options] = $this->parseConfigs($node, $path); 121 | 122 | $exclude = []; 123 | 124 | foreach ($node->childNodes as $child) { 125 | if ($child instanceof \DOMElement && $child->localName === $exclude && self::NAMESPACE_URI === $child->namespaceURI) { 126 | $exclude[] = $child->nodeValue; 127 | } 128 | } 129 | 130 | if ($node->hasAttribute('exclude')) { 131 | if ($exclude) { 132 | throw new \InvalidArgumentException('You cannot use both the attribute "exclude" and tags at the same time.'); 133 | } 134 | 135 | $exclude = [$node->getAttribute('exclude')]; 136 | } 137 | 138 | $this->setCurrentDir(\dirname($path)); 139 | 140 | /** @var RouteCollection[] $imported */ 141 | $imported = $this->import($resource, ('' !== $type ? $type : null), false, $file, $exclude) ?: []; 142 | 143 | if (!\is_array($imported)) { 144 | $imported = [$imported]; 145 | } 146 | 147 | foreach ($imported as $subCollection) { 148 | $subCollection->addDefaults($defaults); 149 | $subCollection->addRequirements($requirements); 150 | $subCollection->addOptions($options); 151 | 152 | $collection->addCollection($subCollection); 153 | } 154 | } 155 | 156 | /** 157 | * @throws \InvalidArgumentException When loading of XML file fails because of syntax errors or when the XML structure is not as expected by the scheme 158 | */ 159 | private function loadFile(string $file): \DOMDocument 160 | { 161 | return XmlUtils::loadFile($file, __DIR__.static::SCHEME_PATH); 162 | } 163 | 164 | /** 165 | * Parses the config elements (default, requirement, option). 166 | * 167 | * @throws \InvalidArgumentException When the XML is invalid 168 | */ 169 | private function parseConfigs(\DOMElement $node, string $path): array 170 | { 171 | $defaults = []; 172 | $requirements = []; 173 | $options = []; 174 | 175 | /** @var \DOMElement $n */ 176 | foreach ($node->getElementsByTagNameNS(self::NAMESPACE_URI, '*') as $n) { 177 | if ($node !== $n->parentNode) { 178 | continue; 179 | } 180 | 181 | switch ($n->localName) { 182 | case 'default': 183 | if ($this->isElementValueNull($n)) { 184 | $defaults[$n->getAttribute('key')] = null; 185 | } else { 186 | $defaults[$n->getAttribute('key')] = $this->parseDefaultsConfig($n, $path); 187 | } 188 | 189 | break; 190 | 191 | case 'requirement': 192 | $requirements[$n->getAttribute('key')] = trim($n->textContent); 193 | break; 194 | 195 | case 'option': 196 | $options[$n->getAttribute('key')] = XmlUtils::phpize(trim($n->textContent)); 197 | break; 198 | 199 | default: 200 | throw new \InvalidArgumentException(sprintf('Unknown tag "%s" used in file "%s". Expected "default", "requirement", "option".', $n->localName, $path)); 201 | } 202 | } 203 | 204 | return [$defaults, $requirements, $options]; 205 | } 206 | 207 | /** 208 | * Parses the "default" elements. 209 | * 210 | * @return array|bool|float|int|string|null The parsed value of the "default" element 211 | */ 212 | private function parseDefaultsConfig(\DOMElement $element, string $path) 213 | { 214 | if ($this->isElementValueNull($element)) { 215 | return null; 216 | } 217 | 218 | // Check for existing element nodes in the default element. There can 219 | // only be a single element inside a default element. So this element 220 | // (if one was found) can safely be returned. 221 | foreach ($element->childNodes as $child) { 222 | if (!$child instanceof \DOMElement) { 223 | continue; 224 | } 225 | 226 | if (self::NAMESPACE_URI !== $child->namespaceURI) { 227 | continue; 228 | } 229 | 230 | return $this->parseDefaultNode($child, $path); 231 | } 232 | 233 | // If the default element doesn't contain a nested "bool", "int", "float", 234 | // "string", "list", or "map" element, the element contents will be treated 235 | // as the string value of the associated default option. 236 | return trim($element->textContent); 237 | } 238 | 239 | /** 240 | * Recursively parses the value of a "default" element. 241 | * 242 | * @return array|bool|float|int|string|null The parsed value 243 | * 244 | * @throws \InvalidArgumentException when the XML is invalid 245 | */ 246 | private function parseDefaultNode(\DOMElement $node, string $path) 247 | { 248 | if ($this->isElementValueNull($node)) { 249 | return null; 250 | } 251 | 252 | switch ($node->localName) { 253 | case 'bool': 254 | return 'true' === trim($node->nodeValue) || '1' === trim($node->nodeValue); 255 | case 'int': 256 | return (int) trim($node->nodeValue); 257 | case 'float': 258 | return (float) trim($node->nodeValue); 259 | case 'string': 260 | return trim($node->nodeValue); 261 | case 'list': 262 | $list = []; 263 | 264 | foreach ($node->childNodes as $element) { 265 | if (!$element instanceof \DOMElement) { 266 | continue; 267 | } 268 | 269 | if (self::NAMESPACE_URI !== $element->namespaceURI) { 270 | continue; 271 | } 272 | 273 | $list[] = $this->parseDefaultNode($element, $path); 274 | } 275 | 276 | return $list; 277 | case 'map': 278 | $map = []; 279 | 280 | foreach ($node->childNodes as $element) { 281 | if (!$element instanceof \DOMElement) { 282 | continue; 283 | } 284 | 285 | if (self::NAMESPACE_URI !== $element->namespaceURI) { 286 | continue; 287 | } 288 | 289 | $map[$element->getAttribute('key')] = $this->parseDefaultNode($element, $path); 290 | } 291 | 292 | return $map; 293 | default: 294 | throw new \InvalidArgumentException(sprintf('Unknown tag "%s" used in file "%s". Expected "bool", "int", "float", "string", "list", or "map".', $node->localName, $path)); 295 | } 296 | } 297 | 298 | private function isElementValueNull(\DOMElement $element): bool 299 | { 300 | $namespaceUri = 'http://www.w3.org/2001/XMLSchema-instance'; 301 | 302 | if (!$element->hasAttributeNS($namespaceUri, 'nil')) { 303 | return false; 304 | } 305 | 306 | return 'true' === $element->getAttributeNS($namespaceUri, 'nil') || '1' === $element->getAttributeNS($namespaceUri, 'nil'); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/Loader/YamlFileLoader.php: -------------------------------------------------------------------------------- 1 | 14 | * @final 15 | */ 16 | class YamlFileLoader extends CompatibilityFileLoader 17 | { 18 | private const AVAILABLE_KEYS = [ 19 | 'resource', 20 | 'type', 21 | 'channel', 22 | 'pattern', 23 | 'handler', 24 | 'callback', 25 | 'defaults', 26 | 'requirements', 27 | 'options', 28 | 'exclude', 29 | ]; 30 | 31 | /** 32 | * @var YamlParser 33 | */ 34 | private $yamlParser; 35 | 36 | /** 37 | * @param mixed $resource 38 | * 39 | * @throws \InvalidArgumentException if the resource cannot be processed 40 | */ 41 | protected function doLoad($resource, string $type = null): RouteCollection 42 | { 43 | $path = $this->locator->locate($resource); 44 | 45 | if (!stream_is_local($path)) { 46 | throw new \InvalidArgumentException(sprintf('This is not a local file "%s".', $path)); 47 | } 48 | 49 | if (!file_exists($path)) { 50 | throw new \InvalidArgumentException(sprintf('File "%s" not found.', $path)); 51 | } 52 | 53 | if (null === $this->yamlParser) { 54 | $this->yamlParser = new YamlParser(); 55 | } 56 | 57 | try { 58 | $config = $this->yamlParser->parseFile($path, Yaml::PARSE_CONSTANT); 59 | } catch (ParseException $e) { 60 | throw new \InvalidArgumentException(sprintf('The file "%s" does not contain valid YAML: %s', $path, $e->getMessage()), 0, $e); 61 | } 62 | 63 | $routeCollection = new RouteCollection(); 64 | $routeCollection->addResource(new FileResource($path)); 65 | 66 | // empty file 67 | if (null === $config) { 68 | return $routeCollection; 69 | } 70 | 71 | // not an array 72 | if (!\is_array($config)) { 73 | throw new \InvalidArgumentException(sprintf('The file "%s" must contain a YAML array.', $path)); 74 | } 75 | 76 | foreach ($config as $routeName => $routeConfig) { 77 | $this->validate($routeConfig, $routeName, $path); 78 | 79 | if (isset($routeConfig['resource'])) { 80 | $this->parseImport($routeCollection, $routeConfig, $path, $resource); 81 | } else { 82 | $this->parseRoute($routeCollection, $routeName, $routeConfig, $path); 83 | } 84 | } 85 | 86 | return $routeCollection; 87 | } 88 | 89 | /** 90 | * Parses an import and adds the routes in the resource to the RouteCollection. 91 | * 92 | * @param array $config Route definition 93 | * @param string $path Full path of the YAML file being processed 94 | * @param string $file Loaded file name 95 | */ 96 | protected function parseImport(RouteCollection $collection, array $config, string $path, string $file): void 97 | { 98 | $type = $config['type'] ?? null; 99 | $defaults = $config['defaults'] ?? []; 100 | $requirements = $config['requirements'] ?? []; 101 | $options = $config['options'] ?? []; 102 | $exclude = $config['exclude'] ?? null; 103 | 104 | $this->setCurrentDir(\dirname($path)); 105 | 106 | /** @var RouteCollection[] $imported */ 107 | $imported = $this->import($config['resource'], $type, false, $file, $exclude) ?: []; 108 | 109 | if (!\is_array($imported)) { 110 | $imported = [$imported]; 111 | } 112 | 113 | foreach ($imported as $subCollection) { 114 | $subCollection->addDefaults($defaults); 115 | $subCollection->addRequirements($requirements); 116 | $subCollection->addOptions($options); 117 | 118 | $collection->addCollection($subCollection); 119 | } 120 | } 121 | 122 | /** 123 | * Parses a route and adds it to the RouteCollection. 124 | * 125 | * @param string $name Route name 126 | * @param array $config Route definition 127 | * @param string $path Full path of the YAML file being processed 128 | */ 129 | protected function parseRoute(RouteCollection $collection, string $name, array $config, string $path): void 130 | { 131 | $defaults = $config['defaults'] ?? []; 132 | $requirements = $config['requirements'] ?? []; 133 | $options = $config['options'] ?? []; 134 | 135 | foreach ($requirements as $placeholder => $requirement) { 136 | if (\is_int($placeholder)) { 137 | throw new \InvalidArgumentException(sprintf('A placeholder name must be a string (%d given). Did you forget to specify the placeholder key for the requirement "%s" of route "%s" in "%s"?', $placeholder, $requirement, $name, $path)); 138 | } 139 | } 140 | 141 | if (isset($config['pattern'])) { 142 | $pattern = $config['pattern']; 143 | } else { 144 | $pattern = $config['channel']; 145 | } 146 | 147 | if (isset($config['callback'])) { 148 | $callback = $config['callback']; 149 | } else { 150 | $callback = $config['handler']; 151 | } 152 | 153 | $route = new Route($pattern, $callback); 154 | $route->addDefaults($defaults); 155 | $route->addRequirements($requirements); 156 | $route->addOptions($options); 157 | 158 | $collection->add($name, $route); 159 | } 160 | 161 | /** 162 | * @throws \InvalidArgumentException if the data is invalid 163 | */ 164 | protected function validate(array $config, string $name, string $path): void 165 | { 166 | if (!\is_array($config)) { 167 | throw new \InvalidArgumentException(sprintf('The definition of "%s" in "%s" must be a YAML array.', $name, $path)); 168 | } 169 | 170 | if ($extraKeys = array_diff(array_keys($config), self::AVAILABLE_KEYS)) { 171 | throw new \InvalidArgumentException(sprintf('The routing file "%s" contains unsupported keys for "%s": "%s". Expected one of: "%s".', $path, $name, implode('", "', $extraKeys), implode('", "', self::AVAILABLE_KEYS))); 172 | } 173 | 174 | if (isset($config['resource'])) { 175 | if (isset($config['channel']) || isset($config['pattern'])) { 176 | throw new \InvalidArgumentException(sprintf('The routing file "%s" must not specify both the "resource" key and the "pattern" key for "%s". Choose between an import and a route definition.', $path, $name)); 177 | } 178 | } else { 179 | if (isset($config['type'])) { 180 | throw new \InvalidArgumentException(sprintf('The "type" key for the route definition "%s" in "%s" is unsupported. It is only available for imports in combination with the "resource" key.', $name, $path)); 181 | } 182 | 183 | if (isset($config['pattern']) && isset($config['channel'])) { 184 | throw new \InvalidArgumentException(sprintf('The route "%s" in file "%s" must not specify both the "channel" key and the "pattern" key.', $name, $path)); 185 | } elseif (isset($config['channel'])) { 186 | trigger_deprecation('gos/pubsub-router-bundle', '2.4', 'Setting the "channel" key for the route "%s" in file "%s" is deprecated and will not be supported in 3.0, use the "pattern" key instead.', $name, $path); 187 | } elseif (!isset($config['pattern'])) { 188 | throw new \InvalidArgumentException(sprintf('You must define a "pattern" for the route "%s" in file "%s".', $name, $path)); 189 | } 190 | 191 | if (isset($config['callback']) && isset($config['handler'])) { 192 | throw new \InvalidArgumentException(sprintf('The route "%s" in file "%s" must not specify both the "handler" key and the "callback" key.', $name, $path)); 193 | } elseif (isset($config['handler'])) { 194 | trigger_deprecation('gos/pubsub-router-bundle', '2.4', 'Setting the "handler" key for the route "%s" in file "%s" is deprecated and will not be supported in 3.0, use the "callback" key instead.', $name, $path); 195 | } elseif (!isset($config['callback'])) { 196 | throw new \InvalidArgumentException(sprintf('You must define a "callback" for the route "%s" in file "%s".', $name, $path)); 197 | } 198 | } 199 | } 200 | 201 | /** 202 | * @param mixed $resource 203 | */ 204 | protected function doSupports($resource, string $type = null): bool 205 | { 206 | return \is_string($resource) 207 | && \in_array(pathinfo($resource, PATHINFO_EXTENSION), ['yml', 'yaml'], true) 208 | && (!$type || 'yaml' === $type); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Loader/schema/routing/routing-1.0.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /src/Matcher/CompiledMatcher.php: -------------------------------------------------------------------------------- 1 | staticRoutes, $this->regexpList, $this->dynamicRoutes] = $compiledRoutes; 28 | } 29 | 30 | public function match(string $channel): array 31 | { 32 | if ($ret = $this->doMatch($channel)) { 33 | return $ret; 34 | } 35 | 36 | throw new ResourceNotFoundException(sprintf('No routes found for "%s".', $channel)); 37 | } 38 | 39 | private function doMatch(string $channel): array 40 | { 41 | foreach ($this->staticRoutes[$channel] ?? [] as [$name, $vars, $pattern, $callback, $defaults, $requirements, $options]) { 42 | $route = new Route($pattern, $callback, $defaults, $requirements, $options); 43 | $route->compile(); 44 | 45 | return [$name, $route, $this->getAttributes($route, $channel, [self::REQUIREMENT_MATCH, []])]; 46 | } 47 | 48 | foreach ($this->regexpList as $offset => $regex) { 49 | while (preg_match($regex, $channel, $matches)) { 50 | foreach ($this->dynamicRoutes[$m = (int) $matches['MARK']] as [$name, $vars, $pattern, $callback, $defaults, $requirements, $options]) { 51 | $ret = []; 52 | 53 | foreach ($vars as $i => $v) { 54 | if (isset($matches[1 + $i])) { 55 | $ret[$v] = $matches[1 + $i]; 56 | } 57 | } 58 | 59 | $route = new Route($pattern, $callback, $defaults, $requirements, $options); 60 | $route->compile(); 61 | 62 | $status = [self::REQUIREMENT_MATCH, null]; 63 | 64 | return [$name, $route, $this->getAttributes($route, $channel, array_replace($ret, $status[1] ?? []))]; 65 | } 66 | 67 | $regex = substr_replace($regex, 'F', $m - $offset, 1 + \strlen((string) $m)); 68 | $offset += \strlen((string) $m); 69 | } 70 | } 71 | 72 | return []; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Matcher/Dumper/CompiledMatcherDumper.php: -------------------------------------------------------------------------------- 1 | generateCompiledRoutes()}]; 25 | EOF; 26 | } 27 | 28 | public function getCompiledRoutes(bool $forDump = false): array 29 | { 30 | // Group hosts by same-suffix, re-order when possible 31 | $routes = new StaticPrefixCollection(); 32 | 33 | foreach ($this->getRoutes()->all() as $name => $route) { 34 | $routes->addRoute('/(.*)', [$name, $route]); 35 | } 36 | 37 | $routes = $this->getRoutes(); 38 | 39 | [$staticRoutes, $dynamicRoutes] = $this->groupStaticRoutes($routes); 40 | 41 | $compiledRoutes = [$this->compileStaticRoutes($staticRoutes)]; 42 | $chunkLimit = \count($dynamicRoutes); 43 | 44 | while (true) { 45 | try { 46 | $this->signalingException = new \RuntimeException('Compilation failed: regular expression is too large'); 47 | $compiledRoutes = array_merge($compiledRoutes, $this->compileDynamicRoutes($dynamicRoutes, $chunkLimit)); 48 | 49 | break; 50 | } catch (\Exception $e) { 51 | if (1 < $chunkLimit && $this->signalingException === $e) { 52 | $chunkLimit = 1 + ($chunkLimit >> 1); 53 | 54 | continue; 55 | } 56 | 57 | throw $e; 58 | } 59 | } 60 | 61 | if ($forDump) { 62 | $compiledRoutes[1] = $compiledRoutes[3]; 63 | } 64 | 65 | return $compiledRoutes; 66 | } 67 | 68 | private function generateCompiledRoutes(): string 69 | { 70 | [$staticRoutes, $regexpCode, $dynamicRoutes] = $this->getCompiledRoutes(true); 71 | 72 | $code = '[ // $staticRoutes'."\n"; 73 | 74 | foreach ($staticRoutes as $path => $routes) { 75 | $code .= sprintf(" %s => [\n", self::export($path)); 76 | 77 | foreach ($routes as $route) { 78 | $code .= sprintf(" [%s, %s, %s, %s, %s, %s, %s],\n", ...array_map([self::class, 'export'], $route)); 79 | } 80 | 81 | $code .= " ],\n"; 82 | } 83 | 84 | $code .= "],\n"; 85 | 86 | $code .= sprintf("[ // \$regexpList%s\n],\n", $regexpCode); 87 | 88 | $code .= '[ // $dynamicRoutes'."\n"; 89 | 90 | foreach ($dynamicRoutes as $path => $routes) { 91 | $code .= sprintf(" %s => [\n", self::export($path)); 92 | 93 | foreach ($routes as $route) { 94 | $code .= sprintf(" [%s, %s, %s, %s, %s, %s, %s],\n", ...array_map([self::class, 'export'], $route)); 95 | } 96 | 97 | $code .= " ],\n"; 98 | } 99 | 100 | $code .= "],\n"; 101 | $code = preg_replace('/ => \[\n (\[.+?),\n \],/', ' => [$1],', $code); 102 | 103 | return $this->indent($code, 1); 104 | } 105 | 106 | private function groupStaticRoutes(RouteCollection $collection): array 107 | { 108 | $staticRoutes = $dynamicRegex = []; 109 | $dynamicRoutes = new RouteCollection(); 110 | 111 | foreach ($collection->all() as $name => $route) { 112 | $compiledRoute = $route->compile(); 113 | $regex = $compiledRoute->getRegex(); 114 | 115 | if (!$compiledRoute->getVariables()) { 116 | $pattern = $route->getPattern(); 117 | 118 | foreach ($dynamicRegex as $rx) { 119 | if (preg_match($rx, $pattern)) { 120 | $dynamicRegex[] = $regex; 121 | $dynamicRoutes->add($name, $route); 122 | 123 | continue 2; 124 | } 125 | } 126 | 127 | $staticRoutes[$pattern][$name] = $route; 128 | } else { 129 | $dynamicRegex[] = $regex; 130 | $dynamicRoutes->add($name, $route); 131 | } 132 | } 133 | 134 | return [$staticRoutes, $dynamicRoutes]; 135 | } 136 | 137 | /** 138 | * Compiles static routes in a switch statement. 139 | * 140 | * Condition-less paths are put in a static array in the switch's default, with generic matching logic. 141 | * Paths that can match two or more routes, or have user-specified conditions are put in separate switch's cases. 142 | * 143 | * @throws \LogicException 144 | */ 145 | private function compileStaticRoutes(array $staticRoutes): array 146 | { 147 | if (!$staticRoutes) { 148 | return []; 149 | } 150 | 151 | $compiledRoutes = []; 152 | 153 | foreach ($staticRoutes as $url => $routes) { 154 | $compiledRoutes[$url] = []; 155 | 156 | foreach ($routes as $name => $route) { 157 | $compiledRoutes[$url][] = $this->compileRoute($route, $name, []); 158 | } 159 | } 160 | 161 | return $compiledRoutes; 162 | } 163 | 164 | /** 165 | * Compiles a regular expression followed by a switch statement to match dynamic routes. 166 | * 167 | * The regular expression matches both the host and the pathinfo at the same time. For stellar performance, 168 | * it is built as a tree of patterns, with re-ordering logic to group same-prefix routes together when possible. 169 | * 170 | * Patterns are named so that we know which one matched (https://pcre.org/current/doc/html/pcre2syntax.html#SEC23). 171 | * This name is used to "switch" to the additional logic required to match the final route. 172 | * 173 | * Condition-less paths are put in a static array in the switch's default, with generic matching logic. 174 | * Paths that can match two or more routes, or have user-specified conditions are put in separate switch's cases. 175 | * 176 | * Last but not least: 177 | * - Because it is not possible to mix unicode/non-unicode patterns in a single regexp, several of them can be generated. 178 | * - The same regexp can be used several times when the logic in the switch rejects the match. When this happens, the 179 | * matching-but-failing subpattern is excluded by replacing its name by "(*F)", which forces a failure-to-match. 180 | * To ease this backlisting operation, the name of subpatterns is also the string offset where the replacement should occur. 181 | */ 182 | private function compileDynamicRoutes(RouteCollection $collection, int $chunkLimit): array 183 | { 184 | if (!$collection->all()) { 185 | return [[], [], '']; 186 | } 187 | 188 | $regexpList = []; 189 | $code = ''; 190 | $state = (object) [ 191 | 'regexMark' => 0, 192 | 'regex' => [], 193 | 'routes' => [], 194 | 'mark' => 0, 195 | 'markTail' => 0, 196 | 'vars' => [], 197 | ]; 198 | $state->getVars = static function ($m) use ($state) { 199 | if ('_route' === $m[1]) { 200 | return '?:'; 201 | } 202 | 203 | $state->vars[] = $m[1]; 204 | 205 | return ''; 206 | }; 207 | 208 | $chunkSize = 0; 209 | $prev = null; 210 | $perModifiers = []; 211 | 212 | foreach ($collection->all() as $name => $route) { 213 | preg_match('#[a-zA-Z]*$#', $route->compile()->getRegex(), $rx); 214 | 215 | if ($chunkLimit < ++$chunkSize || $prev !== $rx[0] && $route->compile()->getVariables()) { 216 | $chunkSize = 1; 217 | $routes = new RouteCollection(); 218 | $perModifiers[] = [$rx[0], $routes]; 219 | $prev = $rx[0]; 220 | } 221 | 222 | if (isset($routes)) { 223 | $routes->add($name, $route); 224 | } 225 | } 226 | 227 | foreach ($perModifiers as [$modifiers, $routes]) { 228 | $rx = '{^(?'; 229 | $code .= "\n {$state->mark} => ".self::export($rx); 230 | $startingMark = $state->mark; 231 | $state->mark += \strlen($rx); 232 | $state->regex = $rx; 233 | 234 | $tree = new StaticPrefixCollection(); 235 | 236 | foreach ($routes->all() as $name => $route) { 237 | preg_match('#^.\^(.*)\$.[a-zA-Z]*$#', $route->compile()->getRegex(), $rx); 238 | 239 | $state->vars = []; 240 | $regex = preg_replace_callback('#\?P<([^>]++)>#', $state->getVars, $rx[1]); 241 | 242 | if ('/' !== $regex && '/' === $regex[-1]) { 243 | $regex = substr($regex, 0, -1); 244 | } 245 | 246 | $tree->addRoute($regex, [$name, $regex, $state->vars, $route]); 247 | } 248 | 249 | $code .= $this->compileStaticPrefixCollection($tree, $state, 0); 250 | 251 | $rx = ")/?$}{$modifiers}"; 252 | $code .= "\n .'{$rx}',"; 253 | $state->regex .= $rx; 254 | $state->markTail = 0; 255 | 256 | // if the regex is too large, throw a signaling exception to recompute with smaller chunk size 257 | set_error_handler(function ($type, $message): void { throw false !== strpos($message, $this->signalingException->getMessage()) ? $this->signalingException : new \ErrorException($message); }); 258 | 259 | try { 260 | preg_match($state->regex, ''); 261 | } finally { 262 | restore_error_handler(); 263 | } 264 | 265 | $regexpList[$startingMark] = $state->regex; 266 | } 267 | 268 | $state->routes[$state->mark][] = [null, null, null, null, false, false, 0]; 269 | unset($state->getVars); 270 | 271 | return [$regexpList, $state->routes, $code]; 272 | } 273 | 274 | /** 275 | * Compiles a regexp tree of subpatterns that matches nested same-prefix routes. 276 | * 277 | * @param \stdClass $state A simple state object that keeps track of the progress of the compilation, 278 | * and gathers the generated switch's "case" and "default" statements 279 | */ 280 | private function compileStaticPrefixCollection(StaticPrefixCollection $tree, \stdClass $state, int $prefixLen = 0): string 281 | { 282 | $code = ''; 283 | $prevRegex = null; 284 | $routes = $tree->getRoutes(); 285 | 286 | foreach ($routes as $i => $route) { 287 | if ($route instanceof StaticPrefixCollection) { 288 | $prevRegex = null; 289 | $prefix = substr($route->getPrefix(), $prefixLen); 290 | $state->mark += \strlen($rx = "|{$prefix}(?"); 291 | $code .= "\n .".self::export($rx); 292 | $state->regex .= $rx; 293 | $code .= $this->indent($this->compileStaticPrefixCollection($route, $state, $prefixLen + \strlen($prefix))); 294 | $code .= "\n .')'"; 295 | $state->regex .= ')'; 296 | ++$state->markTail; 297 | 298 | continue; 299 | } 300 | 301 | /** 302 | * @var string $name 303 | * @var string $regex 304 | * @var array $vars 305 | * @var Route $route 306 | */ 307 | [$name, $regex, $vars, $route] = $route; 308 | 309 | $compiledRoute = $route->compile(); 310 | 311 | if ($compiledRoute->getRegex() === $prevRegex) { 312 | $state->routes[$state->mark][] = $this->compileRoute($route, $name, $vars); 313 | 314 | continue; 315 | } 316 | 317 | $state->mark += 3 + $state->markTail + \strlen($regex) - $prefixLen; 318 | $state->markTail = 2 + \strlen((string) $state->mark); 319 | $rx = sprintf('|%s(*:%s)', substr($regex, $prefixLen), $state->mark); 320 | $code .= "\n .".self::export($rx); 321 | $state->regex .= $rx; 322 | 323 | $prevRegex = $compiledRoute->getRegex(); 324 | $state->routes[$state->mark] = [$this->compileRoute($route, $name, $vars)]; 325 | } 326 | 327 | return $code; 328 | } 329 | 330 | /** 331 | * Compiles a single Route to PHP code used to match it against the path info. 332 | */ 333 | private function compileRoute(Route $route, string $name, array $vars): array 334 | { 335 | return [ 336 | $name, 337 | $vars, 338 | $route->getPattern(), 339 | $route->getCallback(), 340 | $route->getDefaults(), 341 | $route->getRequirements(), 342 | $route->getOptions(), 343 | ]; 344 | } 345 | 346 | private function indent(string $code, int $level = 1): string 347 | { 348 | return preg_replace('/^./m', str_repeat(' ', $level).'$0', $code); 349 | } 350 | 351 | /** 352 | * @param mixed $value 353 | * 354 | * @throws \InvalidArgumentException if the value contains invalid data 355 | * 356 | * @internal 357 | */ 358 | public static function export($value): string 359 | { 360 | if (null === $value) { 361 | return 'null'; 362 | } 363 | 364 | if (!\is_array($value)) { 365 | if (\is_object($value)) { 366 | throw new \InvalidArgumentException(Route::class.' cannot contain objects.'); 367 | } 368 | 369 | return str_replace("\n", '\'."\n".\'', var_export($value, true)); 370 | } 371 | 372 | if (!$value) { 373 | return '[]'; 374 | } 375 | 376 | $i = 0; 377 | $export = '['; 378 | 379 | foreach ($value as $k => $v) { 380 | if ($i === $k) { 381 | ++$i; 382 | } else { 383 | $export .= self::export($k).' => '; 384 | 385 | if (\is_int($k) && $i < $k) { 386 | $i = 1 + $k; 387 | } 388 | } 389 | 390 | $export .= self::export($v).', '; 391 | } 392 | 393 | return substr_replace($export, ']', -2); 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /src/Matcher/Dumper/MatcherDumper.php: -------------------------------------------------------------------------------- 1 | routes = $routes; 17 | } 18 | 19 | public function getRoutes(): RouteCollection 20 | { 21 | return $this->routes; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Matcher/Dumper/MatcherDumperInterface.php: -------------------------------------------------------------------------------- 1 | 'ProjectMatcher', 34 | 'base_class' => Matcher::class, 35 | ], 36 | $options 37 | ); 38 | 39 | // trailing slash support is only enabled if we know how to redirect the user 40 | $interfaces = class_implements($options['base_class']); 41 | 42 | return <<generateMatchMethod()} 59 | } 60 | 61 | EOF; 62 | } 63 | 64 | /** 65 | * Generates the code for the match method implementing MatcherInterface. 66 | */ 67 | private function generateMatchMethod(): string 68 | { 69 | // Group hosts by same-suffix, re-order when possible 70 | $routes = new StaticPrefixCollection(); 71 | foreach ($this->getRoutes()->all() as $name => $route) { 72 | $routes->addRoute('/(.*)', [$name, $route]); 73 | } 74 | 75 | $routes = $this->getRoutes(); 76 | 77 | $code = rtrim($this->compileRoutes($routes), "\n"); 78 | 79 | $code = <<groupStaticRoutes($routes); 94 | 95 | $code = $this->compileStaticRoutes($staticRoutes); 96 | $chunkLimit = \count($dynamicRoutes); 97 | 98 | while (true) { 99 | try { 100 | $this->signalingException = new \RuntimeException('preg_match(): Compilation failed: regular expression is too large'); 101 | $code .= $this->compileDynamicRoutes($dynamicRoutes, $chunkLimit); 102 | break; 103 | } catch (\Exception $e) { 104 | if (1 < $chunkLimit && $this->signalingException === $e) { 105 | $chunkLimit = 1 + ($chunkLimit >> 1); 106 | continue; 107 | } 108 | throw $e; 109 | } 110 | } 111 | 112 | return $code; 113 | } 114 | 115 | /** 116 | * Splits static routes from dynamic routes, so that they can be matched first, using a simple switch. 117 | */ 118 | private function groupStaticRoutes(RouteCollection $collection): array 119 | { 120 | $staticRoutes = $dynamicRegex = []; 121 | $dynamicRoutes = new RouteCollection(); 122 | 123 | foreach ($collection->all() as $name => $route) { 124 | $compiledRoute = $route->compile(); 125 | $regex = $compiledRoute->getRegex(); 126 | 127 | if (!$compiledRoute->getVariables()) { 128 | $pattern = $route->getPattern(); 129 | 130 | foreach ($dynamicRegex as $rx) { 131 | if (preg_match($rx, $pattern)) { 132 | $dynamicRegex[] = $regex; 133 | $dynamicRoutes->add($name, $route); 134 | continue 2; 135 | } 136 | } 137 | 138 | $staticRoutes[$pattern][$name] = $route; 139 | } else { 140 | $dynamicRegex[] = $regex; 141 | $dynamicRoutes->add($name, $route); 142 | } 143 | } 144 | 145 | return [$staticRoutes, $dynamicRoutes]; 146 | } 147 | 148 | /** 149 | * Compiles static routes in a switch statement. 150 | * 151 | * Condition-less paths are put in a static array in the switch's default, with generic matching logic. 152 | * Paths that can match two or more routes, or have user-specified conditions are put in separate switch's cases. 153 | * 154 | * @throws \LogicException 155 | */ 156 | private function compileStaticRoutes(array $staticRoutes): string 157 | { 158 | if (!$staticRoutes) { 159 | return ''; 160 | } 161 | 162 | $code = ''; 163 | 164 | foreach ($staticRoutes as $url => $routes) { 165 | if (1 === \count($routes)) { 166 | foreach ($routes as $name => $route) { 167 | } 168 | } 169 | 170 | $code .= sprintf(" case %s:\n", self::export($url)); 171 | 172 | foreach ($routes as $name => $route) { 173 | $code .= $this->compileRoute($route, $name); 174 | } 175 | 176 | $code .= " break;\n"; 177 | } 178 | 179 | return sprintf(" switch (\$channel) {\n%s }\n\n", $this->indent($code)); 180 | } 181 | 182 | /** 183 | * Compiles a regular expression followed by a switch statement to match dynamic routes. 184 | * 185 | * The regular expression matches both the host and the pathinfo at the same time. For stellar performance, 186 | * it is built as a tree of patterns, with re-ordering logic to group same-prefix routes together when possible. 187 | * 188 | * Patterns are named so that we know which one matched (https://pcre.org/current/doc/html/pcre2syntax.html#SEC23). 189 | * This name is used to "switch" to the additional logic required to match the final route. 190 | * 191 | * Condition-less paths are put in a static array in the switch's default, with generic matching logic. 192 | * Paths that can match two or more routes, or have user-specified conditions are put in separate switch's cases. 193 | * 194 | * Last but not least: 195 | * - Because it is not possible to mix unicode/non-unicode patterns in a single regexp, several of them can be generated. 196 | * - The same regexp can be used several times when the logic in the switch rejects the match. When this happens, the 197 | * matching-but-failing subpattern is excluded by replacing its name by "(*F)", which forces a failure-to-match. 198 | * To ease this backlisting operation, the name of subpatterns is also the string offset where the replacement should occur. 199 | */ 200 | private function compileDynamicRoutes(RouteCollection $collection, int $chunkLimit): string 201 | { 202 | if (!$collection->all()) { 203 | return ''; 204 | } 205 | 206 | $code = ''; 207 | $state = (object) [ 208 | 'regex' => '', 209 | 'switch' => '', 210 | 'default' => '', 211 | 'mark' => 0, 212 | 'markTail' => 0, 213 | 'vars' => [], 214 | ]; 215 | $state->getVars = static function ($m) use ($state) { 216 | $state->vars[] = $m[1]; 217 | 218 | return ''; 219 | }; 220 | 221 | $chunkSize = 0; 222 | $prev = null; 223 | $perModifiers = []; 224 | 225 | foreach ($collection->all() as $name => $route) { 226 | preg_match('#[a-zA-Z]*$#', $route->compile()->getRegex(), $rx); 227 | 228 | if ($chunkLimit < ++$chunkSize || $prev !== $rx[0] && $route->compile()->getVariables()) { 229 | $chunkSize = 1; 230 | $routes = new RouteCollection(); 231 | $perModifiers[] = [$rx[0], $routes]; 232 | $prev = $rx[0]; 233 | } 234 | 235 | if (isset($routes)) { 236 | $routes->add($name, $route); 237 | } 238 | } 239 | 240 | foreach ($perModifiers as [$modifiers, $routes]) { 241 | $rx = '{^(?'; 242 | $code .= self::export($rx); 243 | $state->mark += \strlen($rx); 244 | $state->regex = $rx; 245 | 246 | $tree = new StaticPrefixCollection(); 247 | 248 | foreach ($routes->all() as $name => $route) { 249 | preg_match('#^.\^(.*)\$.[a-zA-Z]*$#', $route->compile()->getRegex(), $rx); 250 | 251 | $state->vars = []; 252 | $regex = preg_replace_callback('#\?P<([^>]++)>#', $state->getVars, $rx[1]); 253 | $tree->addRoute($regex, [$name, $regex, $state->vars, $route]); 254 | } 255 | 256 | $code .= $this->compileStaticPrefixCollection($tree, $state); 257 | 258 | $rx = ")$}{$modifiers}"; 259 | $code .= "\n .'{$rx}'"; 260 | $state->regex .= $rx; 261 | $state->markTail = 0; 262 | 263 | // if the regex is too large, throw a signaling exception to recompute with smaller chunk size 264 | set_error_handler(function (int $type, string $message, string $file, int $line): void { throw 0 === strpos($message, $this->signalingException->getMessage()) ? $this->signalingException : new \ErrorException($message); }); 265 | try { 266 | preg_match($state->regex, ''); 267 | } finally { 268 | restore_error_handler(); 269 | } 270 | } 271 | 272 | if ($state->default) { 273 | $state->switch .= <<indent($state->default, 4)} ); 277 | 278 | list(\$name, \$route, \$vars) = \$routes[\$m]; 279 | {$this->compileSwitchDefault(true)} 280 | EOF; 281 | } 282 | 283 | $matchedPathinfo = '$channel'; 284 | unset($state->getVars); 285 | 286 | return <<indent($state->switch, 2)} } 293 | 294 | if ({$state->mark} === \$m) { 295 | break; 296 | } 297 | \$regex = substr_replace(\$regex, 'F', \$m - \$offset, 1 + strlen(\$m)); 298 | \$offset += strlen(\$m); 299 | } 300 | 301 | EOF; 302 | } 303 | 304 | /** 305 | * Compiles a regexp tree of subpatterns that matches nested same-prefix routes. 306 | * 307 | * @param \stdClass $state A simple state object that keeps track of the progress of the compilation, 308 | * and gathers the generated switch's "case" and "default" statements 309 | */ 310 | private function compileStaticPrefixCollection(StaticPrefixCollection $tree, \stdClass $state, int $prefixLen = 0): string 311 | { 312 | $code = ''; 313 | $prevRegex = null; 314 | $routes = $tree->getRoutes(); 315 | 316 | foreach ($routes as $i => $route) { 317 | if ($route instanceof StaticPrefixCollection) { 318 | $prevRegex = null; 319 | $prefix = substr($route->getPrefix(), $prefixLen); 320 | $state->mark += \strlen($rx = "|{$prefix}(?"); 321 | $code .= "\n .".self::export($rx); 322 | $state->regex .= $rx; 323 | $code .= $this->indent($this->compileStaticPrefixCollection($route, $state, $prefixLen + \strlen($prefix))); 324 | $code .= "\n .')'"; 325 | $state->regex .= ')'; 326 | ++$state->markTail; 327 | continue; 328 | } 329 | 330 | /** 331 | * @var string $name 332 | * @var string $regex 333 | * @var array $vars 334 | * @var Route $route 335 | */ 336 | [$name, $regex, $vars, $route] = $route; 337 | $compiledRoute = $route->compile(); 338 | 339 | if ($compiledRoute->getRegex() === $prevRegex) { 340 | $state->switch = substr_replace($state->switch, $this->compileRoute($route, $name)."\n", -19, 0); 341 | continue; 342 | } 343 | 344 | $state->mark += 3 + $state->markTail + \strlen($regex) - $prefixLen; 345 | $state->markTail = 2 + \strlen((string) $state->mark); 346 | $rx = sprintf('|%s(*:%s)', substr($regex, $prefixLen), $state->mark); 347 | $code .= "\n .".self::export($rx); 348 | $state->regex .= $rx; 349 | 350 | if (!\is_array($next = $routes[1 + $i] ?? null) || $regex !== $next[1]) { 351 | $prevRegex = null; 352 | $defaults = $route->getDefaults(); 353 | 354 | $state->default .= sprintf( 355 | "%s => array(%s, %s, %s),\n", 356 | $state->mark, 357 | self::export($name), 358 | sprintf( 359 | 'new Route(%s, %s, %s, %s, %s)', 360 | self::export($route->getPattern()), 361 | self::export($route->getCallback()), 362 | self::export($route->getDefaults() + $defaults), 363 | self::export($route->getRequirements()), 364 | self::export($route->getOptions()) 365 | ), 366 | self::export($vars) 367 | ); 368 | } else { 369 | $prevRegex = $compiledRoute->getRegex(); 370 | $combine = ' $matches = array('; 371 | foreach ($vars as $j => $m) { 372 | $combine .= sprintf('%s => $matches[%d] ?? null, ', self::export($m), 1 + $j); 373 | } 374 | $combine = $vars ? substr_replace($combine, ");\n\n", -2) : ''; 375 | 376 | $state->switch .= <<mark}: 378 | {$combine}{$this->compileRoute($route, $name)} 379 | break; 380 | 381 | EOF; 382 | } 383 | } 384 | 385 | return $code; 386 | } 387 | 388 | /** 389 | * A simple helper to compiles the switch's "default" for both static and dynamic routes. 390 | */ 391 | private function compileSwitchDefault(bool $hasVars): string 392 | { 393 | if ($hasVars) { 394 | $code = << \$v) { 399 | if (isset(\$matches[1 + \$i])) { 400 | \$attributes[\$v] = \$matches[1 + \$i]; 401 | } 402 | } 403 | 404 | \$attributes = \$this->mergeDefaults(\$attributes, \$route->getDefaults()); 405 | 406 | EOF; 407 | } else { 408 | $code = <<getDefaults(); 411 | 412 | EOF; 413 | } 414 | 415 | $code .= <<compile(); 432 | $matches = (bool) $compiledRoute->getVariables(); 433 | 434 | $code = " // {$name}\n"; 435 | 436 | $gotoname = 'not_'.preg_replace('/[^A-Za-z0-9_]/', '', $name); 437 | 438 | // the offset where the return value is appended below, with indendation 439 | $retOffset = 12 + \strlen($code); 440 | 441 | // optimize parameters array 442 | if ($matches) { 443 | $vars = ["array('$name')", '$matches']; 444 | 445 | $code .= sprintf( 446 | " \$ret = array(%s, %s, \$this->mergeDefaults(%s, %s));\n", 447 | self::export($name), 448 | sprintf( 449 | 'new Route(%s, %s, %s, %s, %s)', 450 | self::export($route->getPattern()), 451 | self::export($route->getCallback()), 452 | self::export($route->getDefaults()), 453 | self::export($route->getRequirements()), 454 | self::export($route->getOptions()) 455 | ), 456 | implode(' + ', $vars), 457 | self::export($route->getDefaults()) 458 | ); 459 | } else { 460 | $code .= sprintf( 461 | " \$ret = array(%s, %s, %s);\n", 462 | self::export($name), 463 | sprintf( 464 | 'new Route(%s, %s, %s, %s, %s)', 465 | self::export($route->getPattern()), 466 | self::export($route->getCallback()), 467 | self::export($route->getDefaults()), 468 | self::export($route->getRequirements()), 469 | self::export($route->getOptions()) 470 | ), 471 | self::export($route->getDefaults()) 472 | ); 473 | } 474 | 475 | $code = substr_replace($code, 'return', $retOffset, 6); 476 | 477 | return $code; 478 | } 479 | 480 | private function indent(string $code, int $level = 1): string 481 | { 482 | return preg_replace('/^./m', str_repeat(' ', $level).'$0', $code); 483 | } 484 | 485 | /** 486 | * @param mixed $value 487 | * 488 | * @throws \InvalidArgumentException if the value contains invalid data 489 | * 490 | * @internal 491 | */ 492 | public static function export($value): string 493 | { 494 | if (null === $value) { 495 | return 'null'; 496 | } 497 | 498 | if (!\is_array($value)) { 499 | if (\is_object($value)) { 500 | throw new \InvalidArgumentException(Route::class.' cannot contain objects.'); 501 | } 502 | 503 | return str_replace("\n", '\'."\n".\'', var_export($value, true)); 504 | } 505 | 506 | if (!$value) { 507 | return 'array()'; 508 | } 509 | 510 | $i = 0; 511 | $export = 'array('; 512 | 513 | foreach ($value as $k => $v) { 514 | if ($i === $k) { 515 | ++$i; 516 | } else { 517 | $export .= self::export($k).' => '; 518 | 519 | if (\is_int($k) && $i < $k) { 520 | $i = 1 + $k; 521 | } 522 | } 523 | 524 | $export .= self::export($v).', '; 525 | } 526 | 527 | return substr_replace($export, ')', -2); 528 | } 529 | } 530 | -------------------------------------------------------------------------------- /src/Matcher/Dumper/StaticPrefixCollection.php: -------------------------------------------------------------------------------- 1 | > 26 | */ 27 | private $items = []; 28 | 29 | public function __construct(string $prefix = '') 30 | { 31 | $this->prefix = $prefix; 32 | } 33 | 34 | public function getPrefix(): string 35 | { 36 | return $this->prefix; 37 | } 38 | 39 | /** 40 | * @return array> 41 | */ 42 | public function getRoutes() 43 | { 44 | return $this->items; 45 | } 46 | 47 | /** 48 | * Adds a route to a group. 49 | * 50 | * @param array|self $route 51 | */ 52 | public function addRoute(string $prefix, $route): void 53 | { 54 | [$prefix, $staticPrefix] = $this->getCommonPrefix($prefix, $prefix); 55 | 56 | for ($i = \count($this->items) - 1; 0 <= $i; --$i) { 57 | $item = $this->items[$i]; 58 | 59 | [$commonPrefix, $commonStaticPrefix] = $this->getCommonPrefix($prefix, $this->prefixes[$i]); 60 | 61 | if ($this->prefix === $commonPrefix) { 62 | // the new route and a previous one have no common prefix, let's see if they are exclusive to each others 63 | 64 | if ($this->prefix !== $staticPrefix && $this->prefix !== $this->staticPrefixes[$i]) { 65 | // the new route and the previous one have exclusive static prefixes 66 | continue; 67 | } 68 | 69 | if ($this->prefix === $staticPrefix && $this->prefix === $this->staticPrefixes[$i]) { 70 | // the new route and the previous one have no static prefix 71 | break; 72 | } 73 | 74 | if ($this->prefixes[$i] !== $this->staticPrefixes[$i] && $this->prefix === $this->staticPrefixes[$i]) { 75 | // the previous route is non-static and has no static prefix 76 | break; 77 | } 78 | 79 | if ($prefix !== $staticPrefix && $this->prefix === $staticPrefix) { 80 | // the new route is non-static and has no static prefix 81 | break; 82 | } 83 | 84 | continue; 85 | } 86 | 87 | if ($item instanceof self && $this->prefixes[$i] === $commonPrefix) { 88 | // the new route is a child of a previous one, let's nest it 89 | $item->addRoute($prefix, $route); 90 | } else { 91 | // the new route and a previous one have a common prefix, let's merge them 92 | $child = new self($commonPrefix); 93 | [$child->prefixes[0], $child->staticPrefixes[0]] = $child->getCommonPrefix($this->prefixes[$i], $this->prefixes[$i]); 94 | [$child->prefixes[1], $child->staticPrefixes[1]] = $child->getCommonPrefix($prefix, $prefix); 95 | $child->items = [$this->items[$i], $route]; 96 | 97 | $this->staticPrefixes[$i] = $commonStaticPrefix; 98 | $this->prefixes[$i] = $commonPrefix; 99 | $this->items[$i] = $child; 100 | } 101 | 102 | return; 103 | } 104 | 105 | // No optimised case was found, in this case we simple add the route for possible 106 | // grouping when new routes are added. 107 | $this->staticPrefixes[] = $staticPrefix; 108 | $this->prefixes[] = $prefix; 109 | $this->items[] = $route; 110 | } 111 | 112 | /** 113 | * Linearizes back a set of nested routes into a collection. 114 | */ 115 | public function populateCollection(RouteCollection $routes): RouteCollection 116 | { 117 | foreach ($this->items as $route) { 118 | if ($route instanceof self) { 119 | $route->populateCollection($routes); 120 | } else { 121 | $routes->add(...$route); 122 | } 123 | } 124 | 125 | return $routes; 126 | } 127 | 128 | /** 129 | * Gets the full and static common prefixes between two route patterns. 130 | * 131 | * The static prefix stops at last at the first opening bracket. 132 | */ 133 | private function getCommonPrefix(string $prefix, string $anotherPrefix): array 134 | { 135 | $baseLength = \strlen($this->prefix); 136 | $end = min(\strlen($prefix), \strlen($anotherPrefix)); 137 | $staticLength = null; 138 | set_error_handler([self::class, 'handleError']); 139 | 140 | for ($i = $baseLength; $i < $end && $prefix[$i] === $anotherPrefix[$i]; ++$i) { 141 | if ('(' === $prefix[$i]) { 142 | $staticLength = $staticLength ?? $i; 143 | 144 | for ($j = 1 + $i, $n = 1; $j < $end && 0 < $n; ++$j) { 145 | if ($prefix[$j] !== $anotherPrefix[$j]) { 146 | break 2; 147 | } 148 | 149 | if ('(' === $prefix[$j]) { 150 | ++$n; 151 | } elseif (')' === $prefix[$j]) { 152 | --$n; 153 | } elseif ('\\' === $prefix[$j] && (++$j === $end || $prefix[$j] !== $anotherPrefix[$j])) { 154 | --$j; 155 | 156 | break; 157 | } 158 | } 159 | 160 | if (0 < $n) { 161 | break; 162 | } 163 | 164 | if (('?' === ($prefix[$j] ?? '') || '?' === ($anotherPrefix[$j] ?? '')) && ($prefix[$j] ?? '') !== ($anotherPrefix[$j] ?? '')) { 165 | break; 166 | } 167 | 168 | $subPattern = substr($prefix, $i, $j - $i); 169 | 170 | if ($prefix !== $anotherPrefix && !preg_match('/^\(\[[^\]]++\]\+\+\)$/', $subPattern) && !preg_match('{(?> 6) && preg_match('//u', $prefix.' '.$anotherPrefix)) { 186 | do { 187 | // Prevent cutting in the middle of an UTF-8 characters 188 | --$i; 189 | } while (0b10 === (\ord($prefix[$i]) >> 6)); 190 | } 191 | 192 | return [substr($prefix, 0, $i), substr($prefix, 0, $staticLength ?? $i)]; 193 | } 194 | 195 | public static function handleError(int $type, string $msg): bool 196 | { 197 | return false !== strpos($msg, 'Compilation failed: lookbehind assertion is not fixed length'); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Matcher/Matcher.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Matcher implements MatcherInterface 13 | { 14 | public const REQUIREMENT_MATCH = 0; 15 | public const REQUIREMENT_MISMATCH = 1; 16 | public const ROUTE_MATCH = 2; 17 | 18 | /** 19 | * @var RouteCollection 20 | */ 21 | protected $routes; 22 | 23 | public function __construct(RouteCollection $routes) 24 | { 25 | $this->routes = $routes; 26 | } 27 | 28 | /** 29 | * @throws ResourceNotFoundException if the given route name does not exist 30 | */ 31 | public function match(string $channel): array 32 | { 33 | if ($ret = $this->matchCollection($channel, $this->routes)) { 34 | return $ret; 35 | } 36 | 37 | throw new ResourceNotFoundException(sprintf('No routes found for "%s".', $channel)); 38 | } 39 | 40 | /** 41 | * @return array|null containing the matched route name, the Route object, and the request attributes 42 | */ 43 | protected function matchCollection(string $channel, RouteCollection $routes): ?array 44 | { 45 | /** 46 | * @var string $name 47 | * @var Route $route 48 | */ 49 | foreach ($routes as $name => $route) { 50 | $compiledRoute = $route->compile(); 51 | 52 | // check the static prefix of the URL first. Only use the more expensive preg_match when it matches 53 | if ('' !== $compiledRoute->getStaticPrefix() && 0 !== strpos($channel, $compiledRoute->getStaticPrefix())) { 54 | continue; 55 | } 56 | 57 | if (!preg_match($compiledRoute->getRegex(), $channel, $matches)) { 58 | continue; 59 | } 60 | 61 | $status = [self::REQUIREMENT_MATCH, null]; 62 | 63 | return [$name, $route, $this->getAttributes($route, $name, array_replace($matches, $status[1] ?? []))]; 64 | } 65 | 66 | return null; 67 | } 68 | 69 | /** 70 | * Returns an array of values to use as request attributes. 71 | * 72 | * As this method requires the Route object, it is not available 73 | * in matchers that do not have access to the matched Route instance 74 | * (like the PHP and Apache matcher dumpers). 75 | * 76 | * @param Route $route The route we are matching against 77 | * @param string $name The name of the route 78 | * @param array $attributes An array of attributes from the matcher 79 | * 80 | * @return array An array of parameters 81 | */ 82 | protected function getAttributes(Route $route, string $name, array $attributes): array 83 | { 84 | return $this->mergeDefaults($attributes, $route->getDefaults()); 85 | } 86 | 87 | protected function mergeDefaults(array $params, array $defaults): array 88 | { 89 | foreach ($params as $key => $value) { 90 | if (!\is_int($key) && null !== $value) { 91 | $defaults[$key] = $value; 92 | } 93 | } 94 | 95 | return $defaults; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Matcher/MatcherInterface.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface MatcherInterface 11 | { 12 | /** 13 | * @throws ResourceNotFoundException if the given route name does not exist 14 | */ 15 | public function match(string $channel): array; 16 | } 17 | -------------------------------------------------------------------------------- /src/Request/PubSubRequest.php: -------------------------------------------------------------------------------- 1 | attributes = new ParameterBag($attributes); 31 | $this->route = $route; 32 | $this->routeName = $routeName; 33 | } 34 | 35 | public function getRouteName(): string 36 | { 37 | return $this->routeName; 38 | } 39 | 40 | public function getRoute(): Route 41 | { 42 | return $this->route; 43 | } 44 | 45 | public function getAttributes(): ParameterBag 46 | { 47 | return $this->attributes; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Router/CompiledRoute.php: -------------------------------------------------------------------------------- 1 | staticPrefix = $staticPrefix; 39 | $this->regex = $regex; 40 | $this->tokens = $tokens; 41 | $this->variables = $variables; 42 | } 43 | 44 | public function serialize() 45 | { 46 | return serialize($this->__serialize()); 47 | } 48 | 49 | public function __serialize(): array 50 | { 51 | return [ 52 | 'staticPrefix' => $this->staticPrefix, 53 | 'regex' => $this->regex, 54 | 'tokens' => $this->tokens, 55 | 'variables' => $this->variables, 56 | ]; 57 | } 58 | 59 | public function unserialize($serialized): void 60 | { 61 | $this->__unserialize(unserialize($serialized)); 62 | } 63 | 64 | public function __unserialize(array $data): void 65 | { 66 | $this->staticPrefix = $data['staticPrefix']; 67 | $this->regex = $data['regex']; 68 | $this->tokens = $data['tokens']; 69 | $this->variables = $data['variables']; 70 | } 71 | 72 | public function getStaticPrefix(): string 73 | { 74 | return $this->staticPrefix; 75 | } 76 | 77 | public function getRegex(): string 78 | { 79 | return $this->regex; 80 | } 81 | 82 | public function getTokens(): array 83 | { 84 | return $this->tokens; 85 | } 86 | 87 | public function getVariables(): array 88 | { 89 | return $this->variables; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Router/Route.php: -------------------------------------------------------------------------------- 1 | 7 | * @final 8 | */ 9 | class Route implements \Serializable 10 | { 11 | /** 12 | * @var string 13 | */ 14 | protected $pattern = ''; 15 | 16 | /** 17 | * @var callable|string 18 | */ 19 | protected $callback; 20 | 21 | /** 22 | * @var array 23 | */ 24 | private $defaults = []; 25 | 26 | /** 27 | * @var array 28 | */ 29 | protected $requirements = []; 30 | 31 | /** 32 | * @var array 33 | */ 34 | protected $options = []; 35 | 36 | /** 37 | * @var CompiledRoute|null 38 | */ 39 | private $compiled; 40 | 41 | /** 42 | * Constructor. 43 | * 44 | * Available options: 45 | * 46 | * * compiler_class: A class name able to compile this route instance (RouteCompiler by default) 47 | * * utf8: Whether UTF-8 matching is enforced ot not 48 | * 49 | * @param string $pattern The path pattern to match 50 | * @param callable|string $callback A callable function that handles this route or a string to be used with a service locator 51 | * @param array $defaults An array of default parameter values 52 | * @param array $requirements An array of requirements for parameters (regexes) 53 | * @param array $options An array of options 54 | */ 55 | public function __construct(string $pattern, $callback, array $defaults = [], array $requirements = [], array $options = []) 56 | { 57 | $this->setPattern($pattern); 58 | $this->setCallback($callback); 59 | $this->addDefaults($defaults); 60 | $this->addRequirements($requirements); 61 | $this->setOptions($options); 62 | } 63 | 64 | public function serialize() 65 | { 66 | return serialize($this->__serialize()); 67 | } 68 | 69 | public function __serialize(): array 70 | { 71 | return [ 72 | 'pattern' => $this->pattern, 73 | 'callback' => $this->callback, 74 | 'defaults' => $this->defaults, 75 | 'requirements' => $this->requirements, 76 | 'options' => $this->options, 77 | 'compiled' => $this->compiled, 78 | ]; 79 | } 80 | 81 | public function unserialize($serialized): void 82 | { 83 | $this->__unserialize(unserialize($serialized)); 84 | } 85 | 86 | public function __unserialize(array $data): void 87 | { 88 | $this->pattern = $data['pattern']; 89 | $this->callback = $data['callback']; 90 | $this->defaults = $data['defaults']; 91 | $this->requirements = $data['requirements']; 92 | $this->options = $data['options']; 93 | $this->compiled = $data['compiled']; 94 | } 95 | 96 | public function getPattern(): string 97 | { 98 | return $this->pattern; 99 | } 100 | 101 | public function setPattern(string $pattern): self 102 | { 103 | if (false !== strpbrk($pattern, '?<')) { 104 | $pattern = preg_replace_callback('#\{(\w++)(<.*?>)?(\?[^\}]*+)?\}#', function ($m): string { 105 | if (isset($m[3][0])) { 106 | $this->setDefault($m[1], '?' !== $m[3] ? substr($m[3], 1) : null); 107 | } 108 | 109 | if (isset($m[2][0])) { 110 | $this->setRequirement($m[1], substr($m[2], 1, -1)); 111 | } 112 | 113 | return '{'.$m[1].'}'; 114 | }, $pattern); 115 | } 116 | 117 | $this->pattern = trim($pattern); 118 | $this->compiled = null; 119 | 120 | return $this; 121 | } 122 | 123 | /** 124 | * @return callable|string 125 | */ 126 | public function getCallback() 127 | { 128 | return $this->callback; 129 | } 130 | 131 | /** 132 | * @param callable|string $callback 133 | * 134 | * @throws \InvalidArgumentException if the callback is not a valid type 135 | */ 136 | public function setCallback($callback): self 137 | { 138 | if (!\is_callable($callback) && !\is_string($callback)) { 139 | throw new \InvalidArgumentException(sprintf('The callback for a route must be a PHP callable or a string, a "%s" was given.', \gettype($callback))); 140 | } 141 | 142 | $this->callback = $callback; 143 | 144 | return $this; 145 | } 146 | 147 | public function getDefaults(): array 148 | { 149 | return $this->defaults; 150 | } 151 | 152 | public function setDefaults(array $defaults): self 153 | { 154 | $this->defaults = []; 155 | 156 | return $this->addDefaults($defaults); 157 | } 158 | 159 | public function addDefaults(array $defaults): self 160 | { 161 | foreach ($defaults as $name => $default) { 162 | $this->defaults[$name] = $default; 163 | } 164 | 165 | $this->compiled = null; 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * @return mixed 172 | */ 173 | public function getDefault(string $name) 174 | { 175 | return $this->defaults[$name] ?? null; 176 | } 177 | 178 | public function hasDefault(string $name): bool 179 | { 180 | return isset($this->defaults[$name]); 181 | } 182 | 183 | /** 184 | * @param mixed $default 185 | */ 186 | public function setDefault(string $name, $default): self 187 | { 188 | $this->defaults[$name] = $default; 189 | $this->compiled = null; 190 | 191 | return $this; 192 | } 193 | 194 | public function getRequirements(): array 195 | { 196 | return $this->requirements; 197 | } 198 | 199 | public function setRequirements(array $requirements): self 200 | { 201 | $this->requirements = []; 202 | 203 | return $this->addRequirements($requirements); 204 | } 205 | 206 | public function addRequirements(array $requirements): self 207 | { 208 | foreach ($requirements as $key => $regex) { 209 | $this->requirements[$key] = $this->sanitizeRequirement($key, $regex); 210 | } 211 | 212 | $this->compiled = null; 213 | 214 | return $this; 215 | } 216 | 217 | public function getRequirement(string $key): ?string 218 | { 219 | return $this->requirements[$key] ?? null; 220 | } 221 | 222 | public function hasRequirement(string $key): bool 223 | { 224 | return isset($this->requirements[$key]); 225 | } 226 | 227 | public function setRequirement(string $key, string $regex): self 228 | { 229 | $this->requirements[$key] = $this->sanitizeRequirement($key, $regex); 230 | $this->compiled = null; 231 | 232 | return $this; 233 | } 234 | 235 | public function getOptions(): array 236 | { 237 | return $this->options; 238 | } 239 | 240 | public function setOptions(array $options): self 241 | { 242 | $this->options = [ 243 | 'compiler_class' => RouteCompiler::class, 244 | ]; 245 | 246 | return $this->addOptions($options); 247 | } 248 | 249 | public function addOptions(array $options): self 250 | { 251 | foreach ($options as $name => $option) { 252 | $this->options[$name] = $option; 253 | } 254 | 255 | $this->compiled = null; 256 | 257 | return $this; 258 | } 259 | 260 | /** 261 | * @param mixed $value 262 | */ 263 | public function setOption(string $name, $value): self 264 | { 265 | $this->options[$name] = $value; 266 | $this->compiled = null; 267 | 268 | return $this; 269 | } 270 | 271 | /** 272 | * @return mixed The option value or null when not given 273 | */ 274 | public function getOption(string $name) 275 | { 276 | return $this->options[$name] ?? null; 277 | } 278 | 279 | public function hasOption(string $name): bool 280 | { 281 | return isset($this->options[$name]); 282 | } 283 | 284 | /** 285 | * @throws \LogicException if the Route cannot be compiled because the pattern is invalid 286 | */ 287 | public function compile(): CompiledRoute 288 | { 289 | if (null !== $this->compiled) { 290 | return $this->compiled; 291 | } 292 | 293 | $class = $this->getOption('compiler_class'); 294 | 295 | return $this->compiled = $class::compile($this); 296 | } 297 | 298 | /** 299 | * @throws \InvalidArgumentException if a requirement value is empty 300 | */ 301 | private function sanitizeRequirement(string $key, string $regex): string 302 | { 303 | if ('' !== $regex && '^' === $regex[0]) { 304 | $regex = (string) substr($regex, 1); // returns false for a single character 305 | } 306 | 307 | if ('$' === substr($regex, -1)) { 308 | $regex = substr($regex, 0, -1); 309 | } 310 | 311 | if ('' === $regex) { 312 | throw new \InvalidArgumentException(sprintf('Routing requirement for "%s" cannot be empty.', $key)); 313 | } 314 | 315 | return $regex; 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/Router/RouteCollection.php: -------------------------------------------------------------------------------- 1 | 10 | * @final 11 | * 12 | * @implements \IteratorAggregate 13 | */ 14 | class RouteCollection implements \Countable, \IteratorAggregate 15 | { 16 | /** 17 | * @var array 18 | */ 19 | protected $routes = []; 20 | 21 | /** 22 | * @var array 23 | */ 24 | private $resources = []; 25 | 26 | /** 27 | * @param array $routes 28 | */ 29 | public function __construct(array $routes = []) 30 | { 31 | foreach ($routes as $routeName => $route) { 32 | $this->add($routeName, $route); 33 | } 34 | } 35 | 36 | public function __clone() 37 | { 38 | foreach ($this->routes as $name => $route) { 39 | $this->routes[$name] = clone $route; 40 | } 41 | } 42 | 43 | /** 44 | * @return \ArrayIterator 45 | */ 46 | #[ReturnTypeWillChange] 47 | public function getIterator() 48 | { 49 | return new \ArrayIterator($this->routes); 50 | } 51 | 52 | /** 53 | * @return int 54 | */ 55 | #[ReturnTypeWillChange] 56 | public function count() 57 | { 58 | return \count($this->routes); 59 | } 60 | 61 | public function add(string $name, Route $route): void 62 | { 63 | $this->routes[$name] = $route; 64 | } 65 | 66 | /** 67 | * @return array 68 | */ 69 | public function all(): array 70 | { 71 | return $this->routes; 72 | } 73 | 74 | public function get(string $name): ?Route 75 | { 76 | return $this->routes[$name] ?? null; 77 | } 78 | 79 | /** 80 | * @param string|string[] $name 81 | */ 82 | public function remove($name): void 83 | { 84 | foreach ((array) $name as $n) { 85 | unset($this->routes[$n]); 86 | } 87 | } 88 | 89 | public function addCollection(self $collection): void 90 | { 91 | // we need to remove all routes with the same names first because just replacing them 92 | // would not place the new route at the end of the merged array 93 | foreach ($collection->all() as $name => $route) { 94 | unset($this->routes[$name]); 95 | $this->routes[$name] = $route; 96 | } 97 | 98 | foreach ($collection->getResources() as $resource) { 99 | $this->addResource($resource); 100 | } 101 | } 102 | 103 | /** 104 | * Adds defaults to all routes. 105 | * 106 | * An existing default value under the same name in a route will be overridden. 107 | * 108 | * @param array $defaults 109 | */ 110 | public function addDefaults(array $defaults): void 111 | { 112 | if ($defaults) { 113 | foreach ($this->routes as $route) { 114 | $route->addDefaults($defaults); 115 | } 116 | } 117 | } 118 | 119 | /** 120 | * Adds requirements to all routes. 121 | * 122 | * An existing requirement under the same name in a route will be overridden. 123 | * 124 | * @param array $requirements 125 | */ 126 | public function addRequirements(array $requirements): void 127 | { 128 | if ($requirements) { 129 | foreach ($this->routes as $route) { 130 | $route->addRequirements($requirements); 131 | } 132 | } 133 | } 134 | 135 | /** 136 | * Adds options to all routes. 137 | * 138 | * An existing option value under the same name in a route will be overridden. 139 | * 140 | * @param array $options 141 | */ 142 | public function addOptions(array $options): void 143 | { 144 | if ($options) { 145 | foreach ($this->routes as $route) { 146 | $route->addOptions($options); 147 | } 148 | } 149 | } 150 | 151 | /** 152 | * @return ResourceInterface[] 153 | */ 154 | public function getResources(): array 155 | { 156 | return array_values($this->resources); 157 | } 158 | 159 | /** 160 | * Adds a resource for this collection. If the resource already exists it is not added. 161 | */ 162 | public function addResource(ResourceInterface $resource): void 163 | { 164 | $key = (string) $resource; 165 | 166 | if (!isset($this->resources[$key])) { 167 | $this->resources[$key] = $resource; 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Router/RouteCompiler.php: -------------------------------------------------------------------------------- 1 | getPattern()); 35 | 36 | return new CompiledRoute( 37 | $result['staticPrefix'], 38 | $result['regex'], 39 | $result['tokens'], 40 | $result['variables'] 41 | ); 42 | } 43 | 44 | /** 45 | * @throws \LogicException if a variable is referenced more than once 46 | * @throws \DomainException if a variable name starts with a digit or if it is too long to be successfully used as 47 | * a PCRE subpattern 48 | */ 49 | private static function compilePattern(Route $route, string $pattern): array 50 | { 51 | $tokens = []; 52 | $variables = []; 53 | $matches = []; 54 | $pos = 0; 55 | $defaultSeparator = '/'; 56 | $useUtf8 = (bool) preg_match('//u', $pattern); 57 | $needsUtf8 = $route->getOption('utf8'); 58 | 59 | if (!$needsUtf8 && $useUtf8 && preg_match('/[\x80-\xFF]/', $pattern)) { 60 | throw new \LogicException(sprintf('Cannot use UTF-8 route patterns without setting the "utf8" option for pattern "%s".', $pattern)); 61 | } 62 | 63 | if (!$useUtf8 && $needsUtf8) { 64 | throw new \LogicException(sprintf('Cannot mix UTF-8 requirements with non-UTF-8 pattern "%s".', $pattern)); 65 | } 66 | 67 | // Match all variables enclosed in "{}" and iterate over them. But we only want to match the innermost variable 68 | // in case of nested "{}", e.g. {foo{bar}}. This in ensured because \w does not match "{" or "}" itself. 69 | preg_match_all('#\{\w+\}#', $pattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); 70 | 71 | foreach ($matches as $match) { 72 | $varName = substr($match[0][0], 1, -1); 73 | // get all static text preceding the current variable 74 | $precedingText = substr($pattern, $pos, $match[0][1] - $pos); 75 | $pos = $match[0][1] + \strlen($match[0][0]); 76 | 77 | if (!\strlen($precedingText)) { 78 | $precedingChar = ''; 79 | } elseif ($useUtf8) { 80 | preg_match('/.$/u', $precedingText, $precedingChar); 81 | $precedingChar = $precedingChar[0]; 82 | } else { 83 | $precedingChar = substr($precedingText, -1); 84 | } 85 | 86 | $isSeparator = '' !== $precedingChar && false !== strpos(static::SEPARATORS, $precedingChar); 87 | 88 | // A PCRE subpattern name must start with a non-digit. Also a PHP variable cannot start with a digit so the 89 | // variable would not be usable as a Controller action argument. 90 | if (preg_match('/^\d/', $varName)) { 91 | throw new \DomainException(sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Please use a different name.', $varName, $pattern)); 92 | } 93 | 94 | if (\in_array($varName, $variables)) { 95 | throw new \LogicException(sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $pattern, $varName)); 96 | } 97 | 98 | if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) { 99 | throw new \DomainException(sprintf('Variable name "%s" cannot be longer than %s characters in route pattern "%s". Please use a shorter name.', $varName, self::VARIABLE_MAXIMUM_LENGTH, $pattern)); 100 | } 101 | 102 | if ($isSeparator && $precedingText !== $precedingChar) { 103 | $tokens[] = ['text', substr($precedingText, 0, -\strlen($precedingChar))]; 104 | } elseif (!$isSeparator && \strlen($precedingText) > 0) { 105 | $tokens[] = ['text', $precedingText]; 106 | } 107 | 108 | $regexp = $route->getRequirement($varName); 109 | 110 | if (null === $regexp) { 111 | $followingPattern = (string) substr($pattern, $pos); 112 | 113 | // Find the next static character after the variable that functions as a separator. By default, this separator and '/' 114 | // are disallowed for the variable. This default requirement makes sure that optional variables can be matched at all 115 | // and that the generating-matching-combination of URLs unambiguous, i.e. the params used for generating the URL are 116 | // the same that will be matched. Example: new Route('/{page}.{_format}', array('_format' => 'html')) 117 | // If {page} would also match the separating dot, {_format} would never match as {page} will eagerly consume everything. 118 | // Also even if {_format} was not optional the requirement prevents that {page} matches something that was originally 119 | // part of {_format} when generating the URL, e.g. _format = 'mobile.html'. 120 | $nextSeparator = self::findNextSeparator($followingPattern, $useUtf8); 121 | $regexp = sprintf( 122 | '[^%s%s]+', 123 | preg_quote($defaultSeparator, self::REGEX_DELIMITER), 124 | $defaultSeparator !== $nextSeparator && '' !== $nextSeparator ? preg_quote($nextSeparator, self::REGEX_DELIMITER) : '' 125 | ); 126 | 127 | if (('' !== $nextSeparator && !preg_match('#^\{\w+\}#', $followingPattern)) || '' === $followingPattern) { 128 | // When we have a separator, which is disallowed for the variable, we can optimize the regex with a possessive 129 | // quantifier. This prevents useless backtracking of PCRE and improves performance by 20% for matching those patterns. 130 | // Given the above example, there is no point in backtracking into {page} (that forbids the dot) when a dot must follow 131 | // after it. This optimization cannot be applied when the next char is no real separator or when the next variable is 132 | // directly adjacent, e.g. '/{x}{y}'. 133 | $regexp .= '+'; 134 | } 135 | } else { 136 | if (!preg_match('//u', $regexp)) { 137 | $useUtf8 = false; 138 | } elseif (!$needsUtf8 && preg_match('/[\x80-\xFF]|(?= 0; --$i) { 161 | $token = $tokens[$i]; 162 | if ('variable' === $token[0] && isset($token[3]) && $route->hasDefault($token[3])) { 163 | $firstOptional = $i; 164 | } else { 165 | break; 166 | } 167 | } 168 | 169 | // compute the matching regexp 170 | $regexp = ''; 171 | 172 | for ($i = 0, $nbToken = \count($tokens); $i < $nbToken; ++$i) { 173 | $regexp .= self::computeRegexp($tokens, $i, $firstOptional); 174 | } 175 | 176 | $regexp = self::REGEX_DELIMITER.'^'.$regexp.'$'.self::REGEX_DELIMITER.'sD'; 177 | 178 | // enable Utf8 matching if really required 179 | if ($needsUtf8) { 180 | $regexp .= 'u'; 181 | 182 | for ($i = 0, $nbToken = \count($tokens); $i < $nbToken; ++$i) { 183 | if ('variable' === $tokens[$i][0]) { 184 | $tokens[$i][] = true; 185 | } 186 | } 187 | } 188 | 189 | return [ 190 | 'staticPrefix' => self::determineStaticPrefix($route, $tokens), 191 | 'regex' => $regexp, 192 | 'tokens' => array_reverse($tokens), 193 | 'variables' => $variables, 194 | ]; 195 | } 196 | 197 | /** 198 | * Determines the longest static prefix possible for a route. 199 | */ 200 | private static function determineStaticPrefix(Route $route, array $tokens): string 201 | { 202 | if (!$tokens) { 203 | return ''; 204 | } 205 | 206 | if ('text' !== $tokens[0][0]) { 207 | return ($route->hasDefault($tokens[0][3]) || '/' === $tokens[0][1]) ? '' : $tokens[0][1]; 208 | } 209 | 210 | $prefix = $tokens[0][1]; 211 | 212 | if (isset($tokens[1][1]) && '/' !== $tokens[1][1] && false === $route->hasDefault($tokens[1][3])) { 213 | $prefix .= $tokens[1][1]; 214 | } 215 | 216 | return $prefix; 217 | } 218 | 219 | /** 220 | * Returns the next static character in the Route pattern that will serve as a separator (or the empty string when none available). 221 | */ 222 | private static function findNextSeparator(string $pattern, bool $useUtf8): string 223 | { 224 | if ('' == $pattern) { 225 | // return empty string if pattern is empty or false (false which can be returned by substr) 226 | return ''; 227 | } 228 | 229 | // first remove all placeholders from the pattern so we can find the next real static character 230 | if ('' === $pattern = preg_replace('#\{\w+\}#', '', $pattern)) { 231 | return ''; 232 | } 233 | 234 | if ($useUtf8) { 235 | preg_match('/^./u', $pattern, $pattern); 236 | } 237 | 238 | return false !== strpos(static::SEPARATORS, $pattern[0]) ? $pattern[0] : ''; 239 | } 240 | 241 | /** 242 | * Computes the regexp used to match a specific token. It can be static text or a subpattern. 243 | * 244 | * @param array $tokens The route tokens 245 | * @param int $index The index of the current token 246 | * @param int $firstOptional The index of the first optional token 247 | * 248 | * @return string The regexp pattern for a single token 249 | */ 250 | private static function computeRegexp(array $tokens, int $index, int $firstOptional): string 251 | { 252 | $token = $tokens[$index]; 253 | 254 | if ('text' === $token[0]) { 255 | // Text tokens 256 | return preg_quote($token[1], self::REGEX_DELIMITER); 257 | } else { 258 | // Variable tokens 259 | if (0 === $index && 0 === $firstOptional) { 260 | // When the only token is an optional variable token, the separator is required 261 | return sprintf('%s(?P<%s>%s)?', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]); 262 | } else { 263 | $regexp = sprintf('%s(?P<%s>%s)', preg_quote($token[1], self::REGEX_DELIMITER), $token[3], $token[2]); 264 | 265 | if ($index >= $firstOptional) { 266 | // Enclose each optional token in a subpattern to make it optional. 267 | // "?:" means it is non-capturing, i.e. the portion of the subject string that 268 | // matched the optional subpattern is not passed back. 269 | $regexp = "(?:$regexp"; 270 | $nbTokens = \count($tokens); 271 | 272 | if ($nbTokens - 1 == $index) { 273 | // Close the optional subpatterns 274 | $regexp .= str_repeat(')?', $nbTokens - $firstOptional - (0 === $firstOptional ? 1 : 0)); 275 | } 276 | } 277 | 278 | return $regexp; 279 | } 280 | } 281 | } 282 | 283 | private static function transformCapturingGroupsToNonCapturings(string $regexp): string 284 | { 285 | for ($i = 0; $i < \strlen($regexp); ++$i) { 286 | if ('\\' === $regexp[$i]) { 287 | ++$i; 288 | continue; 289 | } 290 | 291 | if ('(' !== $regexp[$i] || !isset($regexp[$i + 2])) { 292 | continue; 293 | } 294 | 295 | if ('*' === $regexp[++$i] || '?' === $regexp[$i]) { 296 | ++$i; 297 | continue; 298 | } 299 | 300 | $regexp = substr_replace($regexp, '?:', $i, 0); 301 | ++$i; 302 | } 303 | 304 | return $regexp; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/Router/RouteCompilerInterface.php: -------------------------------------------------------------------------------- 1 | 23 | * @final 24 | */ 25 | class Router implements RouterInterface, WarmableInterface 26 | { 27 | /** 28 | * @var MatcherInterface|null 29 | */ 30 | protected $matcher; 31 | 32 | /** 33 | * @var GeneratorInterface|null 34 | */ 35 | protected $generator; 36 | 37 | /** 38 | * @var LoaderInterface 39 | */ 40 | protected $loader; 41 | 42 | /** 43 | * @var RouteCollection|null 44 | */ 45 | protected $collection; 46 | 47 | /** 48 | * @var array 49 | */ 50 | protected $resources = []; 51 | 52 | /** 53 | * @var array{ 54 | * cache_dir: string|null, 55 | * debug: bool, 56 | * generator_class: class-string, 57 | * generator_base_class: class-string, 58 | * generator_cache_class: non-empty-string|null, 59 | * generator_dumper_class: class-string, 60 | * matcher_class: class-string, 61 | * matcher_base_class: class-string, 62 | * matcher_cache_class: non-empty-string|null, 63 | * matcher_dumper_class: class-string, 64 | * resource_type: string|null, 65 | * } 66 | */ 67 | protected $options; 68 | 69 | /** 70 | * @var string 71 | */ 72 | protected $name; 73 | 74 | /** 75 | * @var ConfigCacheFactoryInterface|null 76 | */ 77 | private $configCacheFactory; 78 | 79 | /** 80 | * @var array|null 81 | */ 82 | private static $cache = []; 83 | 84 | public function __construct(string $name, LoaderInterface $loader, array $resources, array $options = []) 85 | { 86 | $this->name = $name; 87 | $this->loader = $loader; 88 | $this->resources = $resources; 89 | $this->setOptions($options); 90 | } 91 | 92 | /** 93 | * Sets options. 94 | * 95 | * Available options: 96 | * 97 | * * cache_dir: The cache directory (or null to disable caching) 98 | * * debug: Whether to enable debugging or not (false by default) 99 | * * generator_class: The name of a GeneratorInterface implementation 100 | * * generator_base_class: The base class for the dumped generator class 101 | * * generator_cache_class: The class name for the dumped generator class 102 | * * generator_dumper_class: The name of a GeneratorDumperInterface implementation 103 | * * matcher_class: The name of a MatcherInterface implementation 104 | * * matcher_base_class: The base class for the dumped matcher class 105 | * * matcher_cache_class: The class name for the dumped matcher class 106 | * * matcher_dumper_class: The name of a MatcherDumperInterface implementation 107 | * * resource_type: Type hint for the main resource (optional) 108 | * 109 | * @param array $options An array of options 110 | * 111 | * @throws \InvalidArgumentException when an unsupported option is provided 112 | */ 113 | public function setOptions(array $options): void 114 | { 115 | $this->options = [ 116 | 'cache_dir' => null, 117 | 'debug' => false, 118 | 'generator_class' => Generator::class, 119 | 'generator_base_class' => Generator::class, 120 | 'generator_dumper_class' => CompiledGeneratorDumper::class, 121 | 'generator_cache_class' => 'Project'.ucfirst(strtolower($this->name)).'Generator', 122 | 'matcher_class' => Matcher::class, 123 | 'matcher_base_class' => Matcher::class, 124 | 'matcher_dumper_class' => CompiledMatcherDumper::class, 125 | 'matcher_cache_class' => 'Project'.ucfirst(strtolower($this->name)).'Matcher', 126 | 'resource_type' => null, 127 | ]; 128 | 129 | // check option names and live merge, if errors are encountered Exception will be thrown 130 | $invalid = []; 131 | 132 | foreach ($options as $key => $value) { 133 | $this->checkDeprecatedOption($key); 134 | 135 | if (\array_key_exists($key, $this->options)) { 136 | $this->options[$key] = $value; 137 | } else { 138 | $invalid[] = $key; 139 | } 140 | } 141 | 142 | if ($invalid) { 143 | throw new \InvalidArgumentException(sprintf('The Router does not support the following options: "%s".', implode('", "', $invalid))); 144 | } 145 | } 146 | 147 | /** 148 | * @param mixed $value 149 | * 150 | * @throws \InvalidArgumentException when an unsupported option is provided 151 | */ 152 | public function setOption(string $key, $value): void 153 | { 154 | if (!\array_key_exists($key, $this->options)) { 155 | throw new \InvalidArgumentException(sprintf('The Router does not support the "%s" option.', $key)); 156 | } 157 | 158 | $this->checkDeprecatedOption($key); 159 | 160 | $this->options[$key] = $value; 161 | } 162 | 163 | /** 164 | * @return mixed 165 | * 166 | * @throws \InvalidArgumentException when an unsupported option is provided 167 | */ 168 | public function getOption(string $key) 169 | { 170 | if (!\array_key_exists($key, $this->options)) { 171 | throw new \InvalidArgumentException(sprintf('The Router does not support the "%s" option.', $key)); 172 | } 173 | 174 | $this->checkDeprecatedOption($key); 175 | 176 | return $this->options[$key]; 177 | } 178 | 179 | public function getCollection(): RouteCollection 180 | { 181 | if (null === $this->collection) { 182 | $this->collection = new RouteCollection(); 183 | 184 | foreach ($this->resources as $resource) { 185 | if (\is_array($resource)) { 186 | $type = isset($resource['type']) && null !== $resource ? $resource['type'] : $this->options['resource_type']; 187 | 188 | $this->collection->addCollection($this->loader->load($resource['resource'], $type)); 189 | } else { 190 | $this->collection->addCollection($this->loader->load($resource, $this->options['resource_type'])); 191 | } 192 | } 193 | } 194 | 195 | return $this->collection; 196 | } 197 | 198 | /** 199 | * Warms up the cache. 200 | * 201 | * @param string $cacheDir The cache directory 202 | * 203 | * @return string[] A list of classes to preload on PHP 7.4+ 204 | */ 205 | public function warmUp($cacheDir) 206 | { 207 | $currentDir = $this->getOption('cache_dir'); 208 | 209 | // force cache generation 210 | $this->setOption('cache_dir', $cacheDir); 211 | $this->getMatcher(); 212 | $this->getGenerator(); 213 | 214 | $this->setOption('cache_dir', $currentDir); 215 | 216 | return [ 217 | $this->getOption('generator_class'), 218 | $this->getOption('matcher_class'), 219 | ]; 220 | } 221 | 222 | public function setConfigCacheFactory(ConfigCacheFactoryInterface $configCacheFactory): void 223 | { 224 | $this->configCacheFactory = $configCacheFactory; 225 | } 226 | 227 | public function generate(string $routeName, array $parameters = []): string 228 | { 229 | return $this->getGenerator()->generate($routeName, $parameters); 230 | } 231 | 232 | public function match(string $channel): array 233 | { 234 | return $this->getMatcher()->match($channel); 235 | } 236 | 237 | public function getName(): string 238 | { 239 | return $this->name; 240 | } 241 | 242 | public function getGenerator(): GeneratorInterface 243 | { 244 | if (null !== $this->generator) { 245 | return $this->generator; 246 | } 247 | 248 | $compiled = is_a($this->options['generator_class'], CompiledGenerator::class, true) && Generator::class === $this->options['generator_base_class'] && is_a($this->options['generator_dumper_class'], CompiledGeneratorDumper::class, true); 249 | 250 | if (null === $this->options['cache_dir'] || null === $this->options['generator_cache_class']) { 251 | $routes = $this->getCollection(); 252 | 253 | if ($compiled) { 254 | $routes = (new CompiledGeneratorDumper($routes))->getCompiledRoutes(); 255 | } 256 | 257 | return $this->generator = new $this->options['generator_class']($routes); 258 | } 259 | 260 | $cache = $this->getConfigCacheFactory()->cache($this->options['cache_dir'].'/'.$this->options['generator_cache_class'].'.php', 261 | function (ConfigCacheInterface $cache): void { 262 | $dumper = $this->getGeneratorDumperInstance(); 263 | 264 | $options = [ 265 | 'class' => $this->options['generator_cache_class'], 266 | 'base_class' => $this->options['generator_base_class'], 267 | ]; 268 | 269 | $cache->write($dumper->dump($options), $this->getCollection()->getResources()); 270 | } 271 | ); 272 | 273 | if ($compiled) { 274 | return $this->generator = new $this->options['generator_class'](self::getCompiledRoutes($cache->getPath())); 275 | } 276 | 277 | if (!class_exists($this->options['generator_cache_class'], false)) { 278 | require_once $cache->getPath(); 279 | } 280 | 281 | return $this->generator = new $this->options['generator_cache_class'](); 282 | } 283 | 284 | public function getMatcher(): MatcherInterface 285 | { 286 | if (null !== $this->matcher) { 287 | return $this->matcher; 288 | } 289 | 290 | $compiled = is_a($this->options['matcher_class'], CompiledMatcher::class, true) && Matcher::class === $this->options['matcher_base_class'] && is_a($this->options['matcher_dumper_class'], CompiledMatcherDumper::class, true); 291 | 292 | if (null === $this->options['cache_dir'] || null === $this->options['matcher_cache_class']) { 293 | $routes = $this->getCollection(); 294 | 295 | if ($compiled) { 296 | $routes = (new CompiledMatcherDumper($routes))->getCompiledRoutes(); 297 | } 298 | 299 | return $this->matcher = new $this->options['matcher_class']($routes); 300 | } 301 | 302 | $cache = $this->getConfigCacheFactory()->cache($this->options['cache_dir'].'/'.$this->options['matcher_cache_class'].'.php', 303 | function (ConfigCacheInterface $cache): void { 304 | $dumper = $this->getMatcherDumperInstance(); 305 | 306 | $options = [ 307 | 'class' => $this->options['matcher_cache_class'], 308 | 'base_class' => $this->options['matcher_base_class'], 309 | ]; 310 | 311 | $cache->write($dumper->dump($options), $this->getCollection()->getResources()); 312 | } 313 | ); 314 | 315 | if ($compiled) { 316 | return $this->matcher = new $this->options['matcher_class'](self::getCompiledRoutes($cache->getPath())); 317 | } 318 | 319 | if (!class_exists($this->options['matcher_cache_class'], false)) { 320 | require_once $cache->getPath(); 321 | } 322 | 323 | return $this->matcher = new $this->options['matcher_cache_class'](); 324 | } 325 | 326 | protected function getGeneratorDumperInstance(): GeneratorDumperInterface 327 | { 328 | return new $this->options['generator_dumper_class']($this->getCollection()); 329 | } 330 | 331 | protected function getMatcherDumperInstance(): MatcherDumperInterface 332 | { 333 | return new $this->options['matcher_dumper_class']($this->getCollection()); 334 | } 335 | 336 | /** 337 | * Provides the ConfigCache factory implementation, falling back to a default implementation if necessary. 338 | */ 339 | private function getConfigCacheFactory(): ConfigCacheFactoryInterface 340 | { 341 | if (null === $this->configCacheFactory) { 342 | $this->configCacheFactory = new ConfigCacheFactory($this->options['debug']); 343 | } 344 | 345 | return $this->configCacheFactory; 346 | } 347 | 348 | private function checkDeprecatedOption(string $key): void 349 | { 350 | switch ($key) { 351 | case 'generator_base_class': 352 | case 'generator_cache_class': 353 | case 'matcher_base_class': 354 | case 'matcher_cache_class': 355 | trigger_deprecation('gos/pubsub-router-bundle', '2.4', sprintf('Option "%s" given to router %s is deprecated.', $key, static::class)); 356 | } 357 | } 358 | 359 | private static function getCompiledRoutes(string $path): array 360 | { 361 | if ([] === self::$cache && \function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), FILTER_VALIDATE_BOOLEAN) && (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) || filter_var(ini_get('opcache.enable_cli'), FILTER_VALIDATE_BOOLEAN))) { 362 | self::$cache = null; 363 | } 364 | 365 | if (null === self::$cache) { 366 | return require $path; 367 | } 368 | 369 | if (isset(self::$cache[$path])) { 370 | return self::$cache[$path]; 371 | } 372 | 373 | return self::$cache[$path] = require $path; 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /src/Router/RouterInterface.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | interface RouterInterface extends MatcherInterface, GeneratorInterface 12 | { 13 | public function getCollection(): RouteCollection; 14 | 15 | public function getName(): string; 16 | } 17 | -------------------------------------------------------------------------------- /src/Router/RouterRegistry.php: -------------------------------------------------------------------------------- 1 | routers[$router->getName()])) { 18 | throw new \RuntimeException(sprintf('A router named "%s" is already registered.', $router->getName())); 19 | } 20 | 21 | $this->routers[$router->getName()] = $router; 22 | } 23 | 24 | /** 25 | * @throws \InvalidArgumentException if the requested router was not registered 26 | */ 27 | public function getRouter(string $name): RouterInterface 28 | { 29 | if (!$this->hasRouter($name)) { 30 | throw new \InvalidArgumentException(sprintf('A router named "%s" has not been registered.', $name)); 31 | } 32 | 33 | return $this->routers[$name]; 34 | } 35 | 36 | /** 37 | * @return RouterInterface[] 38 | */ 39 | public function getRouters(): array 40 | { 41 | return $this->routers; 42 | } 43 | 44 | public function hasRouter(string $name): bool 45 | { 46 | return isset($this->routers[$name]); 47 | } 48 | } 49 | --------------------------------------------------------------------------------