diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt index c619e92..8aba9a7 100644 --- a/.cspell-project-words.txt +++ b/.cspell-project-words.txt @@ -13,6 +13,7 @@ Nikolay skek Utilis commandfile +~typehint # Non-US-English spellings. ~analyser diff --git a/composer.json b/composer.json index 56439c0..ea00735 100644 --- a/composer.json +++ b/composer.json @@ -10,12 +10,14 @@ "require-dev": { "drupal/coder": "^8.3", "drupal/devel": "^4.0|^5.0", + "drupal/key": "^1", "drupal/geofield": "1.x-dev", "drush/drush": "^12.0 || ^13", "phayes/geophp": "^1.2" }, "suggest": { "drupal/facets": "Provides facetted search.", + "drupal/key": "API Key storage for connecting to Elastic Cloud.", "drupal/search_api_autocomplete": "Provides auto complete for search boxes.", "drupal/search_api_location": "Provides location searches.", "drupal/search_api_spellcheck": "Provides spell checking and 'Did You Mean?'." diff --git a/config/schema/elasticsearch_connector.connector.elastic_cloud_endpoint.schema.yml b/config/schema/elasticsearch_connector.connector.elastic_cloud_endpoint.schema.yml new file mode 100644 index 0000000..7427555 --- /dev/null +++ b/config/schema/elasticsearch_connector.connector.elastic_cloud_endpoint.schema.yml @@ -0,0 +1,13 @@ +plugin.plugin_configuration.elasticsearch_connector.elastic_cloud_endpoint: + type: config_object + label: 'Elasticsearch Elastic Cloud connector settings' + mapping: + api_key_id: + type: string + label: 'Elastic Cloud API key ID' + url: + type: string + label: 'Elasticsearch endpoint' + enable_debug_logging: + type: boolean + label: 'Enable debugging mode: log ElasticSearch network traffic' diff --git a/config/schema/elasticsearch_connector.connector.elastic_cloud_id.schema.yml b/config/schema/elasticsearch_connector.connector.elastic_cloud_id.schema.yml new file mode 100644 index 0000000..b1620cc --- /dev/null +++ b/config/schema/elasticsearch_connector.connector.elastic_cloud_id.schema.yml @@ -0,0 +1,13 @@ +plugin.plugin_configuration.elasticsearch_connector.elastic_cloud_id: + type: config_object + label: 'Elasticsearch Elastic Cloud connector settings' + mapping: + api_key_id: + type: string + label: 'Elastic Cloud API key ID' + elastic_cloud_id: + type: string + label: 'Elastic Cloud ID' + enable_debug_logging: + type: boolean + label: 'Enable debugging mode: log ElasticSearch network traffic' diff --git a/src/Plugin/ElasticSearch/Connector/ElasticCloudEndpointConnector.php b/src/Plugin/ElasticSearch/Connector/ElasticCloudEndpointConnector.php new file mode 100644 index 0000000..bcdb30e --- /dev/null +++ b/src/Plugin/ElasticSearch/Connector/ElasticCloudEndpointConnector.php @@ -0,0 +1,217 @@ +logger = $container->get('logger.channel.elasticsearch_connector_client'); + + // If the key module is installed, then a 'key.repository' service will be + // available: if so, set that. + if ($container->has('key.repository')) { + $instance->keyRepository = $container->get('key.repository'); + } + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getLabel(): string { + return (string) $this->pluginDefinition['label']; + } + + /** + * {@inheritdoc} + */ + public function getDescription(): string { + return (string) $this->pluginDefinition['description']; + } + + /** + * {@inheritdoc} + */ + public function getUrl(): string { + return $this->configuration['url']; + } + + /** + * {@inheritdoc} + */ + public function getConfiguration(): array { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = $configuration + $this->defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function getClient(): Client { + $clientBuilder = ClientBuilder::create() + ->setHosts([$this->configuration['url']]); + + if ($this->keyRepositoryIsValid()) { + $apiKey = $this->keyRepository->getKey($this->configuration['api_key_id']) + ?->getKeyValue(); + $clientBuilder->setApiKey($apiKey); + } + + if ($this->configuration['enable_debug_logging']) { + $clientBuilder->setLogger($this->logger); + } + + return $clientBuilder->build(); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'api_key_id' => '', + 'url' => '', + 'enable_debug_logging' => FALSE, + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $form['url'] = [ + '#type' => 'url', + '#title' => $this->t('Elasticsearch endpoint'), + '#description' => $this->t("Your Hosted deployment's Elasticsearch endpoint, which looks like a URL."), + '#default_value' => $this->configuration['url'] ?? '', + '#required' => TRUE, + ]; + + // If the key repository is valid, then we can assume the key module is + // installed, and present a key-selection widget. + if ($this->keyRepositoryIsValid()) { + $form['api_key_id'] = [ + '#type' => 'key_select', + '#title' => $this->t('Elastic Cloud API key'), + '#default_value' => $this->configuration['api_key_id'] ?? '', + '#required' => TRUE, + '#description' => $this->t('At minimum, this API key needs the following security privileges...
@minimum_security_privileges
...where @role_name is a unique role name; and @index_1_name, and @index_2_name are the machine names of the Indices you create on the server.', [ + '@minimum_security_privileges' => '{"role-a": {"indices": [{"names": ["INDEX_1", "INDEX_2"], "privileges": ["all", "create_index", "delete_index", "maintenance"], "allow_restricted_indices": false}]}}', + '@role_name' => 'role-a', + '@index_1_name' => 'INDEX_1', + '@index_2_name' => 'INDEX_2', + ]), + ]; + } + // If the key repository is not valid, then the key module is probably not + // installed. This plugin won't work without it, so display an error + // message. + else { + $form['no_key_module_message'] = [ + '#theme' => 'status_messages', + '#status_headings' => [ + MessengerInterface::TYPE_ERROR => $this->t('Key module missing'), + ], + '#message_list' => [ + MessengerInterface::TYPE_ERROR => [ + $this->t("You must install Drupal's Key module to use this authentication type! Please ensure that the Key module is downloaded and enabled.", [ + '@key_module_url' => 'https://www.drupal.org/project/key', + ]), + ], + ], + ]; + } + + $form['enable_debug_logging'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable debugging mode: log ElasticSearch network traffic'), + '#description' => $this->t("This will write requests, responses, and response-time information to Drupal's log, which may help you diagnose problems with Drupal's connection to ElasticSearch.

