diff --git a/API.php b/API.php index 6861d94..9311c6e 100644 --- a/API.php +++ b/API.php @@ -24,30 +24,30 @@ class API extends \Piwik\Plugin\API { /** - * Get the pre-generated single OpenAPI spec file if it exists. This endpoint only reads + * Get a pre-generated OpenAPI spec file if it exists. This endpoint only reads * the generated JSON file and does not trigger spec generation. * - * /index.php?module=API&method=OpenApiDocs.getMatomoOpenApiSpec + * /index.php?module=API&method=OpenApiDocs.getOpenApiSpec&spec=CustomAlerts * + * @param string $pluginName Plugin name used in the generated filename. * @param string $format Output format. Only `json` is supported. * @return array The decoded OpenAPI specification payload. * @throws \Exception If the file is missing, unreadable, or contains invalid JSON. */ - public function getMatomoOpenApiSpec(string $format = 'json'): array + public function getOpenApiSpec(string $pluginName, string $format = 'json'): array { Piwik::checkUserHasSomeViewAccess(); - if (strtolower($format) !== 'json') { - throw new \Exception( - Piwik::translate( - 'General_ExceptionInvalidReportRendererFormat', - [$format, 'json'] - ) - ); - } + $this->validateJsonFormat($format); - $filePath = $this->getMatomoSpecFilePath(); + if ( + !Manager::getInstance()->isValidPluginName($pluginName) + || !Manager::getInstance()->isPluginInFilesystem($pluginName) + ) { + throw new \Exception('Invalid plugin name: ' . $pluginName); + } + $filePath = $this->getSpecFilePath($pluginName); if (!$this->isSpecFileReadable($filePath)) { throw new \Exception('OpenAPI spec file was not found. Generate it first via openapidocs:generate-spec-file.'); } @@ -65,11 +65,11 @@ public function getMatomoOpenApiSpec(string $format = 'json'): array return $decodedSpec; } - protected function getMatomoSpecFilePath(): string + protected function getSpecFilePath(string $pluginName): string { $currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs'); - return $currentPluginDir . OpenApiDocs::GENERATED_SPECS_PATH . 'matomo_openapi_spec_v' . OpenApiDocs::DEFAULT_SPEC_VERSION . '.json'; + return $currentPluginDir . OpenApiDocs::GENERATED_SPECS_PATH . $pluginName . '_openapi_spec_v' . OpenApiDocs::DEFAULT_SPEC_VERSION . '.json'; } protected function isSpecFileReadable(string $filePath): bool @@ -86,6 +86,18 @@ protected function readSpecFile(string $filePath) return file_get_contents($filePath); } + protected function validateJsonFormat(string $format): void + { + if (strtolower($format) !== 'json') { + throw new \Exception( + Piwik::translate( + 'General_ExceptionInvalidReportRendererFormat', + [$format, 'json'] + ) + ); + } + } + /** * Get the generated API documentation data for the specified plugin. * diff --git a/tests/Unit/APITest.php b/tests/Unit/APITest.php index a2b3c54..bcc49ce 100644 --- a/tests/Unit/APITest.php +++ b/tests/Unit/APITest.php @@ -43,54 +43,98 @@ protected function tearDown(): void parent::tearDown(); } - public function testGetMatomoOpenApiSpecReturnsDecodedJson() + public function testGetOpenApiSpecReturnsDecodedJsonForPlugin() { $expectedSpec = [ 'openapi' => '3.1.0', 'info' => [ - 'title' => 'Matomo Reporting API', + 'title' => 'Matomo Reporting API for CustomAlerts plugin', 'version' => '1.0.0', ], ]; - $api = $this->buildApiMock(true, json_encode($expectedSpec)); + $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', true, json_encode($expectedSpec)); - $result = $api->getMatomoOpenApiSpec(); + $result = $api->getOpenApiSpec('CustomAlerts'); $this->assertSame($expectedSpec, $result); } - public function testGetMatomoOpenApiSpecThrowsExceptionWhenFileMissing() + public function testGetOpenApiSpecThrowsExceptionWhenFileMissing() { - $api = $this->buildApiMock(false); + $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', false); $this->expectException(\Exception::class); $this->expectExceptionMessage('OpenAPI spec file was not found'); - $api->getMatomoOpenApiSpec(); + $api->getOpenApiSpec('CustomAlerts'); } - public function testGetMatomoOpenApiSpecThrowsExceptionWhenJsonIsInvalid() + public function testGetOpenApiSpecThrowsExceptionWhenJsonIsInvalid() { - $api = $this->buildApiMock(true, '{invalid json}'); + $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', true, '{invalid json}'); $this->expectException(\Exception::class); $this->expectExceptionMessage('OpenAPI spec file contains invalid JSON'); - $api->getMatomoOpenApiSpec(); + $api->getOpenApiSpec('CustomAlerts'); } + public function testGetOpenApiSpecThrowsExceptionWhenFormatIsInvalid() + { + $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', true, '{}'); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('General_ExceptionInvalidReportRendererFormat'); + + $api->getOpenApiSpec('CustomAlerts', 'yaml'); + } + + public function testGetOpenApiSpecThrowsExceptionWhenSpecIsNotAValidPlugin() + { + $api = new API(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid plugin name: DefinitelyNotARealPlugin'); + + $api->getOpenApiSpec('DefinitelyNotARealPlugin'); + } + + public function testGetSpecFilePathUsesPluginSpecificFileName() + { + $api = new API(); + + $this->assertSame( + PIWIK_INCLUDE_PATH . '/plugins/OpenApiDocs/tmp/specs/CustomAlerts_openapi_spec_v1.0.0.json', + $this->callProtectedMethod($api, 'getSpecFilePath', ['CustomAlerts']) + ); + } - private function buildApiMock(bool $isReadable, $fileContents = false): API + + private function buildApiMock(string $filePath, bool $isReadable, $fileContents = false): API { $api = $this->getMockBuilder(API::class) - ->onlyMethods(['getMatomoSpecFilePath', 'isSpecFileReadable', 'readSpecFile']) + ->onlyMethods(['getSpecFilePath', 'isSpecFileReadable', 'readSpecFile']) ->getMock(); - $api->method('getMatomoSpecFilePath')->willReturn('/tmp/matomo_openapi_spec_v1.0.0.json'); + $api->method('getSpecFilePath')->willReturn($filePath); $api->method('isSpecFileReadable')->willReturn($isReadable); $api->method('readSpecFile')->willReturn($fileContents); return $api; } + + /** + * @param object $object + * @param string $methodName + * @param array $arguments + * @return mixed + */ + private function callProtectedMethod($object, string $methodName, array $arguments = []) + { + $reflection = new \ReflectionMethod($object, $methodName); + $reflection->setAccessible(true); + + return $reflection->invokeArgs($object, $arguments); + } }