acquia_search-3.0.1/src/Plugin/SolrConnector/SearchApiSolrAcquiaConnector.php
src/Plugin/SolrConnector/SearchApiSolrAcquiaConnector.php
<?php
namespace Drupal\acquia_search\Plugin\SolrConnector;
use Drupal\acquia_connector\Subscription;
use Drupal\acquia_search\AcquiaSearchApiClient;
use Drupal\acquia_search\Client\Solarium\AcquiaGuzzle;
use Drupal\acquia_search\Client\Solarium\Endpoint as AcquiaEndpoint;
use Drupal\acquia_search\PreferredCoreServiceFactory;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\Url;
use Drupal\search_api_solr\SolrConnector\SolrConnectorPluginBase;
use Drupal\search_api_solr\SolrConnectorInterface;
use Http\Factory\Guzzle\RequestFactory;
use Http\Factory\Guzzle\StreamFactory;
use Solarium\Client;
use Solarium\Core\Client\Adapter\Psr18Adapter;
use Solarium\Core\Client\Endpoint;
use Solarium\Exception\UnexpectedValueException;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Acquia Search Solr Connector.
*
* Extends SolrConnectorPluginBase for Acquia Search.
*
* @package Drupal\acquia_search\Plugin\SolrConnector
*
* @SolrConnector(
* id = "solr_acquia_connector",
* label = @Translation("Acquia Search Connector"),
* description = @Translation("Index items using an Acquia Apache Solr search server.")
* )
*/
class SearchApiSolrAcquiaConnector extends SolrConnectorPluginBase implements SolrConnectorInterface, PluginFormInterface, ContainerFactoryPluginInterface {
/**
* Automatically selected the proper Solr connection based on the environment.
*/
const OVERRIDE_AUTO_SET = 1;
/**
* Enforce read-only mode on this connection.
*/
const READ_ONLY = 2;
/**
* Enforce read/write mode on this connection.
*/
const READ_WRITE = 3;
/**
* Default endpoint key.
*/
const ENDPOINT_KEY = 'search_api_solr';
/**
* Acquia Connector Subscription.
*
* @var \Drupal\acquia_connector\Subscription
*/
protected $subscription;
/**
* A cache backend interface.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* Acquia Search API Client Service.
*
* @var \Drupal\acquia_search\AcquiaSearchApiClient
*/
protected $acquiaSearchApiClient;
/**
* Acquia Search Preferred Core Service.
*
* @var \Drupal\acquia_search\PreferredCoreServiceFactory
*/
protected $preferredCoreServiceFactory;
/**
* Acquia specific Guzzle instance for Solarium.
*
* @var \Drupal\acquia_search\Client\Solarium\AcquiaGuzzle
*/
private $acquiaGuzzle;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, array $plugin_definition, LoggerChannelFactoryInterface $logger_factory, AcquiaGuzzle $acquia_guzzle, MessengerInterface $messenger, CacheBackendInterface $cache, Subscription $subscription, AcquiaSearchApiClient $acquia_search_api_client, PreferredCoreServiceFactory $preferred_core_service_factory) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->acquiaGuzzle = $acquia_guzzle;
$this->acquiaSearchApiClient = $acquia_search_api_client;
$this->messenger = $messenger;
$this->subscription = $subscription;
$this->cache = $cache;
$this->setLogger($logger_factory->get('acquia_search'));
$this->preferredCoreServiceFactory = $preferred_core_service_factory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
// Our schema (8.1.7) is newer than Solr's version, 4.1.1.
$configuration['skip_schema_check'] = TRUE;
// Ensure platform config is always used.
$instance = new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('logger.factory'),
$container->get('acquia_search.solarium.guzzle'),
$container->get('messenger'),
$container->get('cache.default'),
$container->get('acquia_connector.subscription'),
$container->get('acquia_search.api_client'),
$container->get('acquia_search.preferred_core_factory')
);
$instance->setEventDispatcher($container->get('event_dispatcher'));
return $instance;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return array_merge(
parent::defaultConfiguration(),
[
// Our schema (8.1.7) is newer than Solr's version, 4.1.1.
'skip_schema_check' => TRUE,
]
);
}
/**
* {@inheritdoc}
*/
public function getCoreLink() {
return $this->getServerLink();
}
/**
* {@inheritdoc}
*/
public function getCoreInfo($reset = FALSE) {
if (isset($this->configuration['core'])) {
return parent::getCoreInfo($reset);
}
return NULL;
}
/**
* {@inheritdoc}
*
* Acquia-specific: 'admin/info/system' path is protected by Acquia.
* Use admin/system instead.
*/
public function pingServer() {
// Cache the ping during the request so it only happens once.
static $ping;
if (!isset($ping)) {
$ping = $this->pingCore(['handler' => 'admin/system']);
}
return $ping;
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form = parent::buildConfigurationForm($form, $form_state);
$use_fields = [
'timeout',
SolrConnectorInterface::INDEX_TIMEOUT,
SolrConnectorInterface::OPTIMIZE_TIMEOUT,
SolrConnectorInterface::FINALIZE_TIMEOUT,
'commit_within',
];
foreach ($form as $k => $v) {
if (!in_array($k, $use_fields, TRUE)) {
$form[$k]['#access'] = FALSE;
}
}
// Bail early if the subscription doesn't exist.
if (!isset($this->subscription)) {
$form['acquia_search_msg']['#markup'] = $this->formatAcquiaSearchMessage([$this->t('Acquia Search requires Acquia Connector 3.1 and higher. Please <a href=":connector">update</a> and try to connect again.', [
':connector' => Url::fromUri('https://drupal.org/project/acquia_connector')->getUri(),
]),
]);
return $form;
}
elseif (!$this->subscription->isActive()) {
$form['acquia_search_msg']['#markup'] = $this->formatAcquiaSearchMessage([$this->t('An active Acquia Subscription is required to use search. Please <a href=":cloud">contact Acquia support</a> to renew your subscription.', [
':cloud' => Url::fromUri('https://docs.acquia.com/cloud-platform/subs/')->getUri(),
]),
]);
return $form;
}
// Existing search core will be empty if its the default server or new.
$existing_search_core = $this->getConfiguration()['acquia_search_core'] ?? '';
$existing_read_only = $this->getConfiguration()['acquia_search_override_readonly'] ?? SearchApiSolrAcquiaConnector::OVERRIDE_AUTO_SET;
$disabled = FALSE;
$messages = [];
$options = [];
if ($this->subscription->isActive()) {
$options = $this->getAcquiaSearchCores();
if (count($options) === 1) {
$messages[] = array_shift($options);
$disabled = TRUE;
}
}
// If there is no subscription, preserve existing setting but disable.
else {
$messages[] = $this->t('Unable to retrieve an active subscription.');
$disabled = TRUE;
}
// If the existing value isn't in the current subscription.
if ($existing_search_core !== '' && !in_array($existing_search_core, array_keys($options))) {
$messages[] = $this->t("Cannot find existing core within current subscription.");
$disabled = TRUE;
}
$server = $form_state->getFormObject()->getEntity();
if ($server->id() == 'acquia_search_server') {
$messages[] = $this->t("Acquia Search dynamically sets the core for the default server");
$existing_search_core = $this->preferredCoreServiceFactory->get($server)->getPreferredCoreId();
$disabled = TRUE;
$read_only_disabled = FALSE;
}
// Detect if config is a full core id or just the name and set checkbox.
$pieces = explode(".", $existing_search_core);
$search_core_name = array_pop($pieces);
if ($search_core_name === $existing_search_core) {
$acquia_search_env_filter = TRUE;
$existing_search_core = '';
}
$search_settings = Settings::get('acquia_search');
if (isset($search_settings['server_overrides'][$server->id()])) {
$messages[] = $this->t("Search core settings are currently being overridden by settings.php");
if (is_string($search_settings['server_overrides'][$server->id()])) {
$existing_search_core = $search_settings['server_overrides'][$server->id()];
}
elseif (isset($search_settings['server_overrides'][$server->id()]['core_id'])) {
$existing_search_core = $search_settings['server_overrides'][$server->id()]['core_id'];
}
if (isset($search_settings['server_overrides'][$server->id()]['read_only'])) {
$existing_read_only = ($search_settings['server_overrides'][$server->id()]['read_only'] === TRUE) ? SearchApiSolrAcquiaConnector::READ_ONLY : SearchApiSolrAcquiaConnector::READ_WRITE;
$read_only_disabled = TRUE;
}
$disabled = TRUE;
}
$form['#attributes']['autocomplete'] = 'off';
if (!empty($messages)) {
$form['acquia_search_msg']['#markup'] = $this->formatAcquiaSearchMessage($messages);
}
$form['acquia_search_env_filter'] = [
'#type' => 'checkbox',
'#title' => $this->t("Select by core name only"),
'#default_value' => $acquia_search_env_filter ?? FALSE,
'#disabled' => $disabled,
"#description" => $this->t("Check this to select a core name and allow Acquia Search to automatically determine the environment. <strong>Caution!</strong> Non-Acquia environments (local, other hosting, etc) will automatically get the 'dev' environment. Setting the read-only option below to automatic will enable read/write on the dev core in all non-acquia environments."),
];
$core_names_only = [];
foreach ($options as $k => $v) {
if ($k !== '') {
$p = explode(".", $v);
$core_name = array_pop($p);
$core_names_only[$core_name] = $core_name;
}
}
$form['acquia_search_core_names'] = [
'#title' => $this->t('Select a core currently available for your application'),
'#type' => 'select',
'#options' => $core_names_only,
'#default_value' => $search_core_name,
'#disabled' => $disabled,
'#description' => $this->t("Acquia Search will attempt to choose a core corresponding to this name and the current environment. Refer to the Acquia Search README.md for more information. If you wish to use the builtin core selector, use the included Search API server included with the `acquia_search_defaults` module."),
'#states' => [
'visible' => [
':input[name="backend_config[connector_config][acquia_search_env_filter]"]' => ['checked' => TRUE],
],
],
];
// Acquia Search core form selector.
$form['acquia_search_core_id'] = [
'#title' => $this->t('Select a core currently available for your application'),
'#type' => 'select',
'#options' => $options,
'#default_value' => $existing_search_core,
'#disabled' => $disabled,
'#description' => $this->t("This search core will persist between environments. 'prod' cores are read-only unless explicitly set in settings.php. Refer to the Acquia Search README.md for more information. If you wish to use the builtin core selector, use the included Search API server included with the `acquia_search_defaults` module."),
'#states' => [
'invisible' => [
':input[name="backend_config[connector_config][acquia_search_env_filter]"]' => ['checked' => TRUE],
],
],
];
$read_only_disabled = $read_only_disabled ?? $disabled;
if (isset($search_settings['read_only'])) {
$form['acquia_search_default_msg']['#markup'] = $this->formatAcquiaSearchMessage([$this->t("Read Only is being globally set by settings.php.")]);
$existing_read_only = ($search_settings['read_only'] === TRUE) ? SearchApiSolrAcquiaConnector::READ_ONLY : SearchApiSolrAcquiaConnector::READ_WRITE;
$read_only_disabled = TRUE;
}
$form['acquia_search_override_readonly'] = [
'#title' => $this->t("Read only mode"),
'#type' => 'select',
'#options' => [
SearchApiSolrAcquiaConnector::OVERRIDE_AUTO_SET => $this->t("Automatic detection by environment"),
SearchApiSolrAcquiaConnector::READ_ONLY => $this->t("Force read-only mode"),
SearchApiSolrAcquiaConnector::READ_WRITE => $this->t("Force read/write mode"),
],
'#default_value' => $existing_read_only,
'#disabled' => $read_only_disabled,
'#description' => $this->t("Determine how drupal should interact with this core. Automatic detection sets read/write only when the selected core matches the Acquia Environment. Force read only will always set the core to read only regardless of environment, including prod. Force read/write will allow Drupal to write to whatever core is selected, <strong>Use with caution!</strong>"),
];
return $form;
}
/**
* Helper method to provide a formatted search message on forms.
*
* @param array $messages
* The Messages to display.
*
* @return string
* The formatted message.
*/
private function formatAcquiaSearchMessage(array $messages): string {
if (empty($messages)) {
return '';
}
$header = '<div class="messages-list"><div class="messages-list__wrapper"><div class="messages-list__item messages messages--warning"><div class="messages_header"><h2 class="messages__title">Acquia Search Warning</h2></div>';
$message_block = '<div class="messages__content">';
foreach ($messages as $message) {
$message_block .= '<div class="message">' . $message . '</div>';
}
$closure = '</div></div></div>';
return $header . $message_block . $closure;
}
/**
* Empty form validate handler. Without this, the endpoint will crash.
*
* @param array $form
* Form render array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* Form state object.
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
// Determine if config should be set to the core name or full core id.
if ($form_state->getValue('acquia_search_env_filter') === 1) {
$form_state->setValue('acquia_search_core', $form_state->getValue('acquia_search_core_names'));
}
else {
$form_state->setValue('acquia_search_core', $form_state->getValue('acquia_search_core_id'));
}
// Remove form-only values.
$form_state->unsetValue('acquia_search_env_filter');
$form_state->unsetValue('acquia_search_core_names');
$form_state->unsetValue('acquia_search_core_id');
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
// Exclude Acquia Specific settings.
$dynamic_config_keys = [
'scheme',
'host',
'port',
'path',
'core',
'overridden_by_acquia_search',
];
foreach ($dynamic_config_keys as $key) {
unset($this->configuration[$key]);
}
}
/**
* {@inheritdoc}
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
public function adjustTimeout(int $seconds, string $timeout = self::QUERY_TIMEOUT, ?Endpoint &$endpoint = NULL): int {
$this->connect();
if (!$endpoint) {
$endpoint = $this->solr->getEndpoint();
}
$previous_timeout = $endpoint->getOption($timeout);
$options = $endpoint->getOptions();
$options[$timeout] = $seconds;
$endpoint = new AcquiaEndpoint($options);
return $previous_timeout;
}
/**
* Returns the default endpoint name.
*
* @return string
* The endpoint name.
*/
public static function getDefaultEndpoint() {
return AcquiaEndpoint::DEFAULT_NAME;
}
/**
* {@inheritdoc}
*/
public function useTimeout(string $timeout = self::QUERY_TIMEOUT, ?Endpoint $endpoint = NULL) {}
/**
* {@inheritdoc}
*/
protected function connect() {
if (!$this->solr instanceof Client) {
$configuration = $this->configuration;
$this->solr = $this->createClient($configuration);
}
return $this->solr;
}
/**
* Solarium Client Creation.
*
* @param array $configuration
* Ignored in favor of the default Acquia Configuration.
*
* @return object|\Solarium\Client|null
* Solarium Client.
*/
protected function createClient(array &$configuration) {
return new Client(
new Psr18Adapter(
$this->acquiaGuzzle,
new RequestFactory(),
new StreamFactory()
),
$this->eventDispatcher,
[
'endpoint' => [
'search_api_solr' => new AcquiaEndpoint($configuration),
],
]
);
}
/**
* Outputs list of Acquia Search cores.
*
* @return array
* Form element allowing selection of a core.
*/
protected function getAcquiaSearchCores(): array {
$cores = $this->acquiaSearchApiClient->getSearchIndexes();
if ($cores === FALSE) {
return ['' => $this->t('Unable to connect to Acquia Search API.')];
}
// No cores? Return empty core selection list.
if (empty($cores)) {
return ['' => $this->t('Your subscription contains no cores.')];
}
$options[''] = $this->t('- Select a Core -');
foreach (array_keys($cores) as $core) {
$options[$core] = $core;
}
return $options;
}
/**
* {@inheritdoc}
*/
protected function getServerUri() {
$this->connect();
return $this->getEndpointUri($this->solr->getEndpoint(self::ENDPOINT_KEY));
}
/**
* Override any other endpoints by getting the Acquia Default endpoint.
*
* @param string $key
* The endpoint name (ignored).
*
* @return \Solarium\Core\Client\Endpoint
* The endpoint in question.
*/
public function getEndpoint($key = 'search_api_solr') {
$this->connect();
return $this->solr->getEndpoint();
}
/**
* {@inheritdoc}
*
* Avoid providing an valid Update query if module determines this server
* should be locked down (as indicated by the overridden_by_acquia_search
* server option).
*
* @throws \Exception
* If this index in read-only mode.
*/
public function getUpdateQuery() {
$this->connect();
$overridden = $this->solr->getEndpoint(self::ENDPOINT_KEY)->getOption('overridden_by_acquia_search');
if ($overridden === SearchApiSolrAcquiaConnector::READ_ONLY) {
$message = 'The Search API Server serving this index is currently in read-only mode.';
$this->getLogger()->error($message);
throw new \Exception($message);
}
return $this->solr->createUpdate();
}
/**
* {@inheritdoc}
*/
public function getExtractQuery() {
$this->connect();
$query = $this->solr->createExtract();
$subscription_data = $this->subscription->getSubscription();
$query->setHandler($subscription_data['acquia_search']['extract_query_handler_option'] ?? 'update/extract');
return $query;
}
/**
* {@inheritdoc}
*/
public function getMoreLikeThisQuery() {
$this->connect();
$query = $this->solr->createMoreLikeThis();
$query->setHandler('mlt');
$query->addParam('qt', 'mlt');
return $query;
}
/**
* {@inheritdoc}
*/
public function getSolrVersion($force_auto_detect = FALSE) {
try {
return parent::getSolrVersion($force_auto_detect);
}
catch (\Exception $exception) {
return $this->t('Unavailable: @message', ['@message' => $exception->getMessage()]);
}
}
/**
* {@inheritdoc}
*/
public function viewSettings() {
// If connection settings are empty, direct users to Acquia Connector.
if (!$this->subscription->hasCredentials()) {
$uri = Url::fromRoute('acquia_connector.setup_oauth');
$link = Link::fromTextAndUrl($this->t('Setup Acquia Connector'), $uri);
$this->messenger->addWarning($this->t('Cannot connect to Search due to missing credentials. @acquia_connector.', ['@acquia_connector' => $link->toString()]));
}
else {
$uri = Url::fromUri('https://www.acquia.com/products-services/acquia-search', ['absolute' => TRUE]);
$link = Link::fromTextAndUrl($this->t('Acquia Search'), $uri);
$this->messenger->addMessage($this->t('Search is provided by @acquia_search.', ['@acquia_search' => $link->toString()]));
}
return parent::viewSettings();
}
/**
* {@inheritdoc}
*/
protected function getEndpointUri(Endpoint $endpoint): string {
try {
return $endpoint->getCoreBaseUri();
}
catch (UnexpectedValueException $exception) {
$this->getLogger()->error($this->t('Unavailable: @message', ['@message' => $exception->getMessage()]));
return $endpoint->getServerUri();
}
}
/**
* {@inheritdoc}
*/
public function reloadCore() {
return FALSE;
}
}