Warning: This setting will result in poor performance and may log a user’s personally identifiable information. This setting is only intended for temporary use and should be disabled when you finish debugging. Logs written while this mode is active will remain in the log until you clear them or the logs are rotated.

"), + '#default_value' => $this->configuration['enable_debug_logging'] ?? FALSE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration['api_key_id'] = \trim($form_state->getValue('api_key_id')); + $this->configuration['url'] = \trim($form_state->getValue('url')); + $this->configuration['enable_debug_logging'] = (bool) $form_state->getValue('enable_debug_logging'); + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + $url = $form_state->getValue('url'); + if (!UrlHelper::isValid($url)) { + $form_state->setErrorByName('url', $this->t("Invalid Elasticsearch endpoint")); + } + } + + /** + * Determine if the key repository is valid, implying key module is installed. + * + * @return bool + * TRUE if the keyRepository service injected into this plugin is of type + * \Drupal\key\KeyRepositoryInterface; FALSE otherwise. + */ + protected function keyRepositoryIsValid(): bool { + // Note that we are intentionally NOT using KeyRepositoryInterface::class + // here, because doing so would introduce a required dependency on the Key + // module. However, we want an OPTIONAL dependency on the Key module. + return \is_a($this->keyRepository, 'Drupal\key\KeyRepositoryInterface', TRUE); + } + +} diff --git a/src/Plugin/ElasticSearch/Connector/ElasticCloudIdConnector.php b/src/Plugin/ElasticSearch/Connector/ElasticCloudIdConnector.php new file mode 100644 index 0000000..d019556 --- /dev/null +++ b/src/Plugin/ElasticSearch/Connector/ElasticCloudIdConnector.php @@ -0,0 +1,232 @@ +logger = $container->get('logger.channel.elasticsearch_connector_client'); + + // If the key module is installed, then a 'key.repository' service will be + // available: if so, set that. + if ($container->has('key.repository')) { + $instance->keyRepository = $container->get('key.repository'); + } + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getLabel(): string { + return (string) $this->pluginDefinition['label']; + } + + /** + * {@inheritdoc} + */ + public function getDescription(): string { + return (string) $this->pluginDefinition['description']; + } + + /** + * {@inheritdoc} + */ + public function getUrl(): string { + // If we can, build a client, and ask it for the URI. + if ($this->configuration['elastic_cloud_id'] && $this->configuration['api_key_id']) { + $client = $this->getClient(); + return (string) $client->getTransport()->getNodePool()->nextNode()->getUri(); + } + + // If we're missing the data needed to find the URL, then return an empty + // string. + return ''; + } + + /** + * {@inheritdoc} + */ + public function getConfiguration(): array { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = $configuration + $this->defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function getClient(): Client { + $clientBuilder = ClientBuilder::create() + ->setElasticCloudId($this->configuration['elastic_cloud_id']); + + if ($this->keyRepositoryIsValid()) { + $apiKey = $this->keyRepository->getKey($this->configuration['api_key_id'])?->getKeyValue(); + $clientBuilder->setApiKey($apiKey); + } + + if ($this->configuration['enable_debug_logging']) { + $clientBuilder->setLogger($this->logger); + } + + return $clientBuilder->build(); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'api_key_id' => '', + 'elastic_cloud_id' => '', + 'enable_debug_logging' => FALSE, + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $form['elastic_cloud_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('Elastic Cloud ID'), + '#description' => $this->t("Your Hosted deployment's Cloud ID, in the format <label>:<cloud-id>, where <label>: is an optional human-readable name, and <cloud-id> is a base64-encoded text value of about 120 characters, made up of upper and lower case letters and numbers. Your Cloud ID is displayed at the top of the Hosted deployment's Kibana instance's Overview page. You can also find it by clicking on the deployment name from the Elastic Cloud Hosted deployments list page", [ + '@elastic_cloud_deployments' => 'https://cloud.elastic.co/deployments/', + ]), + '#default_value' => $this->configuration['elastic_cloud_id'] ?? '', + '#required' => TRUE, + '#maxlength' => 500, + ]; + + // If the key repository is valid, then we can assume the key module is + // installed, and present a key-selection widget. + if ($this->keyRepositoryIsValid()) { + $form['api_key_id'] = [ + '#type' => 'key_select', + '#title' => $this->t('Elastic Cloud API key'), + '#default_value' => $this->configuration['api_key_id'] ?? '', + '#required' => TRUE, + '#description' => $this->t('At minimum, this API key needs the following security privileges...
@minimum_security_privileges
...where @role_name is a unique role name; and @index_1_name, and @index_2_name are the machine names of the Indices you create on the server.', [ + '@minimum_security_privileges' => '{"role-a": {"indices": [{"names": ["INDEX_1", "INDEX_2"], "privileges": ["all", "create_index", "delete_index", "maintenance"], "allow_restricted_indices": false}]}}', + '@role_name' => 'role-a', + '@index_1_name' => 'INDEX_1', + '@index_2_name' => 'INDEX_2', + ]), + ]; + } + // If the key repository is not valid, then the key module is probably not + // installed. This plugin won't work without it, so display an error + // message. + else { + $form['no_key_module_message'] = [ + '#theme' => 'status_messages', + '#status_headings' => [ + MessengerInterface::TYPE_ERROR => $this->t('Key module missing'), + ], + '#message_list' => [ + MessengerInterface::TYPE_ERROR => [ + $this->t("You must install Drupal's Key module to use this authentication type! Please ensure that the Key module is downloaded and enabled.", [ + '@key_module_url' => 'https://www.drupal.org/project/key', + ]), + ], + ], + ]; + } + + $form['enable_debug_logging'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable debugging mode: log ElasticSearch network traffic'), + '#description' => $this->t("This will write requests, responses, and response-time information to Drupal's log, which may help you diagnose problems with Drupal's connection to ElasticSearch.

