diff --git a/modules/custom/dkan_common/dkan_common.services.yml b/modules/custom/dkan_common/dkan_common.services.yml index 7a892a8c5..8e01f5602 100644 --- a/modules/custom/dkan_common/dkan_common.services.yml +++ b/modules/custom/dkan_common/dkan_common.services.yml @@ -1,5 +1,8 @@ services: - dkan.factory: - class: Drupal\dkan_common\Service\Factory - dkan.json_util: - class: Drupal\dkan_common\Service\JsonUtil + dkan.factory: + class: Drupal\dkan_common\Service\Factory + dkan.json_util: + class: Drupal\dkan_common\Service\JsonUtil + plugin.manager.dkan_common.data_modifier: + class: \Drupal\dkan_common\Plugin\DataModifierManager + parent: default_plugin_manager diff --git a/modules/custom/dkan_common/src/Annotation/DataModifier.php b/modules/custom/dkan_common/src/Annotation/DataModifier.php new file mode 100644 index 000000000..43f0e8eaf --- /dev/null +++ b/modules/custom/dkan_common/src/Annotation/DataModifier.php @@ -0,0 +1,48 @@ +pluginManager->getDefinitions() as $definition) { + $plugins[] = $this->pluginManager->createInstance($definition['id']); + } + return $plugins; + } + +} diff --git a/modules/custom/dkan_common/src/Plugin/DataModifierBase.php b/modules/custom/dkan_common/src/Plugin/DataModifierBase.php new file mode 100644 index 000000000..0dc4d45ad --- /dev/null +++ b/modules/custom/dkan_common/src/Plugin/DataModifierBase.php @@ -0,0 +1,32 @@ +getPluginDefinition()['result']->render(); + } + + /** + * Return the http code annotation. + * + * @return int + * The http code. + */ + public function httpCode() : int { + return (int) $this->getPluginDefinition()['code']; + } + +} diff --git a/modules/custom/dkan_common/src/Plugin/DataModifierInterface.php b/modules/custom/dkan_common/src/Plugin/DataModifierInterface.php new file mode 100644 index 000000000..c549c42fa --- /dev/null +++ b/modules/custom/dkan_common/src/Plugin/DataModifierInterface.php @@ -0,0 +1,60 @@ +alterInfo('dkan_common_data_modifier_info'); + $this->setCacheBackend($cache_backend, 'dkan_common_data_modifier_plugins'); + } + +} diff --git a/modules/custom/dkan_common/tests/src/Unit/Plugin/DataModifierManagerTest.php b/modules/custom/dkan_common/tests/src/Unit/Plugin/DataModifierManagerTest.php new file mode 100644 index 000000000..23ca73e9f --- /dev/null +++ b/modules/custom/dkan_common/tests/src/Unit/Plugin/DataModifierManagerTest.php @@ -0,0 +1,29 @@ +assertTrue(is_object($manager)); + } + +} diff --git a/modules/custom/dkan_metastore/dkan_metastore.services.yml b/modules/custom/dkan_metastore/dkan_metastore.services.yml index 384ae156d..9c4a0269a 100644 --- a/modules/custom/dkan_metastore/dkan_metastore.services.yml +++ b/modules/custom/dkan_metastore/dkan_metastore.services.yml @@ -4,6 +4,8 @@ services: arguments: - '@dkan_schema.schema_retriever' - '@dkan_metastore.sae_factory' + calls: + - [setDataModifierPlugins, ['@plugin.manager.dkan_common.data_modifier']] dkan_metastore.sae_factory: class: \Drupal\dkan_metastore\Factory\Sae arguments: diff --git a/modules/custom/dkan_metastore/src/Service.php b/modules/custom/dkan_metastore/src/Service.php index b61fbe150..962ffa9ac 100644 --- a/modules/custom/dkan_metastore/src/Service.php +++ b/modules/custom/dkan_metastore/src/Service.php @@ -2,6 +2,8 @@ namespace Drupal\dkan_metastore; +use Drupal\dkan_common\DataModifierPluginTrait; +use Drupal\dkan_common\Plugin\DataModifierManager; use Symfony\Component\DependencyInjection\ContainerInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\dkan_metastore\Factory\Sae; @@ -14,6 +16,7 @@ * Service. */ class Service implements ContainerInjectionInterface { + use DataModifierPluginTrait; /** * SAE Factory. @@ -49,6 +52,17 @@ public function __construct(SchemaRetriever $schemaRetriever, Sae $saeFactory) { $this->saeFactory = $saeFactory; } + /** + * Setter to discover data modifier plugins. + * + * @param \Drupal\dkan_common\Plugin\DataModifierManager $pluginManager + * Injected plugin manager. + */ + public function setDataModifierPlugins(DataModifierManager $pluginManager) { + $this->pluginManager = $pluginManager; + $this->plugins = $this->discover(); + } + /** * Get schemas. */ @@ -86,7 +100,10 @@ public function getAll($schema_id): array { // $datasets is an array of JSON encoded string. Needs to be unflattened. $unflattened = array_map( - function ($json_string) { + function ($json_string) use ($schema_id) { + if (!empty($this->plugins)) { + $json_string = $this->modifyData($schema_id, $json_string); + } return json_decode($json_string); }, $datasets @@ -107,8 +124,35 @@ function ($json_string) { * The json data. */ public function get($schema_id, $identifier): string { - return $this->getEngine($schema_id) + $data = $this->getEngine($schema_id) ->get($identifier); + if (!empty($this->plugins)) { + $data = $this->modifyData($schema_id, $data); + } + return $data; + } + + /** + * Provides data modifiers plugins an opportunity to act. + * + * @param string $schema_id + * The {schema_id} slug from the HTTP request. + * @param string $data + * The Json input. + * + * @return string + * The Json, modified by each applicable discovered data modifier plugins. + */ + private function modifyData(string $schema_id, string $data) { + $dataObj = json_decode($data); + + foreach ($this->plugins as $plugin) { + if ($plugin->requiresModification($schema_id, $dataObj)) { + $dataObj = $plugin->modify($schema_id, $dataObj); + } + } + + return json_encode($dataObj); } /** diff --git a/modules/custom/dkan_metastore/src/WebServiceApiDocs.php b/modules/custom/dkan_metastore/src/WebServiceApiDocs.php index 7e4eaa0a9..b163c2c7d 100644 --- a/modules/custom/dkan_metastore/src/WebServiceApiDocs.php +++ b/modules/custom/dkan_metastore/src/WebServiceApiDocs.php @@ -2,6 +2,8 @@ namespace Drupal\dkan_metastore; +use Drupal\dkan_common\DataModifierPluginTrait; +use Drupal\dkan_common\Plugin\DataModifierManager; use Symfony\Component\DependencyInjection\ContainerInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\dkan_common\JsonResponseTrait; @@ -13,6 +15,7 @@ */ class WebServiceApiDocs implements ContainerInjectionInterface { use JsonResponseTrait; + use DataModifierPluginTrait; /** * List of endpoints to keep for dataset-specific docs. @@ -47,7 +50,8 @@ class WebServiceApiDocs implements ContainerInjectionInterface { public static function create(ContainerInterface $container) { return new WebServiceApiDocs( $container->get("dkan_api.docs"), - $container->get("dkan_metastore.service") + $container->get("dkan_metastore.service"), + $container->get('plugin.manager.dkan_common.data_modifier') ); } @@ -58,10 +62,15 @@ public static function create(ContainerInterface $container) { * Serves openapi spec for dataset-related endpoints. * @param \Drupal\dkan_metastore\Service $metastoreService * Metastore service. + * @param \Drupal\dkan_common\Plugin\DataModifierManager $pluginManager + * Metastore plugin manager. */ - public function __construct(Docs $docsController, Service $metastoreService) { + public function __construct(Docs $docsController, Service $metastoreService, DataModifierManager $pluginManager) { $this->docsController = $docsController; $this->metastoreService = $metastoreService; + $this->pluginManager = $pluginManager; + + $this->plugins = $this->discover(); } /** @@ -109,6 +118,26 @@ private function modifyDatasetEndpoint(array $spec, string $identifier) { return $spec; } + /** + * Provides data modifiers plugins an opportunity to act. + * + * @param string $identifier + * The distribution's identifier. + * + * @return bool + * TRUE if sql endpoint docs needs to be protected, FALSE otherwise. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + private function modifyData(string $identifier) { + foreach ($this->plugins as $plugin) { + if ($plugin->requiresModification('distribution', $identifier)) { + return TRUE; + } + } + return FALSE; + } + /** * Modify the generic sql endpoint to be specific to the current dataset. * @@ -121,7 +150,10 @@ private function modifyDatasetEndpoint(array $spec, string $identifier) { * Spec with dataset-specific datastore sql endpoint. */ private function modifySqlEndpoint(array $spec, string $identifier) { - if (isset($spec['paths']['/api/1/datastore/sql'])) { + if ($this->modifyData($identifier)) { + unset($spec['paths']['/api/1/datastore/sql']); + } + elseif (isset($spec['paths']['/api/1/datastore/sql'])) { unset($spec['paths']['/api/1/datastore/sql']['get']['parameters']); $spec = $this->replaceDistributions($spec, $identifier); $spec['tags'][] = ["name" => "SQL Query"]; @@ -196,15 +228,37 @@ private function replaceDistributions(array $spec, string $identifier) { // Create and customize a path for each dataset distribution/resource. if (isset($data->distribution)) { foreach ($data->distribution as $dist) { - $path = "/api/1/datastore/sql?query=[SELECT * FROM {$dist->identifier}];"; - - $spec['paths'][$path] = $spec['paths']['/api/1/datastore/sql']; - $spec['paths'][$path]['get']['summary'] = $dist->data->title ?? ""; - $spec['paths'][$path]['get']['description'] = $dist->data->description ?? ""; + $spec = $this->replaceDistribution($dist, $spec, $identifier); } unset($spec['paths']['/api/1/datastore/sql']); } return $spec; } + /** + * Replace a single distribution within the spec. + * + * @param mixed $dist + * A distribution object. + * @param array $spec + * The original spec array. + * @param string $identifier + * The dataset uuid. + * + * @return array + * Modified spec. + */ + private function replaceDistribution($dist, array $spec, string $identifier) { + $path = "/api/1/datastore/sql?query=[SELECT * FROM {$dist->identifier}];"; + + $spec['paths'][$path] = $spec['paths']['/api/1/datastore/sql']; + if (isset($dist->data->title)) { + $spec['paths'][$path]['get']['summary'] = $dist->data->title; + } + if (isset($dist->data->description)) { + $spec['paths'][$path]['get']['description'] = $dist->data->description; + } + return $spec; + } + } diff --git a/modules/custom/dkan_metastore/tests/src/Unit/WebServiceApiDocsTest.php b/modules/custom/dkan_metastore/tests/src/Unit/WebServiceApiDocsTest.php index 27f1c7888..3c23b73dd 100644 --- a/modules/custom/dkan_metastore/tests/src/Unit/WebServiceApiDocsTest.php +++ b/modules/custom/dkan_metastore/tests/src/Unit/WebServiceApiDocsTest.php @@ -2,6 +2,9 @@ namespace Drupal\Tests\dkan_metastore\Unit; +use Drupal\Core\Database\Query\SelectInterface; +use Drupal\dkan_common\Plugin\DataModifierManager; +use Drupal\dkan_common\Plugin\DataModifierBase; use PHPUnit\Framework\TestCase; use Drupal\Core\Serialization\Yaml; use Drupal\dkan_api\Controller\Docs; @@ -17,15 +20,48 @@ class WebServiceApiDocsTest extends TestCase { /** - * + * Tests dataset-specific docs without data modifier plugin. */ - public function testGetDatasetSpecific() { - $mockChain = $this->getCommonMockChain(); + public function testDatasetSpecificDocsWithoutSqlModifier() { + $dataset = json_encode([ + 'distribution' => [ + [ + 'identifier' => 'dist-1234', + 'data' => [ + 'title' => 'Title', + 'description' => 'Description', + ], + ], + ], + ]); + + $mockChain = $this->getCommonMockChain() + ->add(Service::class, "get", $dataset) + ->add(DataModifierManager::class, 'getDefinitions', []) + ->add(SelectInterface::class, 'fetchCol', []); $controller = WebServiceApiDocs::create($mockChain->getMock()); $response = $controller->getDatasetSpecific(1); - $spec = '{"openapi":"3.0.1","info":{"title":"API Documentation","version":"Alpha"},"tags":[{"name":"Dataset"},{"name":"SQL Query"}],"paths":{"\/api\/1\/datastore\/sql":{"get":{"summary":"Query resources","tags":["SQL Query"],"responses":{"200":{"description":"Ok"}}}},"\/api\/1\/metastore\/schemas\/dataset\/items\/1":{"get":{"summary":"Get this dataset","tags":["Dataset"],"responses":{"200":{"description":"Ok"}}}}}}'; + $spec = '{"openapi":"3.0.1","info":{"title":"API Documentation","version":"Alpha"},"tags":[{"name":"Dataset"},{"name":"SQL Query"}],"paths":{"\/api\/1\/metastore\/schemas\/dataset\/items\/1":{"get":{"summary":"Get this dataset","tags":["Dataset"],"responses":{"200":{"description":"Ok"}}}},"\/api\/1\/datastore\/sql?query=[SELECT * FROM dist-1234];":{"get":{"summary":"Title","tags":["SQL Query"],"responses":{"200":{"description":"Ok"}},"description":"Description"}}}}'; + + $this->assertEquals($spec, $response->getContent()); + } + + /** + * Tests dataset-specific docs when SQL endpoint is protected. + */ + public function testDatasetSpecificDocsWithSqlModifier() { + $mockChain = $this->getCommonMockChain() + ->add(Service::class, "get", "{}") + ->add(DataModifierManager::class, 'getDefinitions', [['id' => 'foobar']]) + ->add(DataModifierManager::class, 'createInstance', DataModifierBase::class) + ->add(DataModifierBase::class, 'requiresModification', TRUE); + + $controller = WebServiceApiDocs::create($mockChain->getMock()); + $response = $controller->getDatasetSpecific(1); + + $spec = '{"openapi":"3.0.1","info":{"title":"API Documentation","version":"Alpha"},"tags":[{"name":"Dataset"}],"paths":{"\/api\/1\/metastore\/schemas\/dataset\/items\/1":{"get":{"summary":"Get this dataset","tags":["Dataset"],"responses":{"200":{"description":"Ok"}}}}}}'; $this->assertEquals($spec, $response->getContent()); } @@ -42,9 +78,10 @@ private function getCommonMockChain() { $mockChain->add(ContainerInterface::class, 'get', (new Options)->add('dkan_api.docs', Docs::class) ->add('dkan_metastore.service', Service::class) + ->add('plugin.manager.dkan_common.data_modifier', DataModifierManager::class) ) - ->add(Docs::class, "getJsonFromYmlFile", $serializer->decode($yamlSpec)) - ->add(Service::class, "get", "{}"); + ->add(Docs::class, "getJsonFromYmlFile", $serializer->decode($yamlSpec)); + return $mockChain; } diff --git a/modules/custom/dkan_non_public/dkan_non_public.info.yml b/modules/custom/dkan_non_public/dkan_non_public.info.yml new file mode 100644 index 000000000..75e499a87 --- /dev/null +++ b/modules/custom/dkan_non_public/dkan_non_public.info.yml @@ -0,0 +1,7 @@ +name: 'Non Public Data Modifier' +description: 'Protects the resources of datasets with non-public access level, on publicly accessible endpoints.' +type: module +core: 8.x +package: DKAN +dependencies: + - dkan_alt_api diff --git a/modules/custom/dkan_non_public/src/Plugin/DataModifier/NonPublicResourceProtector.php b/modules/custom/dkan_non_public/src/Plugin/DataModifier/NonPublicResourceProtector.php new file mode 100644 index 000000000..83f69a91d --- /dev/null +++ b/modules/custom/dkan_non_public/src/Plugin/DataModifier/NonPublicResourceProtector.php @@ -0,0 +1,298 @@ +configuration = $configuration; + $this->pluginId = $plugin_id; + $this->pluginDefinition = $plugin_definition; + $this->database = $database; + $this->routeMatch = $routeMatch; + $this->uuid5 = $uuid5; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('database'), + $container->get('current_route_match'), + $container->get('dkan_data.uuid5') + ); + } + + /** + * Check if a resource needs to be protected. + * + * @param string $schema + * The schema id. + * @param object|string $data + * Object, json or identifier string representing a dataset or distribution. + * + * @return bool + * TRUE if the data requires modification, FALSE otherwise. + */ + public function requiresModification(string $schema, $data) { + return in_array($schema, $this->schemasToModify) + && !$this->alternateEndpoint() + && $this->accessLevel($schema, $data) === 'non-public'; + } + + /** + * Check if user requests one of the alternate dkan_alt_api endpoint. + * + * @return bool + * TRUE from alternate endpoints, FALSE otherwise. + */ + private function alternateEndpoint() { + $routeName = $this->routeMatch->getRouteName(); + return strpos($routeName, 'dkan_alt_api.') === 0; + } + + /** + * Check if a dataset or a distribution's parent has non-public access level. + * + * @param string $schema + * The schema id. + * @param mixed $data + * Object, json or identifier string representing a dataset or distribution. + * + * @return bool + * TRUE if non-public, FALSE otherwise. + */ + private function accessLevel(string $schema, $data) : string { + // For distributions, check their parent dataset's access level. + if ('distribution' === $schema) { + return $this->parentDatasetAccessLevel($data); + } + return $this->datasetAccessLevel($data); + } + + /** + * Returns a dataset's access level. + * + * @param object|string $data + * A dataset object or json string. + * + * @return string + * The access level of the dataset. + */ + private function datasetAccessLevel($data) { + if (is_string($data)) { + $data = json_decode($data); + } + return $data->accessLevel; + } + + /** + * Get the access level of a distribution's parent dataset. + * + * @param object|string $dist + * Object, json or identifier string representing a distribution. + * + * @return string + * The parent dataset's access level. + */ + private function parentDatasetAccessLevel($dist) { + $identifier = $this->getIdentifier($dist); + $parentDataset = $this->getParentDataset($identifier); + return $this->datasetAccessLevel($parentDataset); + } + + /** + * Get a distribution's identifier, from its object or json string. + * + * @param object|string $dist + * Object, json or identifier string representing a distribution. + * + * @return string + * The distribution's identifier. + */ + private function getIdentifier($dist) : string { + if (is_string($dist)) { + if ($this->uuid5->isValid($dist)) { + return $dist; + } + $dist = json_decode($dist); + } + return $dist->identifier; + } + + /** + * Get the metadata of a distribution's parent dataset. + * + * @param string $identifier + * The distribution's identifier. + * + * @return string|bool + * The dataset's metadata, or FALSE. + */ + private function getParentDataset($identifier) { + $datasets = $this->database->select('node__field_json_metadata', 'm') + ->condition('m.field_json_metadata_value', '%accessLevel%', 'LIKE') + ->condition('m.field_json_metadata_value', "%{$identifier}%", 'LIKE') + ->fields('m', ['field_json_metadata_value']) + ->execute() + ->fetchCol(); + + return reset($datasets); + } + + /** + * Protect potentially sensitive data in a dataset or distribution. + * + * @param string $schema + * The schema id. + * @param object|string $data + * Object, json or identifier string representing a dataset or distribution. + * + * @return mixed + * Modified data, or FALSE. + */ + public function modify(string $schema, $data) { + if ('distribution' === $schema) { + return $this->protectDistribution($data); + } + return $this->protectDataset($data); + } + + /** + * Protect dataset object or string. + * + * @param object|string $dataset + * A dataset object or json string. + * + * @return object|string + * The protected dataset. + */ + private function protectDataset($dataset) { + if (is_string($dataset)) { + $datasetObj = json_decode($dataset); + $datasetObj = $this->protectDatasetObject($datasetObj); + return json_encode($datasetObj); + } + return $this->protectDatasetObject($dataset); + } + + /** + * Protect dataset object. + * + * @param object $dataset + * The dataset object. + * + * @return object + * The protected dataset object + */ + private function protectDatasetObject($dataset) { + if (isset($dataset->distribution) && is_array($dataset->distribution)) { + foreach ($dataset->distribution as $key => &$dist) { + $dataset->distribution[$key] = $this->protectDistributionObject($dist); + } + } + return $dataset; + } + + /** + * Protect distribution, both object and json strings. + * + * @param object|string $dist + * A distribution object or json string. + * + * @return false|mixed|string + * A protected distribution. + */ + private function protectDistribution($dist) { + if (is_string($dist)) { + $distObj = json_decode($dist); + $distObj = $this->protectDistributionObject($distObj); + return json_encode($distObj); + } + return $this->protectDistributionObject($dist); + } + + /** + * Protect distribution object. + * + * @param object $dist + * A distribution object. + * + * @return object + * A protected distribution object, with an explanation. + */ + private function protectDistributionObject($dist) { + unset($dist); + return (object) ['title' => $this->message()]; + } + +} diff --git a/modules/custom/dkan_non_public/tests/src/Unit/Plugin/DataModifier/NonPublicResourceProtectorTest.php b/modules/custom/dkan_non_public/tests/src/Unit/Plugin/DataModifier/NonPublicResourceProtectorTest.php new file mode 100644 index 000000000..181f581a6 --- /dev/null +++ b/modules/custom/dkan_non_public/tests/src/Unit/Plugin/DataModifier/NonPublicResourceProtectorTest.php @@ -0,0 +1,117 @@ + [ + 'dataset', + (object) ['accessLevel' => 'public'], + [], + FALSE, + ], + 'resource object with non-public parent dataset' => [ + 'distribution', + (object) ['identifier' => '1', 'data' => ['foo' => 'bar']], + ['{"accessLevel":"non-public"}'], + TRUE, + ], + 'resource json string with public parent dataset' => [ + 'distribution', + '{"identifier":"1","data":{"foo":"bar"}}', + ['{"accessLevel":"public"}'], + FALSE, + ], + 'resource uuid with public parent dataset' => [ + 'distribution', + '6b1f0bb4-60cd-5b27-8e03-5fecff4c7e2a', + ['{"accessLevel":"non-public"}'], + TRUE, + ], + ]; + } + + /** + * @dataProvider requiresModificationProvider + */ + public function testRequiresModification(string $schema, $data, $datasets, $expected) { + $container = $this->getCommonMockChain() + ->add(StatementInterface::class, 'fetchCol', $datasets); + + $plugin = NonPublicResourceProtector::create( + $container->getMock(), + [], + 'non_public_resource_protector', + [] + ); + + $this->assertEquals($expected, $plugin->requiresModification($schema, $data)); + } + + public function modifyProvider() { + return [ + 'dataset json string without resources' => [ + 'dataset', + '{"foo":"bar"}', + '{"foo":"bar"}', + ], + 'dataset object with empty distribution array' => [ + 'dataset', + (object) ["foo" => "bar", "distribution" => []], + (object) ["foo" => "bar", "distribution" => []], + ], + ]; + } + + /** + * @dataProvider modifyProvider + */ + public function testModify($schema, $data, $expected) { + $plugin = NonPublicResourceProtector::create( + $this->getCommonMockChain()->getMock(), + [], + 'non_public_resource_protector', + [] + ); + + $this->assertEquals($expected, $plugin->modify($schema, $data)); + } + + public function getCommonMockChain() { + $pluginMessage = "Resource hidden since dataset access level is non-public."; + + $options = (new Options()) + ->add('database', Connection::class) + ->add('current_route_match', RouteMatchInterface::class) + ->add('dkan_data.uuid5', Uuid5::class); + + return (new Chain($this)) + ->add(Container::class, 'get', $options) + ->add(Connection::class, 'select', SelectInterface::class) + ->add(SelectInterface::class, 'condition', ConditionInterface::class) + ->add(ConditionInterface::class, 'condition', ConditionInterface::class) + ->add(ConditionInterface::class, 'fields', SelectInterface::class) + ->add(SelectInterface::class, 'execute', StatementInterface::class) + ->add(StatementInterface::class, 'fetchCol', []) + ->add(DataModifierBase::class, 'message', $pluginMessage); + } + +} diff --git a/modules/custom/dkan_sql_endpoint/src/Controller/Api.php b/modules/custom/dkan_sql_endpoint/src/Controller/Api.php index f5905de00..20c6f0e4f 100644 --- a/modules/custom/dkan_sql_endpoint/src/Controller/Api.php +++ b/modules/custom/dkan_sql_endpoint/src/Controller/Api.php @@ -2,6 +2,8 @@ namespace Drupal\dkan_sql_endpoint\Controller; +use Drupal\dkan_common\DataModifierPluginTrait; +use Drupal\dkan_common\Plugin\DataModifierManager; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; use Drupal\Core\Database\Connection; @@ -17,6 +19,7 @@ */ class Api implements ContainerInjectionInterface { use JsonResponseTrait; + use DataModifierPluginTrait; private $service; private $database; @@ -37,7 +40,8 @@ public static function create(ContainerInterface $container) { $container->get('database'), $container->get('dkan_datastore.service.factory.resource'), $container->get('request_stack'), - $container->get('dkan_datastore.database_table_factory') + $container->get('dkan_datastore.database_table_factory'), + $container->get('plugin.manager.dkan_common.data_modifier') ); } @@ -49,13 +53,17 @@ public function __construct( Connection $database, Resource $resourceServiceFactory, RequestStack $requestStack, - DatabaseTableFactory $databaseTableFactory + DatabaseTableFactory $databaseTableFactory, + DataModifierManager $pluginManager ) { $this->service = $service; $this->database = $database; $this->resourceServiceFactory = $resourceServiceFactory; $this->requestStack = $requestStack; $this->databaseTableFactory = $databaseTableFactory; + $this->pluginManager = $pluginManager; + + $this->plugins = $this->discover(); } /** @@ -103,7 +111,11 @@ private function runQuery(string $query) { return $this->getResponseFromException($e); } - $databaseTable = $this->getDatabaseTable($this->service->getTableName($query)); + $uuid = $this->service->getTableName($query); + if ($modifyResponse = $this->modifyData($uuid)) { + return $modifyResponse; + } + $databaseTable = $this->getDatabaseTable($uuid); try { $result = $databaseTable->query($queryObject); @@ -115,6 +127,24 @@ private function runQuery(string $query) { return $this->getResponse($result, 200); } + /** + * Provides data modifiers plugins an opportunity to act. + * + * @param string $identifier + * The distribution's identifier. + * + * @return object|bool + * The json response if sql endpoint docs needs modifying, FALSE otherwise. + */ + private function modifyData(string $identifier) { + foreach ($this->plugins as $plugin) { + if ($plugin->requiresModification('distribution', $identifier)) { + return $this->getResponse((object) ["message" => $plugin->message()], $plugin->httpCode()); + } + } + return FALSE; + } + /** * Private. */ diff --git a/modules/custom/dkan_sql_endpoint/tests/src/Unit/Controller/ApiTest.php b/modules/custom/dkan_sql_endpoint/tests/src/Unit/Controller/ApiTest.php index 9fa970881..0e4d3b0e0 100644 --- a/modules/custom/dkan_sql_endpoint/tests/src/Unit/Controller/ApiTest.php +++ b/modules/custom/dkan_sql_endpoint/tests/src/Unit/Controller/ApiTest.php @@ -8,6 +8,8 @@ use Drupal\Core\Config\ImmutableConfig; use Drupal\Core\Database\Connection; use Drupal\Core\DependencyInjection\Container; +use Drupal\dkan_common\Plugin\DataModifierBase; +use Drupal\dkan_common\Plugin\DataModifierManager; use MockChain\Chain; use MockChain\Options; use Drupal\dkan_datastore\Storage\DatabaseTable; @@ -30,7 +32,7 @@ class ApiTest extends TestCase { * */ public function test() { - $controller = Api::create($this->getContainer()); + $controller = Api::create($this->getCommonMockChain()->getMock()); $response = $controller->runQueryGet(); $this->assertEquals("[]", $response->getContent()); } @@ -39,7 +41,7 @@ public function test() { * */ public function test2() { - $controller = Api::create($this->getContainer()); + $controller = Api::create($this->getCommonMockChain()->getMock()); $response = $controller->runQueryPost(); $this->assertEquals("[]", $response->getContent()); } @@ -47,7 +49,27 @@ public function test2() { /** * */ - private function getContainer() { + public function testWithDataModifierPlugin() { + $pluginMessage = "Resource hidden since dataset access level is non-public."; + $pluginCode = 401; + + $container = $this->getCommonMockChain() + ->add(DataModifierManager::class, 'getDefinitions', [['id' => 'foobar']]) + ->add(DataModifierManager::class, 'createInstance', DataModifierBase::class) + ->add(DataModifierBase::class, 'requiresModification', TRUE) + ->add(DataModifierBase::class, 'message', $pluginMessage) + ->add(DataModifierBase::class, 'httpCode', $pluginCode); + + $controller = Api::create($container->getMock()); + $response = $controller->runQueryGet(); + $expected = '{"message":"Resource hidden since dataset access level is non-public."}'; + $this->assertEquals($expected, $response->getContent()); + } + + /** + * @return \MockChain\Chain + */ + public function getCommonMockChain() { $container = (new Chain($this)) ->add(Container::class, "get", ConfigFactory::class) ->add(ConfigFactory::class, "get", ImmutableConfig::class) @@ -59,12 +81,13 @@ private function getContainer() { ->add("database", Connection::class) ->add('dkan_datastore.service.factory.resource', ResourceServiceFactory::class) ->add('request_stack', RequestStack::class) - ->add('dkan_datastore.database_table_factory', DatabaseTableFactory::class); + ->add('dkan_datastore.database_table_factory', DatabaseTableFactory::class) + ->add('plugin.manager.dkan_common.data_modifier', DataModifierManager::class); $query = '[SELECT * FROM abc][WHERE abc = \'blah\'][ORDER BY abc DESC][LIMIT 1 OFFSET 3];'; $body = json_encode(["query" => $query]); - $container = (new Chain($this)) + return (new Chain($this)) ->add(Container::class, "get", $options) ->add(RequestStack::class, 'getCurrentRequest', Request::class) ->add(Request::class, 'get', $query) @@ -76,9 +99,9 @@ private function getContainer() { ->add(Resource::class, 'getId', "1") ->add(DatabaseTableFactory::class, 'getInstance', DatabaseTable::class) ->add(DatabaseTable::class, 'query', []) - ->getMock(); - - return $container; + ->add(DataModifierManager::class, 'getDefinitions', []) + ->add(DataModifierManager::class, 'createInstance', DataModifierBase::class) + ->add(DataModifierBase::class, 'requiresModification', FALSE); } } diff --git a/phpunit.xml b/phpunit.xml index 7064c9f96..48eaf28b4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,6 +16,7 @@ modules/custom/dkan_frontend/tests/src/Unit modules/custom/dkan_harvest/test/src/Unit modules/custom/dkan_metastore/tests/src/Unit + modules/custom/dkan_non_public/tests/src/Unit modules/custom/dkan_schema/tests/src/Unit modules/custom/dkan_sql_endpoint/tests/src/Unit @@ -44,6 +45,7 @@ modules/custom/dkan_harvest/src modules/custom/dkan_lunr/src modules/custom/dkan_metastore/src + modules/custom/dkan_non_public/src modules/custom/dkan_schema/src modules/custom/dkan_sql_endpoint/src