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";
+ }
+ }
+}