Warning: This setting will result in poor performance and may log a user’s personally identifiable information. This setting is only intended for temporary use and should be disabled when you finish debugging. Logs written while this mode is active will remain in the log until you clear them or the logs are rotated.

"), + '#default_value' => $this->configuration['enable_debug_logging'] ?? FALSE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration['api_key_id'] = \trim($form_state->getValue('api_key_id')); + $this->configuration['elastic_cloud_id'] = \trim($form_state->getValue('elastic_cloud_id')); + $this->configuration['enable_debug_logging'] = (bool) $form_state->getValue('enable_debug_logging'); + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + // Check that the Elastic Cloud ID can be parsed. + $elasticCloudId = $form_state->getValue('elastic_cloud_id') ?? ''; + try { + TransportBuilder::create()->setCloudId($elasticCloudId); + } + catch (CloudIdParseException) { + $form_state->setErrorByName('elastic_cloud_id', 'The Elastic Cloud ID that you entered could not be parsed by the official HTTP transport for Elastic PHP clients.'); + } + } + + /** + * Determine if the key repository is valid, implying key module is installed. + * + * @return bool + * TRUE if the keyRepository service injected into this plugin is of type + * \Drupal\key\KeyRepositoryInterface; FALSE otherwise. + */ + protected function keyRepositoryIsValid(): bool { + // Note that we are intentionally NOT using KeyRepositoryInterface::class + // here, because doing so would introduce a required dependency on the Key + // module. However, we want an OPTIONAL dependency on the Key module. + return \is_a($this->keyRepository, 'Drupal\key\KeyRepositoryInterface', TRUE); + } + +} diff --git a/src/SearchAPI/BackendClient.php b/src/SearchAPI/BackendClient.php index d3b4ccd..d9de391 100644 --- a/src/SearchAPI/BackendClient.php +++ b/src/SearchAPI/BackendClient.php @@ -261,12 +261,16 @@ class BackendClient implements BackendClientInterface { public function updateFieldMapping(IndexInterface $index): void { $indexId = $this->getIndexId($index); try { + $this->client->indices()->close(['index' => $indexId]); $params = $this->fieldParamsBuilder->mapFieldParams($indexId, $index); $this->client->indices()->putMapping($params); } catch (ElasticSearchException | TransportException $e) { throw new SearchApiException(sprintf('An error occurred updating field mappings for index %s.', $indexId), 0, $e); } + finally { + $this->client->indices()->open(['index' => $indexId]); + } } /**