From 51970e9c7fcdf801c82921cb43f0aa739c0fe251 Mon Sep 17 00:00:00 2001 From: thierrydallacroce Date: Wed, 4 Dec 2019 15:11:16 -0800 Subject: [PATCH] Publicly accessible version of api docs (#264) * Publicly-accessible version of docs endpoint, with updated unit tests * Update the dkan_api docs service --- modules/custom/dkan_api/dkan_api.services.yml | 1 + .../custom/dkan_api/src/Controller/Docs.php | 113 +++++++++++++---- .../tests/src/Unit/Controller/DocsTest.php | 115 ++++++------------ .../Controller/docs/dkan_api_openapi_spec.yml | 22 ++++ 4 files changed, 151 insertions(+), 100 deletions(-) diff --git a/modules/custom/dkan_api/dkan_api.services.yml b/modules/custom/dkan_api/dkan_api.services.yml index b056c64ae..1a7fc4ab8 100644 --- a/modules/custom/dkan_api/dkan_api.services.yml +++ b/modules/custom/dkan_api/dkan_api.services.yml @@ -3,3 +3,4 @@ services: class: \Drupal\dkan_api\Controller\Docs arguments: - '@module_handler' + - '@request_stack' diff --git a/modules/custom/dkan_api/src/Controller/Docs.php b/modules/custom/dkan_api/src/Controller/Docs.php index 32c1dc6a2..61971deb6 100644 --- a/modules/custom/dkan_api/src/Controller/Docs.php +++ b/modules/custom/dkan_api/src/Controller/Docs.php @@ -9,6 +9,7 @@ use Drupal\Core\Serialization\Yaml; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RequestStack; /** * Serves openapi spec for dataset-related endpoints. @@ -29,6 +30,13 @@ class Docs implements ContainerInjectionInterface { */ private $moduleHandler; + /** + * Request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + private $requestStack; + /** * Serializer to translate yaml to json. * @@ -40,25 +48,29 @@ class Docs implements ContainerInjectionInterface { * Inherited. * * @{inheritdocs} - * - * @codeCoverageIgnore */ public static function create(ContainerInterface $container) { - $moduleHandler = $container->get('module_handler'); - return new Docs($moduleHandler); + return new Docs( + $container->get('module_handler'), + $container->get('request_stack') + ); } /** * Constructor. */ - public function __construct(ModuleHandlerInterface $moduleHandler) { + public function __construct( + ModuleHandlerInterface $moduleHandler, + RequestStack $requestStack + ) { $this->moduleHandler = $moduleHandler; + $this->requestStack = $requestStack; $this->serializer = new Yaml(); $this->spec = $this->getJsonFromYmlFile(); } /** - * Ger version. + * Get version. */ public function getVersions() { return new JsonResponse(["version" => 1, "url" => "/api/1"]); @@ -73,36 +85,95 @@ public function getVersions() { public function getJsonFromYmlFile() { $modulePath = $this->moduleHandler->getModule('dkan_api')->getPath(); $ymlSpecPath = $modulePath . '/docs/dkan_api_openapi_spec.yml'; - $ymlSpec = $this->fileGetContents($ymlSpecPath); + $ymlSpec = file_get_contents($ymlSpecPath); return $this->serializer->decode($ymlSpec); } /** - * Wrapper around file_get_contents to facilitate testing. + * Returns the complete API spec. * - * @param string $path - * Path for our yml spec. + * @return \Symfony\Component\HttpFoundation\JsonResponse + * OpenAPI spec response. + */ + public function getComplete() { + if ($this->requestStack->getCurrentRequest()->get('authentication') === "false") { + $spec = $this->getPublic(); + } + else { + $spec = $this->spec; + } + + $jsonSpec = json_encode($spec); + return $this->sendResponse($jsonSpec); + } + + /** + * Return a publicly-accessible version of the API spec. * - * @return false|string - * Our yml file, or FALSE. + * Remove any endpoint requiring authentication, as well as the security + * schemes components from the api spec. * - * @codeCoverageIgnore + * @return array + * The modified API spec, without authentication-related items. */ - private function fileGetContents($path) { - return file_get_contents($path); + private function getPublic() { + $publicSpec = $this->removeAuthenticatedEndpoints($this->spec); + $cleanSpec = $this->cleanUpEndpoints($publicSpec); + unset($cleanSpec['components']['securitySchemes']); + return $cleanSpec; } /** - * Returns the complete API spec. + * Remove API spec endpoints requiring authentication. * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * OpenAPI spec response. + * @param array $spec + * The original spec. + * + * @return array + * The modified API spec, without authenticated endpoints. */ - public function getComplete() { - $jsonSpec = json_encode($this->spec); + private function removeAuthenticatedEndpoints(array $spec) { + foreach ($spec['paths'] as $path => $operations) { + $this->removeAuthenticatedOperations($operations, $path, $spec); + } + return $spec; + } - return $this->sendResponse($jsonSpec); + /** + * Within a path, remove operations requiring authentication. + * + * @param array $operations + * Operations for the current path. + * @param string $path + * The path being processed. + * @param array $spec + * Our modified dataset-specific openapi spec. + */ + private function removeAuthenticatedOperations(array $operations, string $path, array &$spec) { + foreach ($operations as $operation => $details) { + if (isset($spec['paths'][$path][$operation]['security'])) { + unset($spec['paths'][$path][$operation]); + } + } + } + + /** + * Clean up empty endpoints from the spec. + * + * @param array $spec + * The original spec. + * + * @return array + * The cleaned up API spec. + */ + private function cleanUpEndpoints(array $spec) { + foreach ($spec['paths'] as $path => $operations) { + if (empty($operations)) { + unset($spec['paths'][$path]); + } + } + return $spec; } /** diff --git a/modules/custom/dkan_api/tests/src/Unit/Controller/DocsTest.php b/modules/custom/dkan_api/tests/src/Unit/Controller/DocsTest.php index dd4d18c6f..b72170ed7 100644 --- a/modules/custom/dkan_api/tests/src/Unit/Controller/DocsTest.php +++ b/modules/custom/dkan_api/tests/src/Unit/Controller/DocsTest.php @@ -6,108 +6,65 @@ use Drupal\dkan_api\Controller\Docs; use Drupal\dkan_common\Tests\DkanTestBase; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\dkan_common\Tests\Mock\Chain; +use Drupal\dkan_common\Tests\Mock\Options; +use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerInterface; -use Drupal\dkan_data\Storage\Data; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; /** - * Class. + * Test class Docs. */ class DocsTest extends DkanTestBase { - /** - * - */ - public function getContainer() { - // TODO: Change the autogenerated stub. - parent::setUp(); - - $container = $this->getMockBuilder(ContainerInterface::class) - ->setMethods(['get']) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); - - $container->method('get') - ->with( - $this->logicalOr( - $this->equalTo('module_handler'), - $this->equalTo('dkan_data.storage') - ) - ) - ->will($this->returnCallback([$this, 'containerGet'])); - - return $container; - } - - /** - * - */ - public function containerGet($input) { - switch ($input) { - case 'module_handler': - return $this->getModuleHandlerMock(); + public function testGetVersions() { + $mockChain = $this->getCommonMockChain(); + $controller = Docs::create($mockChain->getMock()); + $response = $controller->getVersions(); - case 'dkan_data.storage': - return $this->getDataMock(); + $spec = '{"version":1,"url":"\/api\/1"}'; - break; - } + $this->assertEquals($spec, $response->getContent()); } - /** - * - */ public function testGetComplete() { - $controller = Docs::create($this->getContainer()); - $response = $controller->getComplete(); + $mock = $this->getCommonMockChain() + ->add(RequestStack::class, 'getCurrentRequest',Request::class) + ->add(Request::class, 'get', NULL); + + $spec = '{"openapi":"3.0.1","info":{"title":"API Documentation","version":"Alpha"},"components":{"securitySchemes":{"basicAuth":{"type":"http","scheme":"basic"}}},"paths":{"\/api\/1\/metastore\/schemas\/dataset\/items\/{identifier}":{"get":{"summary":"Get this dataset","tags":["Dataset"],"parameters":[{"name":"identifier","in":"path","description":"Dataset uuid","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Ok"}}},"delete":{"summary":"This operation should not be present in dataset-specific docs.","security":[{"basicAuth":[]}],"responses":{"200":{"description":"Ok"}}},"post":null},"\/api\/1\/some\/other\/path":{"patch":{"summary":"This path and operation should not be present in dataset-specific docs.","security":[{"basicAuth":[]}],"responses":{"200":{"description":"Ok"}}}}}}'; - $spec = '{"openapi":"3.0.1","info":{"title":"API Documentation","version":"Alpha"},"paths":{"\/api\/1\/metastore\/schemas\/dataset\/items\/{identifier}":{"get":{"summary":"Get this dataset","tags":["Dataset"],"parameters":[{"name":"identifier","in":"path","description":"Dataset uuid","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Ok"}}}}}}'; + $controller = Docs::create($mock->getMock()); + $response = $controller->getComplete(); $this->assertEquals($spec, $response->getContent()); } - /** - * - */ - private function getModuleHandlerMock() { - $mock = $this->getMockBuilder(ModuleHandlerInterface::class) - ->disableOriginalConstructor() - ->setMethods(['getModule']) - ->getMockForAbstractClass(); + public function testGetPublic() { + $mock = $this->getCommonMockChain() + ->add(RequestStack::class, 'getCurrentRequest',Request::class) + ->add(Request::class, 'get', 'false'); - $mock->method('getModule') - ->willReturn($this->getExtensionMock()); + $spec = '{"openapi":"3.0.1","info":{"title":"API Documentation","version":"Alpha"},"components":[],"paths":{"\/api\/1\/metastore\/schemas\/dataset\/items\/{identifier}":{"get":{"summary":"Get this dataset","tags":["Dataset"],"parameters":[{"name":"identifier","in":"path","description":"Dataset uuid","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Ok"}}},"post":null}}}'; - return $mock; - } - - /** - * - */ - private function getExtensionMock() { - $mock = $this->getMockBuilder(Extension::class) - ->disableOriginalConstructor() - ->setMethods(['getPath']) - ->getMock(); - - $mock->method('getPath') - ->willReturn(__DIR__); + $controller = Docs::create($mock->getMock()); + $response = $controller->getComplete(); - return $mock; + $this->assertEquals($spec, $response->getContent()); } - /** - * - */ - private function getDataMock() { - $mock = $this->getMockBuilder(Data::class) - ->disableOriginalConstructor() - ->setMethods(['retrieve']) - ->getMock(); + public function getCommonMockChain() { + $options = (new Options()) + ->add('module_handler', ModuleHandlerInterface::class) + ->add('request_stack', RequestStack::class); - $mock->method('retrieve') - ->willReturn("{}"); + $mockChain = (new Chain($this)) + ->add(ContainerInterface::class, 'get', $options) + ->add(ModuleHandlerInterface::class, 'getModule', Extension::class) + ->add(Extension::class, 'getPath', __DIR__); - return $mock; + return $mockChain; } } diff --git a/modules/custom/dkan_api/tests/src/Unit/Controller/docs/dkan_api_openapi_spec.yml b/modules/custom/dkan_api/tests/src/Unit/Controller/docs/dkan_api_openapi_spec.yml index 48e2ba420..ee5b0f4ba 100644 --- a/modules/custom/dkan_api/tests/src/Unit/Controller/docs/dkan_api_openapi_spec.yml +++ b/modules/custom/dkan_api/tests/src/Unit/Controller/docs/dkan_api_openapi_spec.yml @@ -2,6 +2,11 @@ openapi: 3.0.1 info: title: API Documentation version: Alpha +components: + securitySchemes: + basicAuth: + type: http + scheme: basic paths: /api/1/metastore/schemas/dataset/items/{identifier}: get: @@ -19,3 +24,20 @@ paths: responses: 200: description: Ok + delete: + summary: This operation should not be present in dataset-specific docs. + security: + - basicAuth: [] + responses: + 200: + description: Ok + # Though an empty verb invalidates the spec, test its removal by dataset-specific docs. + post: + /api/1/some/other/path: + patch: + summary: This path and operation should not be present in dataset-specific docs. + security: + - basicAuth: [] + responses: + 200: + description: Ok