diff --git a/tools/raml2html/README.md b/tools/raml2html/README.md index 3dde5dbd2c..1a9ab55df2 100644 --- a/tools/raml2html/README.md +++ b/tools/raml2html/README.md @@ -4,15 +4,39 @@ Tool for generating static HTML from RAML definitions. ## Installation -Install required dependencies before use. Go to raml2html root directory and run: +PHP 8 and [Composer](https://getcomposer.org/) are required. -``` -composer install +To install required dependencies, go to raml2html directory and run: + +```sh +composer install; ``` -To generate static HTML from RAML definitions, use the following code: +## Usage +### Generate + +To generate static HTML from RAML definitions, use the following code from project root: ```sh php tools/raml2html/raml2html.php build --non-standard-http-methods=COPY,MOVE,PUBLISH,SWAP -t default -o docs/api/rest_api/rest_api_reference/ docs/api/rest_api/rest_api_reference/input/ibexa.raml ``` + +### Compare documented routes with DXP configured routes + +To test static HTML against an Ibexa DXP to find routes missing from the doc, and routes removed from the DXP: + +```sh +php tools/raml2html/raml2html.php test:compare ~/ibexa-dxp +``` + +Note: The Ibexa DXP doesn't need to run. + +```shell +mkdir ~/ibexa-dxp; +cd ~/ibexa-dxp; +composer create-project ibexa/commerce-skeleton . --no-install --ignore-platform-reqs --no-scripts; +composer install --ignore-platform-reqs --no-scripts; +cd -; +php tools/raml2html/raml2html.php test:compare ~/ibexa-dxp; +``` diff --git a/tools/raml2html/composer.json b/tools/raml2html/composer.json index 07e47581f2..cc813dad37 100644 --- a/tools/raml2html/composer.json +++ b/tools/raml2html/composer.json @@ -14,6 +14,10 @@ ], "require": { "php": "^8.0", + "ext-json": "*", + "ext-yaml": "*", + "ext-dom": "*", + "ext-libxml": "*", "symfony/console": "^6.0", "raml-org/raml-php-parser": "^4.8", "twig/twig": "^3.6", diff --git a/tools/raml2html/src/Application.php b/tools/raml2html/src/Application.php index 7485e356c8..028e8b1c5b 100644 --- a/tools/raml2html/src/Application.php +++ b/tools/raml2html/src/Application.php @@ -7,6 +7,9 @@ use EzSystems\Raml2Html\Command\BuildCommand; use EzSystems\Raml2Html\Command\ClearCacheCommand; use EzSystems\Raml2Html\Command\LintTypesCommand; +use EzSystems\Raml2Html\Command\TestCompareCommand; +use EzSystems\Raml2Html\Command\TestLogicCommand; +use EzSystems\Raml2Html\Command\TestTypeUsageCommand; use EzSystems\Raml2Html\Generator\Generator; use EzSystems\Raml2Html\RAML\ParserFactory; use EzSystems\Raml2Html\Twig\Extension\HashExtension; @@ -40,6 +43,9 @@ protected function getDefaultCommands(): array $this->getRamlParserFactory() ), new ClearCacheCommand(self::CACHE_DIR), + new TestCompareCommand(), + new TestTypeUsageCommand(), + new TestLogicCommand(), ]); } diff --git a/tools/raml2html/src/Command/TestCompareCommand.php b/tools/raml2html/src/Command/TestCompareCommand.php new file mode 100644 index 0000000000..bf5250a01d --- /dev/null +++ b/tools/raml2html/src/Command/TestCompareCommand.php @@ -0,0 +1,70 @@ +setName('test:compare') + ->setDescription('Compare REST API Reference documentation with Ibexa DXP routing configuration under /api/ibexa/v2 prefix') + ->setHelp('It is recommended to not use --console-path and --routing-file options while testing the Rest API Reference HTML file against configuration. Those options are used to test that the default configuration file list is up-to-date and other subtleties.') + ->addArgument('ibexa-dxp-root', InputArgument::REQUIRED, 'Path to an Ibexa DXP root directory') + ->addArgument('rest-api-reference', InputArgument::OPTIONAL, 'Path to the REST API Reference HTML file', 'docs/api/rest_api/rest_api_reference/rest_api_reference.html') + ->addOption('console-path', 'c', InputOption::VALUE_OPTIONAL, 'Path to the console relative to Ibexa DXP root directory (if no value, use `bin/console`)', false) + ->addOption('routing-file', 'f', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, 'Path to a routing configuration YAML file relative to Ibexa DXP root directory', ReferenceTester::DEFAULT_FILE_LIST) + ->addOption('tested-routes', 't', InputOption::VALUE_OPTIONAL, + "ref: Test if reference routes are found in the configuration file;\n + conf: Test if configuration routes are found in the reference file;\n + both: Test both.", 'both'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $restApiReference = $input->getArgument('rest-api-reference'); + if (!is_file($restApiReference)) { + $output->writeln("$restApiReference doesn't exist or is not a file."); + return 1; + } + + $dxpRoot = $input->getArgument('ibexa-dxp-root'); + if (!is_dir($dxpRoot)) { + $output->writeln("$dxpRoot doesn't exist or is not a directory."); + return 2; + } + + $consolePath = $input->getOption('console-path'); + if (null === $consolePath) { + $consolePath = 'bin/console'; + } + + $routingFiles = $input->getOption('routing-file'); + + $referenceTester = new ReferenceTester($restApiReference, $dxpRoot, $consolePath, $routingFiles, $output); + + $testedRoutes = [ + 'ref' => ReferenceTester::TEST_REFERENCE_ROUTES, + 'conf' => ReferenceTester::TEST_CONFIG_ROUTES, + 'both' => ReferenceTester::TEST_ALL_ROUTES, + ][$input->getOption('tested-routes')] ?? ReferenceTester::TEST_ALL_ROUTES; + + $referenceTester->run($testedRoutes); + + return Command::SUCCESS; + } +} diff --git a/tools/raml2html/src/Command/TestLogicCommand.php b/tools/raml2html/src/Command/TestLogicCommand.php new file mode 100644 index 0000000000..cc47b0921c --- /dev/null +++ b/tools/raml2html/src/Command/TestLogicCommand.php @@ -0,0 +1,171 @@ +setName('test:logic') + ->setDescription('Check REST logic in RAML files') + ->addArgument('raml-input-dir', InputArgument::OPTIONAL, 'Path to the REST API Reference\'s RAML input directory', 'docs/api/rest_api/rest_api_reference/input') + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $dir = $input->getArgument('raml-input-dir'); + $files = shell_exec("find $dir -type f -name '*.raml'"); + if (!empty($files)) { + foreach (explode(PHP_EOL, trim($files)) as $file) { + $output->writeln("$file"); + $definitions = yaml_parse_file($file); + if (is_array($definitions)) { + $this->checkDefinitions($file, yaml_parse_file($file), $output); + } + } + } + + return Command::SUCCESS; + } + + private function checkDefinitions(string $parent, array $definitions, OutputInterface $output) + { + foreach ($definitions as $key => $definition) { + if ('/' === $key[0]) { // $key is a route + if (is_array($definition)) { + $this->checkDefinitions($key, $definition, $output); + } + } + switch ($key) { // $key is a method + case 'get': + $this->checkUselessRequestHeader($parent, $key, $definition, 'Content-Type', $output);// Used in rare case of GET with a body… + $this->checkUselessRequestBody($parent, $key, $definition, $output);// … + $this->checkMandatoryRequestHeader($parent, $key, $definition, 'Accept', $output); + $this->checkAcceptHeaderAgainstResponseBody($parent, $key, $definition, $output); + break; + case 'post': + //$this->checkMandatoryHeader($parent, $key, $definition, 'Content-Type', $output);// POST may not have a payload like `POST /content/objects/{contentId}/hide` + //$this->checkUselessRequestBody($parent, $key, $definition, $output);// … + //$this->checkMandatoryHeader($parent, $key, $definition, 'Accept', $output);// …and may return 204 No Content. + $this->checkAcceptHeaderAgainstResponseBody($parent, $key, $definition, $output); + $this->checkContentTypeHeaderAgainstRequestBody($parent, $key, $definition, $output); + break; + case 'patch': + $this->checkMandatoryRequestHeader($parent, $key, $definition, 'Content-Type', $output); + $this->checkMandatoryRequestBody($parent, $key, $definition, $output); + $this->checkMandatoryRequestHeader($parent, $key, $definition, 'Accept', $output); + $this->checkAcceptHeaderAgainstResponseBody($parent, $key, $definition, $output); + $this->checkContentTypeHeaderAgainstRequestBody($parent, $key, $definition, $output); + break; + case 'delete': + //$this->checkUselessRequestHeader($parent, $key, $definition, 'Content-Type', $output);// Can need a payload to precise what to delete + //$this->checkUselessRequestBody($parent, $key, $definition, $output);// … + //$this->checkUselessHeader($parent, $key, $definition, 'Accept', $output);// Can return the updated "parent"/"container"/"list" + $this->checkAcceptHeaderAgainstResponseBody($parent, $key, $definition, $output); + $this->checkContentTypeHeaderAgainstRequestBody($parent, $key, $definition, $output); + break; + } + } + } + + private function checkUselessRequestHeader($route, $method, $methodDefinition, $header, $output) + { + if (array_key_exists('headers', $methodDefinition) && array_key_exists($header, $methodDefinition['headers'])) { + $output->writeln("$method $route may not need a '$header' request header."); + } + } + + private function checkMandatoryRequestHeader($route, $method, $methodDefinition, $header, $output) + { + if (!array_key_exists('headers', $methodDefinition) || !array_key_exists($header, $methodDefinition['headers'])) { + if ('Accept' === $header && array_key_exists('responses', $methodDefinition)) { + if (!array_key_exists('200', array_keys($methodDefinition['responses'])) && empty(array_intersect(['301', '307'], array_keys($methodDefinition['responses'])))) { + // No need for an Accept header when expecting a redirection, right? + $output->writeln("$method $route needs a '$header' request header."); + } + } else { + $output->writeln("$method $route needs a '$header' request header."); + } + } + } + + private function checkUselessRequestBody($route, $method, $methodDefinition, $output) + { + if (array_key_exists('body', $methodDefinition)) { + $output->writeln("$method $route may not need a request body."); + } + } + + private function checkMandatoryRequestBody($route, $method, $methodDefinition, $output) + { + if (!array_key_exists('body', $methodDefinition)) { + $output->writeln("$method $route needs a request body."); + } + } + + private function checkAcceptHeaderAgainstResponseBody($route, $method, $methodDefinition, $output) + { + if (array_key_exists('headers', $methodDefinition) + && array_key_exists('Accept', $methodDefinition['headers']) + && array_key_exists('example', $methodDefinition['headers']['Accept']) + && array_key_exists('responses', $methodDefinition) + ) { + if (array_key_exists('200', $methodDefinition['responses'])) { + if (array_key_exists('body', $methodDefinition['responses']['200'])) { + $acceptedTypes = explode(PHP_EOL, trim($methodDefinition['headers']['Accept']['example'])); + $returnedTypes = array_keys($methodDefinition['responses']['200']['body']); + $missingAcceptedType = array_diff($returnedTypes, $acceptedTypes); + $missingReturnedType = array_diff($acceptedTypes, $returnedTypes); + if (!empty($missingAcceptedType) || !empty($missingReturnedType)) { + $output->writeln("$method $route 'Accept' header and body doesn't contain the same types."); + if (!empty($missingAcceptedType)) { + $output->writeln("\tThe following are returned but not accepted: ".implode(', ', $missingAcceptedType)); + } + if (!empty($missingReturnedType)) { + $output->writeln("\tThe following are accepted but not returned: ".implode(', ', $missingReturnedType)); + } + } + } + } else if (array_key_exists('204', $methodDefinition['responses'])) { + //Accept header can be used to indicate the format to use in case of returning an error + $output->writeln("$method $route may not need an 'Accept' header as it responses with an HTTP code meaning an empty body."); + } + } + } + + private function checkContentTypeHeaderAgainstRequestBody($route, $method, $methodDefinition, $output) + { + if (array_key_exists('headers', $methodDefinition) + && array_key_exists('Content-Type', $methodDefinition['headers']) + && array_key_exists('example', $methodDefinition['headers']['Content-Type']) + && array_key_exists('body', $methodDefinition)) { + $saidTypes = explode(PHP_EOL, trim($methodDefinition['headers']['Content-Type']['example'])); + $bodyTypes = array_keys($methodDefinition['body']); + $missingSaidType = array_diff($bodyTypes, $saidTypes); + $missingBodyType = array_diff($saidTypes, $bodyTypes); + if (!empty($missingSaidType) || !empty($missingBodyType)) { + $output->writeln("$method $route 'Content-Type' header and request body doesn't contain the same types."); + if (!empty($missingSaidType)) { + $output->writeln("\tThe following are sent as request body but are not available as Content-Type: ".implode(', ', $missingSaidType)); + } + if (!empty($missingBodyType)) { + $output->writeln("\tThe following can be declared in Content-Type but aren't used in request body: ".implode(', ', $missingBodyType)); + } + } + } + } +} diff --git a/tools/raml2html/src/Command/TestTypeUsageCommand.php b/tools/raml2html/src/Command/TestTypeUsageCommand.php new file mode 100644 index 0000000000..ac632266b7 --- /dev/null +++ b/tools/raml2html/src/Command/TestTypeUsageCommand.php @@ -0,0 +1,116 @@ +setName('test:type:usage') + ->setDescription('Check that types are used') + ->setHelp('Parse ibexa-types.raml and check if each type is used.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->definedTypes = yaml_parse_file('docs/api//rest_api/rest_api_reference/input/ibexa-types.raml'); + $usedByRouteTypeList = []; + $this->usedByOtherTypeTypeList = []; + + foreach ($this->definedTypes as $type => $typeDefinition) { + $usageFileList = shell_exec("grep 'type: $type' -R docs/api/rest_api/rest_api_reference/input | grep -v examples | grep -v ibexa-types.raml"); + if (!empty($usageFileList)) { + $usedByRouteTypeList[] = $type; + } + } + + foreach ($usedByRouteTypeList as $usedByRouteType) { + $definedType = $this->definedTypes[$usedByRouteType]; + if (!array_key_exists('type', $definedType)) { + //TODO: warning + dump($definedType); + } + $usedType = str_ends_with($definedType['type'], '[]') ? substr($definedType['type'], 0, -2) : $definedType['type']; + if (!in_array($usedType, $this->usedByOtherTypeTypeList)) { + $this->usedByOtherTypeTypeList[] = $usedType; + } + $this->exploreProperties($definedType); + } + + $usedTypeList = array_merge($usedByRouteTypeList, $this->usedByOtherTypeTypeList); + $unusedTypeList = array_values(array_diff(array_keys($this->definedTypes), $usedTypeList)); + sort($unusedTypeList); + + if (!empty($unusedTypeList)) { + $style = new SymfonyStyle($input, $output); + $style->title('Unused types'); + $style->listing($unusedTypeList); + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + private function exploreProperties(array $definedType) + { + if (array_key_exists('properties', $definedType)) { + foreach ($definedType['properties'] as $property => $propertyDefinition) { + $usedType = null; + if (is_array($propertyDefinition)) { + if (array_key_exists('type', $propertyDefinition)) { + if ('array' === $propertyDefinition['type']) { + if (is_array($propertyDefinition['items']) && array_key_exists('properties', $propertyDefinition['items'])) { + $this->exploreProperties($propertyDefinition['items']); + } else { + $usedType = $propertyDefinition['items']; + } + } elseif (array_key_exists('properties', $propertyDefinition)) { + $this->exploreProperties($propertyDefinition); + } elseif (str_ends_with($propertyDefinition['type'], '[]')) { + $usedType = substr($propertyDefinition['type'], 0, -2); + } else { + $usedType = $propertyDefinition['type']; + } + } + } else { + if (str_ends_with($propertyDefinition, '[]')) { + $usedType = substr($propertyDefinition, 0, -2); + } else { + $usedType = $propertyDefinition; + } + } + if (null !== $usedType && !in_array($usedType, $this->usedByOtherTypeTypeList)) { + if (!is_string($usedType)) { + //TODO: warning + dump($propertyDefinition, $usedType); + } + $this->usedByOtherTypeTypeList[] = $usedType; + if (array_key_exists($usedType, $this->definedTypes)) { + if (!is_array($this->definedTypes[$usedType])) { + //TODO: warning + dump($usedType, $this->definedTypes[$usedType]); + } + $this->exploreProperties($this->definedTypes[$usedType]); + } + } + } + } + } +} diff --git a/tools/raml2html/src/Test/ReferenceTester.php b/tools/raml2html/src/Test/ReferenceTester.php new file mode 100644 index 0000000000..e74886cfb7 --- /dev/null +++ b/tools/raml2html/src/Test/ReferenceTester.php @@ -0,0 +1,514 @@ +output = $output; + + $this->restApiReference = $restApiReference; + $this->dxpRoot = $dxpRoot; + $this->parseApiReference($this->restApiReference); + $this->parseRoutes($consolePath, $routingFiles); + } + + private function parseApiReference($restApiReference): void + { + $refRoutes = []; + + $restApiRefDoc = new \DOMDocument(); + $restApiRefDoc->preserveWhiteSpace = false; + $restApiRefDoc->loadHTMLFile($restApiReference, LIBXML_NOERROR); + $restApiRefXpath = new \DOMXpath($restApiRefDoc); + + /** @var \DOMElement $urlElement */ + foreach ($restApiRefXpath->query('//*[@data-field="url"]') as $urlElement) { + $route = $urlElement->nodeValue; + if (!array_key_exists($route, $refRoutes)) { + $refRoutes[$route] = [ + 'methods' => [], + ]; + } + $method = $urlElement->previousSibling->previousSibling->nodeValue; + $displayName = trim(str_replace('¶', '', $urlElement->parentNode->parentNode->previousSibling->previousSibling->nodeValue)); + $removed = '(removed)' === substr($displayName, -strlen('(removed)')); + $deprecated = '(deprecated)' === substr($displayName, -strlen('(deprecated)')); + $substitute = null; + if ($removed || $deprecated) { + $matches = []; + if (preg_match('/use (?[A-Z]+) (?[{}\/a-zA-Z]+) instead./', $urlElement->parentNode->nextSibling->nextSibling->nodeValue, $matches)) { + $substitute = array_intersect_key($matches, array_flip(array('method', 'route'))); + if (!array_key_exists($substitute['route'], $refRoutes)) { + $refRoutes[$substitute['route']] = [ + 'methods' => [], + ]; + } + if (!array_key_exists($substitute['method'], $refRoutes[$substitute['route']]['methods'])) { + $refRoutes[$substitute['route']]['methods'][$substitute['method']] = [ + 'replace' => [], + ]; + } + $refRoutes[$substitute['route']]['methods'][$substitute['method']]['replace'][] = [ + 'method' => $method, + 'route' => $route, + ]; + } + } + $replace = []; + if (array_key_exists($method, $refRoutes[$route]['methods'])) { + $replace = $refRoutes[$route]['methods'][$method]['replace']; + } + $refRoutes[$route]['methods'][$method] = [ + 'removed' => $removed, + 'deprecated' => $deprecated, + 'substitute' => $substitute, + 'replace' => $replace, + ]; + } + + $this->refRoutes = $refRoutes; + ksort($this->refRoutes); + } + + private function parseRoutes($consolePath = 'bin/console', $routingFiles = null) + { + if (is_string($consolePath)) { + $this->parseRouterOutput($consolePath); + } elseif (is_array($routingFiles)) { + $this->parseRoutingFiles($routingFiles); + } elseif (is_string($routingFiles)) { + $this->parseRoutingFiles([$routingFiles]); + } else { + $this->parseRoutingFiles(self::DEFAULT_FILE_LIST); + } + ksort($this->confRoutes); + } + + private function parseRouterOutput($consolePath) + { + $confRoutes = []; + + $routerCommand = 'debug:router --format=txt --show-controllers'; + $consolePathLastChar = substr($consolePath, -1); + if (in_array($consolePathLastChar, ['"', "'"])) { + $consoleCommand = substr($consolePath, 0, -1) . " {$routerCommand}{$consolePathLastChar}"; + } else { + $consoleCommand = "$consolePath $routerCommand"; + } + + $routerOutput = shell_exec("cd {$this->dxpRoot} && $consoleCommand | grep '{$this->apiUri}'"); + + foreach (explode("\n", $routerOutput) as $outputLine) { + $outputLine = trim($outputLine); + if (empty($outputLine)) { + continue; + } + $lineParts = preg_split('/\s+/', $outputLine); + $routeProperties = array_combine(['Name', 'Method', 'Scheme', 'Host', 'Path', 'Controller'], $lineParts); + $routeId = $routeProperties['Name']; + $methods = explode('|', $routeProperties['Method']); + $bundle = implode('\\', array_slice(explode('\\', $routeProperties['Controller']), 0, 3)); + if (in_array($bundle, self::EXCLUDED_BUNDLE_LIST)) { + continue; + } + foreach ($methods as $method) { + if ('OPTIONS' === $method) { + continue; + } + $routePath = str_replace($this->apiUri, '', $routeProperties['Path']); + if (!array_key_exists($routePath, $confRoutes)) { + $confRoutes[$routePath] = ['methods' => []]; + } + $confRoutes[$routePath]['methods'][$method] = [ + 'id' => $routeId, + 'file' => null, + 'line' => null, + ]; + } + } + + $this->confRoutes = $confRoutes; + } + + private function parseRoutingFiles($routingFiles): void + { + $confRoutes = []; + + $parsedRoutingFiles = []; + foreach ($routingFiles as $routingFile) { + $routingFilePath = "{$this->dxpRoot}/$routingFile"; + if (!is_file($routingFilePath)) { + user_error("$routingFilePath doesn't exist or is not a file", E_USER_WARNING); + continue; + } + $parsedRoutingFiles[$routingFile] = \yaml_parse_file($routingFilePath); + } + + foreach ($parsedRoutingFiles as $routingFile => $parsedRoutingFile) { + foreach ($parsedRoutingFile as $routeId => $routeDef) { + $line = (int)explode(':', `grep -n '^$routeId:$' {$this->dxpRoot}/$routingFile`)[0]; + if (!array_key_exists('path', $routeDef) && array_key_exists('resource', $routeDef)) { + user_error("$routeId in $routingFile imports another file {$routeDef['resource']}", E_USER_WARNING); + continue; + } + if (!array_key_exists('methods', $routeDef)) { + $routeDef['methods'] = self::METHOD_LIST; + } + foreach($this->ignoredApiUris as $ignoredApiUri) { + if (str_starts_with($routeDef['path'], $ignoredApiUri)) { + continue 2; + } + } + if (str_starts_with($routeDef['path'], $this->apiUri)) { + $routeDef['path'] = str_replace($this->apiUri, '', $routeDef['path']); + } + if (!array_key_exists($routeDef['path'], $confRoutes)) { + $confRoutes[$routeDef['path']] = [ + 'methods' => [], + ]; + } + foreach ($routeDef['methods'] as $method) { + $confRoutes[$routeDef['path']]['methods'][$method] = [ + 'id' => $routeId, + 'file' => $routingFile, + 'line' => $line, + ]; + } + } + } + + $this->confRoutes = $confRoutes; + } + + public function run(int $testedRoutes = self::TEST_ALL_ROUTES) + { + $refRoutes = $this->refRoutes; + $confRoutes = $this->confRoutes; + + // Check methods from routes found in both reference and configuration + foreach (array_intersect(array_keys($refRoutes), array_keys($confRoutes)) as $commonRoute) { + $missingMethods = $this->compareMethods($commonRoute, $commonRoute, $testedRoutes); + if (!array_key_exists('GET', $refRoutes[$commonRoute]['methods']) && array_key_exists('HEAD', $refRoutes[$commonRoute]['methods']) + && array_key_exists('GET', $confRoutes[$commonRoute]['methods']) && array_key_exists('HEAD', $confRoutes[$commonRoute]['methods']) + && !is_null($confRoutes[$commonRoute]['methods']['HEAD']['id']) && $confRoutes[$commonRoute]['methods']['GET']['id'] === $confRoutes[$commonRoute]['methods']['HEAD']['id']) { + $this->output("\t$commonRoute has no GET reference but has a HEAD reference, HEAD and GET share the same configuration route id ({$confRoutes[$commonRoute]['methods']['GET']['id']}) so GET might be just a fallback for HEAD."); + } + if (false !== strpos($commonRoute, '{')) { + if (self::TEST_REFERENCE_ROUTES & $testedRoutes && $missingMethods[self::REF_METHOD_NOT_IN_CONF]) { + // Check reference route's methods not found in the configuration against similar routes from configuration + $similarConfRoutes = $this->getSimilarRoutes($commonRoute, $confRoutes); + foreach (['highly', 'poorly'] as $similarityLevel) { + foreach ($similarConfRoutes[$similarityLevel] as $confRoute) { + if ($confRoute === $commonRoute) { + continue; + } + $stillMissingMethod = $this->compareMethods($commonRoute, $confRoute, self::TEST_REFERENCE_ROUTES, $missingMethods[self::REF_METHOD_NOT_IN_CONF]); + $foundMethods = array_diff($missingMethods[self::REF_METHOD_NOT_IN_CONF], $stillMissingMethod[self::REF_METHOD_NOT_IN_CONF]); + if (!empty($foundMethods)) { + foreach ($foundMethods as $foundMethod) { + if ('highly' === $similarityLevel) { + $this->output("\t{$this->getConfRoutePrompt($confRoute)} has $foundMethod and is highly similar to $commonRoute"); + } else { + $this->output("\t{$this->getConfRoutePrompt($confRoute)} has $foundMethod and is a bit similar to $commonRoute"); + } + } + } + } + } + } + if (self::TEST_CONFIG_ROUTES & $testedRoutes) { + // Check configuration route's methods not found in the reference against similar routes from reference + $similarRefRoutes = $this->getSimilarRoutes($commonRoute, $refRoutes); + foreach (['highly', 'poorly'] as $similarityLevel) { + foreach ($similarRefRoutes[$similarityLevel] as $refRoute) { + if ($refRoute === $commonRoute) { + continue; + } + $stillMissingMethod = $this->compareMethods($refRoute, $commonRoute, self::TEST_CONFIG_ROUTES, $missingMethods[self::CONF_METHOD_NOT_IN_REF]); + $foundMethods = array_diff($missingMethods[self::CONF_METHOD_NOT_IN_REF], $stillMissingMethod[self::CONF_METHOD_NOT_IN_REF]); + if (!empty($foundMethods)) { + foreach ($foundMethods as $foundMethod) { + if ('highly' === $similarityLevel) { + $this->output("\t$refRoute has $foundMethod and is highly similar to $commonRoute"); + } else { + $this->output("\t$refRoute has $foundMethod and is a bit similar to $commonRoute"); + } + } + } + } + } + } + } + } + + if (self::TEST_REFERENCE_ROUTES & $testedRoutes) { + // Check reference routes not found in the configuration + foreach (array_diff(array_keys($refRoutes), array_keys($confRoutes)) as $refRouteWithoutConf) { + $this->output("$refRouteWithoutConf not found in config files."); + if (false !== strpos($refRouteWithoutConf, '{')) { + $similarConfRoutes = $this->getSimilarRoutes($refRouteWithoutConf, $confRoutes); + if (!empty($similarConfRoutes['highly'])) { + foreach ($similarConfRoutes['highly'] as $confRoute) { + $this->output("\t$refRouteWithoutConf is highly similar to $confRoute"); + $this->compareMethods($refRouteWithoutConf, $confRoute, self::TEST_REFERENCE_ROUTES); + } + continue; + } + if (!empty($similarConfRoutes['poorly'])) { + foreach ($similarConfRoutes['poorly'] as $confRoute) { + $this->output("\t$refRouteWithoutConf is a bit similar to $confRoute"); + $this->compareMethods($refRouteWithoutConf, $confRoute, self::TEST_REFERENCE_ROUTES); + } + continue; + } + } + foreach ($refRoutes[$refRouteWithoutConf]['methods'] as $method=>$methodStatus) { + if ($methodStatus['removed']) { + $this->output("\t$method $refRouteWithoutConf is flagged as removed"); + } else if ($methodStatus['deprecated']) { + $this->output("\t$method $refRouteWithoutConf is flagged as deprecated and can now be flagged as removed"); + } else { + $this->output("\t$method $refRouteWithoutConf is not flagged."); + } + if ($methodStatus['removed'] || $methodStatus['deprecated']) { + if ($methodStatus['substitute']) { + $this->output("\tand the substitute {$methodStatus['substitute']['method']} {$methodStatus['substitute']['route']} is proposed."); + } else { + $this->output("\twithout substitute proposal."); + } + } + } + } + } + + if (self::TEST_CONFIG_ROUTES & $testedRoutes) { + // Check configuration routes not found in the reference + foreach (array_diff(array_keys($confRoutes), array_keys($refRoutes)) as $confRouteWithoutRef) { + $this->output("{$this->getConfRoutePrompt($confRouteWithoutRef)} not found in reference."); + if (false !== strpos($confRouteWithoutRef, '{')) { + $similarRefRoutes = $this->getSimilarRoutes($confRouteWithoutRef, $refRoutes); + if (!empty($similarRefRoutes['highly'])) { + foreach ($similarRefRoutes['highly'] as $refRoute) { + $this->output("\t$confRouteWithoutRef is highly similar to $refRoute"); + $this->compareMethods($refRoute, $confRouteWithoutRef, self::TEST_CONFIG_ROUTES); + } + continue; + } + if (!empty($similarRefRoutes['poorly'])) { + foreach ($similarRefRoutes['poorly'] as $refRoute) { + $this->output("\t$confRouteWithoutRef is a bit similar to $refRoute"); + $this->compareMethods($refRoute, $confRouteWithoutRef, self::TEST_CONFIG_ROUTES); + } + } + } + } + } + } + + /** + * Compare reference route methods and configuration route methods, output methods missing on one side or the other. + * @param array|null $testedMethods A list of methods to search for and compare; if null, all existing methods are compared + * @return array A list of missing methods + */ + private + function compareMethods(string $refRoute, string $confRoute, int $testedRoutes = self::TEST_ALL_ROUTES, ?array $testedMethods = null): array + { + $refRoutes = $this->refRoutes; + $confRoutes = $this->confRoutes; + $missingMethods = [ + self::REF_METHOD_NOT_IN_CONF => [], + self::CONF_METHOD_NOT_IN_REF => [], + + ]; + + if (self::TEST_REFERENCE_ROUTES & $testedRoutes) { + // Check reference route's methods missing from configuration route + foreach (array_diff(array_keys($refRoutes[$refRoute]['methods']), array_keys($confRoutes[$confRoute]['methods'])) as $refMethodWithoutConf) { + if (null === $testedMethods || in_array($refMethodWithoutConf, $testedMethods)) { + if ($refRoute === $confRoute) { + $this->output("$refRoute: $refMethodWithoutConf not found in configuration."); + } else { + $this->output("\t$refMethodWithoutConf not found in configuration while comparing to $confRoute."); + } + $missingMethods[self::REF_METHOD_NOT_IN_CONF][] = $refMethodWithoutConf; + } + } + } + + if (self::TEST_CONFIG_ROUTES & $testedRoutes) { + // Check configuration route's methods missing from reference route + foreach (array_diff(array_keys($confRoutes[$confRoute]['methods']), array_keys($refRoutes[$refRoute]['methods'])) as $confMethodWithoutRef) { + if (null === $testedMethods || in_array($confMethodWithoutRef, $testedMethods)) { + if ($refRoute === $confRoute) { + $this->output("{$this->getConfRoutePrompt($confRoute, $confMethodWithoutRef)}: $confMethodWithoutRef not found in reference."); + } else { + $this->output("\t$confMethodWithoutRef not found in reference while comparing to $refRoute."); + } + $missingMethods[self::CONF_METHOD_NOT_IN_REF][] = $confMethodWithoutRef; + } + } + } + + return $missingMethods; + } + + private + function getSimilarRoutes(string $path, array $routeCollection): array + { + $routePattern = $this->getRoutePattern($path); + $highlySimilarRoutes = []; + $poorlySimilarRoutes = []; + foreach (array_keys($routeCollection) as $route) { + if (preg_match($routePattern, $route)) { + if ($this->getSimplifiedRoute($route) === $this->getSimplifiedRoute($path)) { + $highlySimilarRoutes[] = $route; + } else { + $poorlySimilarRoutes[] = $route; + } + } + } + return [ + 'highly' => $highlySimilarRoutes, + 'poorly' => $poorlySimilarRoutes, + ]; + } + + private + function getSimplifiedRoute(string $path): string + { + return str_replace(['identifier', 'number', '_', '-'], ['id', 'no', ''], strtolower($path)); + } + + private + function getRoutePattern(string $path): string + { + return '@^' . preg_replace('@\{[^}]+\}@', '\{[^}]+\}', $path) . '$@'; + } + + private + function getConfRoutePrompt(string $path, $method = null): string + { + $prompt = $path; + + if (array_key_exists($path, $this->confRoutes)) { + if ($method && array_key_exists($method, $this->confRoutes[$path]['methods'])) { + if (array_key_exists('file', $this->confRoutes[$path]['methods'][$method]) && !is_null($this->confRoutes[$path]['methods'][$method]['file'])) { + $location = $this->confRoutes[$path]['methods'][$method]['file']; + if (array_key_exists('line', $this->confRoutes[$path]['methods'][$method]) && !is_null($this->confRoutes[$path]['methods'][$method]['line'])) { + $location .= "@{$this->confRoutes[$path]['methods'][$method]['line']}"; + } + $prompt = "$prompt ($location)"; + } + } else { + $files = []; + $lines = []; + $pairs = []; + foreach ($this->confRoutes[$path]['methods'] as $methodDetail) { + if (array_key_exists('file', $methodDetail) && !is_null($methodDetail['file'])) { + $files[] = $methodDetail['file']; + if (array_key_exists('line', $methodDetail) && !is_null($methodDetail['line'])) { + $lines[] = $methodDetail['line']; + $pairs[] = "{$methodDetail['file']}@{$methodDetail['line']}"; + } else { + $pairs[] = $methodDetail['file']; + } + } + } + $filteredFiles = array_unique($files); + if (!empty($filteredFiles)) { + if (1 < count($filteredFiles)) { + $pairs = implode(',', array_unique($pairs)); + $prompt = "$prompt ($pairs)"; + } else { + $file = $filteredFiles[0]; + $lines = implode(',', array_unique($lines)); + $prompt = "$prompt ($file@$lines)"; + } + } + } + } + + return $prompt; + } + + private + function output($message) + { + if ($this->output) { + $this->output->writeln($message); + } else { + echo strip_tags($message) . "\n"; + } + } +}