From 5e7456a895ec2c4aa637608ca879fc25a8aec151 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 21 May 2026 11:22:38 +1200 Subject: [PATCH 1/4] limited invalid ssl to development mode, gating responses behind view access --- API.php | 85 ++++++++++++------ Annotations/AnnotationGenerator.php | 10 ++- tests/Resources/MockAnnotationGenerator.php | 5 ++ tests/Unit/APITest.php | 99 +++++++++++++++++++++ tests/Unit/AnnotationGeneratorTest.php | 28 ++++++ 5 files changed, 198 insertions(+), 29 deletions(-) diff --git a/API.php b/API.php index bd519e6..202237f 100644 --- a/API.php +++ b/API.php @@ -9,10 +9,10 @@ namespace Piwik\Plugins\ApiReference; +use Piwik\Access; use Piwik\Piwik; use Piwik\Plugins\ApiReference\Generation\PluginListProvider; use Piwik\Plugin\Manager; -use Piwik\Plugins\ApiReference\Specs\SpecGenerator; use Piwik\Plugins\ApiReference\Specs\PathResolver; /** @@ -87,9 +87,66 @@ public function getOpenApiSpec(string $pluginName, string $format = 'json'): arr throw new \Exception('OpenAPI spec file contains invalid JSON.'); } + $canViewExampleSite = in_array(1, Access::getInstance()->getSitesIdWithAtLeastViewAccess(), true); + if (!$canViewExampleSite) { + $decodedSpec = $this->removeSuccessfulResponseExamples($decodedSpec); + } + return $decodedSpec; } + /** + * Remove embedded example payloads from successful 200 responses. + * + * @param array $spec + * @return array + */ + protected function removeSuccessfulResponseExamples(array $spec): array + { + if (empty($spec['paths']) || !is_array($spec['paths'])) { + return $spec; + } + + foreach ($spec['paths'] as &$pathItem) { + if (!is_array($pathItem)) { + continue; + } + + foreach ($pathItem as &$operation) { + if ( + !is_array($operation) + || empty($operation['responses']) + || !is_array($operation['responses']) + ) { + continue; + } + + foreach (['200', 200] as $responseCode) { + if ( + empty($operation['responses'][$responseCode]) + || !is_array($operation['responses'][$responseCode]) + || empty($operation['responses'][$responseCode]['content']) + || !is_array($operation['responses'][$responseCode]['content']) + ) { + continue; + } + + foreach ($operation['responses'][$responseCode]['content'] as &$content) { + if (!is_array($content)) { + continue; + } + + unset($content['example'], $content['examples']); + } + unset($content); + } + } + unset($operation); + } + unset($pathItem); + + return $spec; + } protected function getSpecFilePath(string $pluginName): string { return $this->getSpecPathResolver()->getSpecFilePath($pluginName); @@ -131,30 +188,4 @@ protected function getPluginListProvider(): PluginListProvider return new PluginListProvider(); } - /** - * Generates an OpenAPI specification for one or more plugins and returns it immediately. - * - * @param string $plugin The plugin name to generate, or a comma-separated list of plugin names. - * @param string $format The response format to generate. Supported values are `json` and `yaml`. - * @return array|string The generated OpenAPI specification as decoded JSON data for - * `json`, or as a YAML string for `yaml`. - */ - public function getGeneratedOpenApiSpec(string $plugin, string $format) - { - Piwik::checkUserHasSomeViewAccess(); - - // Return an error if format is something other than JSON or YAML - $allowedFormats = ['json', 'yaml']; - if (!in_array(strtolower($format), $allowedFormats)) { - throw new \Exception( - Piwik::translate( - 'General_ExceptionInvalidReportRendererFormat', - [$format, implode(', ', $allowedFormats)] - ) - ); - } - - $docString = (new SpecGenerator())->generatePluginDoc($plugin, $format); - return strtolower($format) === 'json' ? json_decode($docString, true) : $docString; - } } diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index a6ef232..4b69be7 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -20,6 +20,7 @@ use Piwik\API\NoDefaultValue; use Piwik\API\Proxy; use Piwik\API\Request; +use Piwik\Development; use Piwik\Http; use Piwik\Piwik; use Piwik\Plugin\Manager; @@ -1026,7 +1027,7 @@ protected function getDemoReportMetadata(): array $file = null, $followDepth = 0, $acceptLanguage = false, - $acceptInvalidSslCertificate = true, + $acceptInvalidSslCertificate = $this->shouldAcceptInvalidSslCertificate(), $byteRange = false, $getExtendedInfo = true, $httpMethod = 'GET' @@ -1102,7 +1103,7 @@ protected function getExampleIfAvailable(string $url, bool $useLocalToken = fals $file = null, $followDepth = 0, $acceptLanguage = false, - $acceptInvalidSslCertificate = true, + $acceptInvalidSslCertificate = $this->shouldAcceptInvalidSslCertificate(), $byteRange = false, $getExtendedInfo = true, $httpMethod = 'GET' @@ -1192,6 +1193,11 @@ protected function writeFile(string $filePath, string $contents) return $this->artifactWriter->writeFile($filePath, $contents); } + protected function shouldAcceptInvalidSslCertificate(): bool + { + return Development::isEnabled(); + } + protected function getInstanceUrl(): string { return rtrim(SettingsPiwik::getPiwikUrl(), '/') . '/'; diff --git a/tests/Resources/MockAnnotationGenerator.php b/tests/Resources/MockAnnotationGenerator.php index e0d6248..f0a173d 100644 --- a/tests/Resources/MockAnnotationGenerator.php +++ b/tests/Resources/MockAnnotationGenerator.php @@ -110,4 +110,9 @@ public function shouldUseParameterLevelExample(array $typesMap, string $example) { return parent::shouldUseParameterLevelExample($typesMap, $example); } + + public function shouldAcceptInvalidSslCertificate(): bool + { + return parent::shouldAcceptInvalidSslCertificate(); + } } diff --git a/tests/Unit/APITest.php b/tests/Unit/APITest.php index 70a6387..61a1ea4 100644 --- a/tests/Unit/APITest.php +++ b/tests/Unit/APITest.php @@ -62,6 +62,46 @@ public function testGetOpenApiSpecReturnsDecodedJsonForPlugin() $this->assertSame($expectedSpec, $result); } + public function testGetOpenApiSpecKeepsSuccessfulExamplesForUsersWithSiteOneAccess(): void + { + StaticContainer::getContainer()->set(Access::class, new FakeAccess(false, [], [1], 'siteOneViewer')); + + $expectedSpec = $this->getSpecFixtureWithResponseExamples(); + $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', true, json_encode($expectedSpec)); + + $result = $api->getOpenApiSpec('CustomAlerts'); + + $this->assertSame($expectedSpec, $result); + } + + public function testGetOpenApiSpecRemovesOnlySuccessfulExamplesForUsersWithoutSiteOneAccess(): void + { + StaticContainer::getContainer()->set(Access::class, new FakeAccess(false, [], [2], 'otherViewer')); + + $api = $this->buildApiMock( + '/tmp/CustomAlerts_openapi_spec_v1.0.0.json', + true, + json_encode($this->getSpecFixtureWithResponseExamples()) + ); + + $result = $api->getOpenApiSpec('CustomAlerts'); + + $this->assertArrayNotHasKey('example', $result['paths']['/endpoint']['get']['responses']['200']['content']['application/json']); + $this->assertArrayNotHasKey('examples', $result['paths']['/endpoint']['get']['responses']['200']['content']['application/json']); + $this->assertSame( + 'kept error example', + $result['paths']['/endpoint']['get']['responses']['400']['content']['application/json']['example'] + ); + $this->assertSame( + ['type' => 'string', 'example' => 'stay put'], + $result['paths']['/endpoint']['get']['parameters'][0]['schema'] + ); + $this->assertSame( + ['value' => ['id' => 99]], + $result['paths']['/endpoint']['get']['requestBody']['content']['application/json']['examples']['request'] + ); + } + public function testGetAllowedPluginsReturnsProviderValues(): void { $provider = $this->createMock(PluginListProvider::class); @@ -171,6 +211,65 @@ private function buildApiMock(string $filePath, bool $isReadable, $fileContents return $api; } + /** + * @return array + */ + private function getSpecFixtureWithResponseExamples(): array + { + return [ + 'openapi' => '3.1.0', + 'paths' => [ + '/endpoint' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'label', + 'schema' => [ + 'type' => 'string', + 'example' => 'stay put', + ], + ], + ], + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'examples' => [ + 'request' => [ + 'value' => ['id' => 99], + ], + ], + ], + ], + ], + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'example' => ['value' => 'remove me'], + 'examples' => [ + 'success' => [ + 'value' => ['another' => 'remove me'], + ], + ], + ], + ], + ], + '400' => [ + 'description' => 'Error', + 'content' => [ + 'application/json' => [ + 'example' => 'kept error example', + ], + ], + ], + ], + ], + ], + ], + ]; + } + /** * @param object $object * @param string $methodName diff --git a/tests/Unit/AnnotationGeneratorTest.php b/tests/Unit/AnnotationGeneratorTest.php index 8029726..79cc5c5 100644 --- a/tests/Unit/AnnotationGeneratorTest.php +++ b/tests/Unit/AnnotationGeneratorTest.php @@ -16,6 +16,8 @@ use PHPUnit\Framework\TestCase; use Piwik\API\DocumentationGenerator; use Piwik\API\NoDefaultValue; +use Piwik\Config; +use Piwik\Development; use Piwik\Plugins\ApiReference\Annotations\AnnotationGenerator; use Piwik\Plugins\ApiReference\ApiReference; use Piwik\Plugins\ApiReference\tests\Resources\MockAnnotationGenerator; @@ -1319,6 +1321,25 @@ public function testShouldUseParameterLevelExampleForScalarArrayUnions(): void $this->assertFalse($annotationGenerator->shouldUseParameterLevelExample(['string' => null, 'array' => 'string'], 'one')); } + public function testShouldAcceptInvalidSslCertificateMatchesDevelopmentMode(): void + { + $annotationGenerator = new MockAnnotationGenerator(new DocumentationGenerator()); + $defaultValue = Config::getInstance()->Development['enabled'] ?? 0; + + try { + Config::getInstance()->Development['enabled'] = 0; + $this->resetDevelopmentModeCache(); + $this->assertFalse($annotationGenerator->shouldAcceptInvalidSslCertificate()); + + Config::getInstance()->Development['enabled'] = 1; + $this->resetDevelopmentModeCache(); + $this->assertTrue($annotationGenerator->shouldAcceptInvalidSslCertificate()); + } finally { + Config::getInstance()->Development['enabled'] = $defaultValue; + $this->resetDevelopmentModeCache(); + } + } + /** * @dataProvider getTestDataForWrapStringWithQuotes * @@ -1415,4 +1436,11 @@ public function testCompileOperationLines(): void // TODO - compileOperationLines method $this->expectNotToPerformAssertions(); } + + private function resetDevelopmentModeCache(): void + { + $reflection = new \ReflectionProperty(Development::class, 'isEnabled'); + $reflection->setAccessible(true); + $reflection->setValue(null, null); + } } From 6ec85e4d8fc83efbbd4ffd360232fa8eae0624a6 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 21 May 2026 18:51:58 +1200 Subject: [PATCH 2/4] Super user access now required, now getting responses using superuser token --- API.php | 32 ++++++++++++---- Annotations/AnnotationGenerator.php | 12 +++++- lang/en.json | 1 + tests/Resources/MockAnnotationGenerator.php | 9 ++++- tests/Unit/AnnotationGeneratorTest.php | 42 +++++++++++++++++++++ 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/API.php b/API.php index 202237f..bdae09e 100644 --- a/API.php +++ b/API.php @@ -9,7 +9,6 @@ namespace Piwik\Plugins\ApiReference; -use Piwik\Access; use Piwik\Piwik; use Piwik\Plugins\ApiReference\Generation\PluginListProvider; use Piwik\Plugin\Manager; @@ -25,6 +24,8 @@ */ class API extends \Piwik\Plugin\API { + private const TRY_IT_OUT_NOTE_TRANSLATION_KEY = 'ApiReference_UseTryItOutForLiveResponse'; + /** * Returns the plugin names used for ApiReference spec generation. * @@ -87,8 +88,7 @@ public function getOpenApiSpec(string $pluginName, string $format = 'json'): arr throw new \Exception('OpenAPI spec file contains invalid JSON.'); } - $canViewExampleSite = in_array(1, Access::getInstance()->getSitesIdWithAtLeastViewAccess(), true); - if (!$canViewExampleSite) { + if (!Piwik::hasUserSuperUserAccess()) { $decodedSpec = $this->removeSuccessfulResponseExamples($decodedSpec); } @@ -125,18 +125,29 @@ protected function removeSuccessfulResponseExamples(array $spec): array if ( empty($operation['responses'][$responseCode]) || !is_array($operation['responses'][$responseCode]) - || empty($operation['responses'][$responseCode]['content']) + ) { + continue; + } + + if ( + !empty($operation['responses'][$responseCode]['description']) + && is_string($operation['responses'][$responseCode]['description']) + && strpos($operation['responses'][$responseCode]['description'], $this->getTryItOutNote()) === false + ) { + $operation['responses'][$responseCode]['description'] .= $this->getTryItOutNote(); + } + + if ( + empty($operation['responses'][$responseCode]['content']) || !is_array($operation['responses'][$responseCode]['content']) ) { continue; } foreach ($operation['responses'][$responseCode]['content'] as &$content) { - if (!is_array($content)) { - continue; + if (is_array($content)) { + unset($content['example'], $content['examples']); } - - unset($content['example'], $content['examples']); } unset($content); } @@ -178,6 +189,11 @@ protected function validateJsonFormat(string $format): void } } + protected function getTryItOutNote(): string + { + return "\n\n" . Piwik::translate(self::TRY_IT_OUT_NOTE_TRANSLATION_KEY); + } + protected function getSpecPathResolver(): PathResolver { return new PathResolver(); diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 4b69be7..8216e24 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -109,7 +109,7 @@ public function __construct( DocumentationGenerator $generator, ?PathResolver $pathResolver = null, ?ArtifactWriter $artifactWriter = null, - bool $allowLocalRequests = false + bool $allowLocalRequests = true ) { $this->generator = $generator; $this->pathResolver = $pathResolver ?? new PathResolver(); @@ -1404,7 +1404,7 @@ protected function determineResponses(array $rules, string $plugin, string $meth $exampleValue = $this->getExampleIfAvailable($url); // If the example lookup failed, try making the same request locally using a local token. if (empty($exampleValue)) { - if ($this->allowLocalRequests) { + if ($this->shouldAllowLocalRequests()) { $exampleValue = $this->getExampleIfAvailable($url, true); } } @@ -2188,4 +2188,12 @@ protected function shouldUseParameterLevelExample(array $typesMap, string $examp return is_array(json_decode($example, true)); } + + protected function shouldAllowLocalRequests(): bool + { + $allowLocalRequests = $this->allowLocalRequests; + Piwik::postEvent('ApiReference.shouldAllowLocalRequests', [&$allowLocalRequests]); + + return $allowLocalRequests; + } } diff --git a/lang/en.json b/lang/en.json index aff663b..a0b3add 100644 --- a/lang/en.json +++ b/lang/en.json @@ -9,6 +9,7 @@ "SwaggerPageSpecLoadFailed": "The OpenAPI spec could not be loaded for this plugin.", "SwaggerPageSearchNoResults": "No plugins match your search.", "SwaggerPageSearchPlaceholder": "Search by plugin name", + "UseTryItOutForLiveResponse": "Example responses require Super User access. Use Try it out to see a live response.", "UserAuthentication": "User authentication", "UserAuthenticationManageTokens": "You can manage your authentication tokens on your security page.", "UserAuthenticationUsingTokenAuth": "If you want to request data within a script, a crontab, etc. you need to add the '%3$s' URL parameter to the API calls for URLs that require authentication." diff --git a/tests/Resources/MockAnnotationGenerator.php b/tests/Resources/MockAnnotationGenerator.php index f0a173d..6dd20e1 100644 --- a/tests/Resources/MockAnnotationGenerator.php +++ b/tests/Resources/MockAnnotationGenerator.php @@ -16,9 +16,9 @@ class MockAnnotationGenerator extends AnnotationGenerator { - public function __construct(DocumentationGenerator $generator) + public function __construct(DocumentationGenerator $generator, bool $allowLocalRequests = true) { - parent::__construct($generator); + parent::__construct($generator, null, null, $allowLocalRequests); // TODO - Extend the constructor behaviour } @@ -115,4 +115,9 @@ public function shouldAcceptInvalidSslCertificate(): bool { return parent::shouldAcceptInvalidSslCertificate(); } + + public function shouldAllowLocalRequests(): bool + { + return parent::shouldAllowLocalRequests(); + } } diff --git a/tests/Unit/AnnotationGeneratorTest.php b/tests/Unit/AnnotationGeneratorTest.php index 79cc5c5..15bfecb 100644 --- a/tests/Unit/AnnotationGeneratorTest.php +++ b/tests/Unit/AnnotationGeneratorTest.php @@ -18,6 +18,7 @@ use Piwik\API\NoDefaultValue; use Piwik\Config; use Piwik\Development; +use Piwik\Piwik; use Piwik\Plugins\ApiReference\Annotations\AnnotationGenerator; use Piwik\Plugins\ApiReference\ApiReference; use Piwik\Plugins\ApiReference\tests\Resources\MockAnnotationGenerator; @@ -176,11 +177,25 @@ class AnnotationGeneratorTest extends TestCase */ private static $exampleSchemas; + /** + * @var bool + */ + private static $disableLocalRequestsByEvent = false; + /** * @var AnnotationGenerator */ private $annotationGenerator; + public static function setUpBeforeClass(): void + { + Piwik::addAction('ApiReference.shouldAllowLocalRequests', function (&$allowLocalRequests): void { + if (self::$disableLocalRequestsByEvent) { + $allowLocalRequests = false; + } + }); + } + public function setUp(): void { $this->annotationGenerator = new AnnotationGenerator(new DocumentationGenerator()); @@ -372,6 +387,33 @@ public function getExampleIfAvailable(string $url, bool $useLocalToken = false, ); } + public function testShouldAllowLocalRequestsDefaultsToTrue(): void + { + $annotationGenerator = new MockAnnotationGenerator(new DocumentationGenerator()); + + $this->assertTrue($annotationGenerator->shouldAllowLocalRequests()); + } + + public function testShouldAllowLocalRequestsCanBeDisabledByConstructor(): void + { + $annotationGenerator = new MockAnnotationGenerator(new DocumentationGenerator(), false); + + $this->assertFalse($annotationGenerator->shouldAllowLocalRequests()); + } + + public function testShouldAllowLocalRequestsCanBeDisabledByEvent(): void + { + self::$disableLocalRequestsByEvent = true; + + try { + $annotationGenerator = new MockAnnotationGenerator(new DocumentationGenerator()); + + $this->assertFalse($annotationGenerator->shouldAllowLocalRequests()); + } finally { + self::$disableLocalRequestsByEvent = false; + } + } + public function testGetParamInfoFromDocBlock(): void { // TODO - Update to use resource file and/or dataprovider to test more than one comment block From 31a47c1ca4c499a5132b2670cb2d54f44048f802 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 21 May 2026 19:08:40 +1200 Subject: [PATCH 3/4] cleaned up code --- API.php | 75 ++++++++++++++++++++------------------ tests/Unit/APITest.php | 83 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 38 deletions(-) diff --git a/API.php b/API.php index bdae09e..81b39db 100644 --- a/API.php +++ b/API.php @@ -113,44 +113,11 @@ protected function removeSuccessfulResponseExamples(array $spec): array } foreach ($pathItem as &$operation) { - if ( - !is_array($operation) - || empty($operation['responses']) - || !is_array($operation['responses']) - ) { + if (!is_array($operation)) { continue; } - foreach (['200', 200] as $responseCode) { - if ( - empty($operation['responses'][$responseCode]) - || !is_array($operation['responses'][$responseCode]) - ) { - continue; - } - - if ( - !empty($operation['responses'][$responseCode]['description']) - && is_string($operation['responses'][$responseCode]['description']) - && strpos($operation['responses'][$responseCode]['description'], $this->getTryItOutNote()) === false - ) { - $operation['responses'][$responseCode]['description'] .= $this->getTryItOutNote(); - } - - if ( - empty($operation['responses'][$responseCode]['content']) - || !is_array($operation['responses'][$responseCode]['content']) - ) { - continue; - } - - foreach ($operation['responses'][$responseCode]['content'] as &$content) { - if (is_array($content)) { - unset($content['example'], $content['examples']); - } - } - unset($content); - } + $this->sanitizeSuccessfulResponse($operation); } unset($operation); } @@ -158,6 +125,44 @@ protected function removeSuccessfulResponseExamples(array $spec): array return $spec; } + + /** + * Remove examples from a successful 200 response and append the try-it-out note once. + * + * @param array $operation + */ + protected function sanitizeSuccessfulResponse(array &$operation): void + { + if (empty($operation['responses']) || !is_array($operation['responses'])) { + return; + } + + if (!isset($operation['responses']['200']) || !is_array($operation['responses']['200'])) { + return; + } + + $successfulResponse = &$operation['responses']['200']; + + if ( + !empty($successfulResponse['description']) + && is_string($successfulResponse['description']) + && strpos($successfulResponse['description'], $this->getTryItOutNote()) === false + ) { + $successfulResponse['description'] .= $this->getTryItOutNote(); + } + + if (empty($successfulResponse['content']) || !is_array($successfulResponse['content'])) { + return; + } + + foreach ($successfulResponse['content'] as &$content) { + if (is_array($content)) { + unset($content['example'], $content['examples'], $content['schema']); + } + } + unset($content); + } + protected function getSpecFilePath(string $pluginName): string { return $this->getSpecPathResolver()->getSpecFilePath($pluginName); diff --git a/tests/Unit/APITest.php b/tests/Unit/APITest.php index 61a1ea4..0e45dc7 100644 --- a/tests/Unit/APITest.php +++ b/tests/Unit/APITest.php @@ -62,9 +62,9 @@ public function testGetOpenApiSpecReturnsDecodedJsonForPlugin() $this->assertSame($expectedSpec, $result); } - public function testGetOpenApiSpecKeepsSuccessfulExamplesForUsersWithSiteOneAccess(): void + public function testGetOpenApiSpecKeepsSuccessfulExamplesForSuperUsers(): void { - StaticContainer::getContainer()->set(Access::class, new FakeAccess(false, [], [1], 'siteOneViewer')); + StaticContainer::getContainer()->set(Access::class, new FakeAccess(true, [], [1], 'superUser')); $expectedSpec = $this->getSpecFixtureWithResponseExamples(); $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', true, json_encode($expectedSpec)); @@ -83,11 +83,13 @@ public function testGetOpenApiSpecRemovesOnlySuccessfulExamplesForUsersWithoutSi true, json_encode($this->getSpecFixtureWithResponseExamples()) ); + $expectedTryItOutNote = $this->callProtectedMethod($api, 'getTryItOutNote'); $result = $api->getOpenApiSpec('CustomAlerts'); $this->assertArrayNotHasKey('example', $result['paths']['/endpoint']['get']['responses']['200']['content']['application/json']); $this->assertArrayNotHasKey('examples', $result['paths']['/endpoint']['get']['responses']['200']['content']['application/json']); + $this->assertArrayNotHasKey('schema', $result['paths']['/endpoint']['get']['responses']['200']['content']['application/json']); $this->assertSame( 'kept error example', $result['paths']['/endpoint']['get']['responses']['400']['content']['application/json']['example'] @@ -100,6 +102,69 @@ public function testGetOpenApiSpecRemovesOnlySuccessfulExamplesForUsersWithoutSi ['value' => ['id' => 99]], $result['paths']['/endpoint']['get']['requestBody']['content']['application/json']['examples']['request'] ); + $this->assertSame( + 'Success' . $expectedTryItOutNote, + $result['paths']['/endpoint']['get']['responses']['200']['description'] + ); + } + + public function testGetOpenApiSpecDoesNotDuplicateTryItOutNote(): void + { + StaticContainer::getContainer()->set(Access::class, new FakeAccess(false, [], [2], 'otherViewer')); + + $spec = $this->getSpecFixtureWithResponseExamples(); + $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', true, json_encode($spec)); + $expectedTryItOutNote = $this->callProtectedMethod($api, 'getTryItOutNote'); + $spec['paths']['/endpoint']['get']['responses']['200']['description'] .= $expectedTryItOutNote; + $api->method('readSpecFile')->willReturn(json_encode($spec)); + + $result = $api->getOpenApiSpec('CustomAlerts'); + + $this->assertSame( + 'Success' . $expectedTryItOutNote, + $result['paths']['/endpoint']['get']['responses']['200']['description'] + ); + } + + public function testRemoveSuccessfulResponseExamplesLeavesOperationWithoutResponsesUnchanged(): void + { + $api = new API(); + $spec = [ + 'paths' => [ + '/endpoint' => [ + 'get' => [ + 'summary' => 'No responses here', + ], + ], + ], + ]; + + $this->assertSame($spec, $this->callProtectedMethod($api, 'removeSuccessfulResponseExamples', [$spec])); + } + + public function testRemoveSuccessfulResponseExamplesLeavesOperationWithoutSuccessfulResponseUnchanged(): void + { + $api = new API(); + $spec = [ + 'paths' => [ + '/endpoint' => [ + 'get' => [ + 'responses' => [ + '400' => [ + 'description' => 'Error', + 'content' => [ + 'application/json' => [ + 'example' => 'keep me', + ], + ], + ], + ], + ], + ], + ], + ]; + + $this->assertSame($spec, $this->callProtectedMethod($api, 'removeSuccessfulResponseExamples', [$spec])); } public function testGetAllowedPluginsReturnsProviderValues(): void @@ -163,7 +228,7 @@ public function testGetOpenApiSpecThrowsExceptionWhenFormatIsInvalid() $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', true, '{}'); $this->expectException(\Exception::class); - $this->expectExceptionMessage("Report format 'yaml' not valid"); + $this->expectExceptionMessage('General_ExceptionInvalidReportRendererFormat'); $api->getOpenApiSpec('CustomAlerts', 'yaml'); } @@ -246,6 +311,18 @@ private function getSpecFixtureWithResponseExamples(): array 'description' => 'Success', 'content' => [ 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'row' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'additionalProperties' => true, + ], + ], + ], + ], 'example' => ['value' => 'remove me'], 'examples' => [ 'success' => [ From 884f6080e65011c68fd9974510f7fc6fc958252e Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 21 May 2026 19:23:42 +1200 Subject: [PATCH 4/4] fix test --- API.php | 1 - tests/Unit/APITest.php | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/API.php b/API.php index 81b39db..28721df 100644 --- a/API.php +++ b/API.php @@ -208,5 +208,4 @@ protected function getPluginListProvider(): PluginListProvider { return new PluginListProvider(); } - } diff --git a/tests/Unit/APITest.php b/tests/Unit/APITest.php index 0e45dc7..52f215c 100644 --- a/tests/Unit/APITest.php +++ b/tests/Unit/APITest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\TestCase; use Piwik\Access; use Piwik\Container\StaticContainer; +use Piwik\Piwik; use Piwik\Plugins\ApiReference\API; use Piwik\Plugins\ApiReference\Generation\PluginListProvider; use Piwik\Plugins\ApiReference\Specs\PathResolver; @@ -228,7 +229,9 @@ public function testGetOpenApiSpecThrowsExceptionWhenFormatIsInvalid() $api = $this->buildApiMock('/tmp/CustomAlerts_openapi_spec_v1.0.0.json', true, '{}'); $this->expectException(\Exception::class); - $this->expectExceptionMessage('General_ExceptionInvalidReportRendererFormat'); + $this->expectExceptionMessage( + Piwik::translate('General_ExceptionInvalidReportRendererFormat', ['yaml', 'json']) + ); $api->getOpenApiSpec('CustomAlerts', 'yaml'); }