coveo-1.0.0-alpha1/modules/coveo_search_api/src/Plugin/search_api/backend/SearchApiCoveoBackend.php
modules/coveo_search_api/src/Plugin/search_api/backend/SearchApiCoveoBackend.php
<?php
declare(strict_types=1);
namespace Drupal\coveo_search_api\Plugin\search_api\backend;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\Url;
use Drupal\Core\Utility\Error as DrupalError;
use Drupal\coveo\API\SearchApiFactory;
use Drupal\coveo\Coveo\Index;
use Drupal\coveo\DocumentBody;
use Drupal\coveo\Entity\CoveoOrganizationInterface;
use Drupal\coveo\FieldConverter;
use Drupal\coveo_search_api\Event\CoveoDocumentAlter;
use Drupal\coveo_search_api\Event\CoveoDocumentsAlter;
use Drupal\coveo_search_api\SyncFields;
use Drupal\search_api\Backend\BackendPluginBase;
use Drupal\search_api\IndexInterface;
use Drupal\search_api\Item\FieldInterface;
use Drupal\search_api\Item\ItemInterface;
use Drupal\search_api\Plugin\PluginFormTrait;
use Drupal\search_api\Query\ConditionGroupInterface;
use Drupal\search_api\Query\QueryInterface;
use NecLimDul\Coveo\FieldApi\Api\FieldsApi;
use NecLimDul\Coveo\FieldApi\Model\FieldListingOptions;
use NecLimDul\Coveo\PushApi\Api\ItemApi;
use NecLimDul\Coveo\SearchApi\Api\SearchV2Api;
use NecLimDul\Coveo\SearchApi\Model\RestQueryParameters;
use NecLimDul\Coveo\SearchApi\Model\RestQueryResponse;
use Neclimdul\OpenapiPhp\Helper\Logging\Error as ApiError;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
// cspell:ignore compressedbinarydatafileid clickableuri
/**
* Coveo search API backend plugin.
*
* @SearchApiBackend(
* id = "coveo",
* label = @Translation("Coveo Content Backend"),
* description = @Translation("Index items using a Coveo Search.")
* )
*/
class SearchApiCoveoBackend extends BackendPluginBase implements PluginFormInterface {
use PluginFormTrait;
/**
* The module handler.
*/
private ModuleHandlerInterface $moduleHandler;
/**
* Coveo Fields API connection.
*/
private FieldsApi $fieldsApi;
/**
* Coveo SearchV2 API connection.
*/
private SearchV2Api $searchApi;
/**
* The current Index.
*/
private Index $coveoIndex;
/**
* Index API service.
*/
private ItemApi $itemApi;
public function __construct(
array $configuration,
$plugin_id,
array $plugin_definition,
ModuleHandlerInterface $moduleHandler,
private readonly EntityTypeManagerInterface $entityTypeManager,
private readonly EventDispatcherInterface $eventDispatcher,
LoggerInterface $logger,
private readonly TimeInterface $time,
private readonly SearchApiFactory $searchFactory,
private readonly SyncFields $syncFields,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->moduleHandler = $moduleHandler;
$this->setLogger($logger);
}
/**
* {@inheritdoc}
*/
public static function create(
ContainerInterface $container,
array $configuration,
$plugin_id,
$plugin_definition,
): static {
$plugin = new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('module_handler'),
$container->get('entity_type.manager'),
$container->get('event_dispatcher'),
$container->get('logger.channel.coveo'),
$container->get('datetime.time'),
$container->get('coveo.rest.search_api_factory'),
$container->get('coveo_search_api.sync'),
);
// @todo correctly set these up in the constructor.
$plugin->setFieldsHelper($container->get('search_api.fields_helper'));
$plugin->setMessenger($container->get('messenger'));
$plugin->setStringTranslation($container->get('string_translation'));
return $plugin;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'organization_name' => '',
'search_key' => '',
'view_all_content' => '',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['organization_name'] = [
'#type' => 'select',
'#title' => 'Organization',
'#default_value' => $this->getOrganizationName(),
'#options' => array_map(
fn(CoveoOrganizationInterface $org) => $org->label(),
$this->getOrganizations(),
),
'#description' => $this->t('If you don\'t see your organization, make sure its created and has a push source id. <a href=":manage">Manage organizations</a>', [
':manage' => Url::fromRoute('entity.coveo_organization.collection')->toString(),
]),
'#required' => TRUE,
];
// @todo How do you clear the search key?
$form['search_key'] = [
'#type' => 'password',
'#title' => $this->t('Query/Search API Key'),
// @todo Communicate this is populated.
'#description' => $this->t('The API key from directly querying Coveo. (Only enter when creating/changing)'),
'#default_value' => '',
'#size' => 60,
'#maxlength' => 128,
'#previous_value' => $this->getSearchKey(),
];
$form['view_all_content_delete_hack'] = [
'#type' => 'checkbox',
'#title' => $this->t('View all content delete hack'),
'#description' => $this->t('Pass "View All Content" to Coveo SearchAPI searches during delete operations. This requires a Search API key with elevated permissions that needs to be rotated more often. It can safely be ignored if you are not doing anything weird with content permissions.'),
'#default_value' => $this->configuration['view_all_content_delete_hack'] ?? FALSE,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
$values = $form_state->getValues();
if (empty($values['search_key']) && !empty($form['search_key']['#previous_value'])) {
$form_state->setValue('search_key', $form['search_key']['#previous_value']);
}
$create_fields = $this->syncFields->createInternalFields($this->getOrganization());
if ($create_fields) {
$this->getMessenger()->addMessage('Internal fields should be up to date!');
}
else {
$form_state->setError($form, $this->t('There was a problem adding the necessary fields to your Coveo source, please check permissions.'));
}
}
/**
* {@inheritdoc}
*/
public function viewSettings() {
$info = [];
$org = $this->getOrganization();
$info[] = [
'label' => $this->t('Organization'),
'info' => Link::createFromRoute(
$org?->label(),
'entity.coveo_organization.view',
['coveo_organization' => $org?->id()],
),
];
$info[] = [
'label' => $this->t('Source ID'),
'info' => $org?->getPushSourceId(),
];
$info[] = [
'label' => $this->t('Search Key'),
'info' => !empty($this->getSearchKey()) ? $this->t('--- Hidden ---') : $this->t('Not provided'),
];
if (empty($org->getPushSourceId())) {
$info['missing_push_source_id'] = [
'label' => 'Missing source id',
'info' => $this->t('Missing source id will cause indexing to fail. <a href=":org_url">Fix organization</a>', [
':org_url' => $org->toUrl()->toString(),
]),
];
}
$missing_title = [];
$missing_data = [];
$indexes = $this->getServer()->getIndexes();
foreach ($indexes as $index) {
if ($index->getField('coveo_data') === NULL && $index->getField('coveo_compressedbinarydatafileid') === NULL) {
$missing_data[] = $index->label();
}
if ($index->getField('coveo_title') === NULL) {
$missing_title[] = $index->label();
}
}
if (!empty($missing_data)) {
$info['missing_data'] = [
'label' => 'Missing data field',
'info' => $this->t("Missing data field will impair Coveo's ability to index and show a meaningful description.<br> <em>:indexes</em>", [
':indexes' => implode(',', $missing_data),
]),
];
}
if (!empty($missing_title)) {
$info['missing_title'] = [
'label' => 'Missing title field',
'info' => $this->t("Missing title field will impair Coveo's ability to show a meaningful titles in results.<br> <em>:indexes</em>", [
':indexes' => implode(',', $missing_title),
]),
];
}
return $info;
}
/**
* {@inheritdoc}
*/
public function getSupportedFeatures() {
return [];
}
/**
* {@inheritdoc}
*/
public function indexItems(IndexInterface $index, array $items) {
// If read only, just tell SearchAPI we finished without sending anything.
if ($this->isReadOnly()) {
return array_keys($items);
}
/** @var \NecLimDul\Coveo\PushApi\Model\DocumentBody[] $objects */
$objects = array_map([$this, 'prepareItem'], $items);
// Let other modules alter objects before sending them to Coveo.
// This is a great place for them to interact with more complex features
// like permissions.
$this->eventDispatcher->dispatch(new CoveoDocumentsAlter($objects, $items, $index));
$this->alterCoveoObjects($objects, $index, $items);
if (count($objects) > 0) {
$this->getIndexHelper()->addOrUpdate(
$this->getOrganization()->getPushSourceId(),
// Remove keys used for altering.
array_values($objects),
);
}
return array_keys($objects);
}
/**
* Prepare a single item for indexing.
*
* Used as a helper method in indexItem()/indexItems().
*
* @param \Drupal\search_api\Item\ItemInterface $item
* The item to index.
*
* @return \Drupal\coveo\DocumentBody
* Documents to update.
*/
private function prepareItem(ItemInterface $item): DocumentBody {
$itemId = $item->getId();
$field_converter = $this->getOrganization()
->getFieldConverter();
$document = new DocumentBody();
$document->setAdditionalProperties([
// Incorrectly cased on purpose to match documentation.
// cspell:ignore documenttype sourcetype
'documenttype' => 'WebPage',
'sourcetype' => 'Push',
FieldConverter::COVEO_ITEM_ID_FIELD => $itemId,
FieldConverter::COVEO_INDEX_ID_FIELD => $item->getIndex()->id(),
FieldConverter::COVEO_PREFIX_FIELD => $field_converter->getPrefix(),
]);
$this->setDocumentUrls($item, $document);
$this->addDocumentFields($field_converter, $document, $item->getFields());
// Allow altering of individual documents. This makes it easier to associate
// the original item with the document.
$this->eventDispatcher->dispatch(new CoveoDocumentAlter($document, $item));
return $document;
}
/**
* Resolve relevant urls onto Coveo document.
*
* @param \Drupal\search_api\Item\ItemInterface $item
* Search API item.
* @param \Drupal\coveo\DocumentBody $document
* Document body being sent to Coveo.
*/
private function setDocumentUrls(ItemInterface $item, DocumentBody $document): void {
$real_entity = $item->getOriginalObject()->getValue();
// We're going to tie this to content entities for now. If you need to index
// something else you need a different backend.
assert($real_entity instanceof ContentEntityInterface);
$url = $real_entity->toUrl('canonical', ['absolute' => TRUE]);
// Set a clickable url so users _see_ a pretty url.
// @see https://docs.coveo.com/en/86/index-content/updating-the-printable-and-clickable-uris
$document->setAdditionalProperty('clickableuri', $url->toString());
// Turn of path processing so we can get a consistent url that can be
// deleted and updated.
// @todo This might need additional values to make things like shared
// development environments work.
$url->setOption('path_processing', FALSE);
$document->setAdditionalProperty('documentId', $url->toString());
}
/**
* Add fields to document.
*
* @param \Drupal\coveo\FieldConverter $field_converter
* Field conversion service.
* @param \Drupal\coveo\DocumentBody $document
* Document body being sent to Coveo.
* @param \Drupal\search_api\Item\FieldInterface[] $item_fields
* List of item fields.
*/
private function addDocumentFields(FieldConverter $field_converter, DocumentBody $document, array $item_fields): void {
foreach ($item_fields as $field) {
$field_id = $field->getFieldIdentifier();
// Magically map to internal Coveo fields.
if ($field_converter->isCoveoMagic($field_id)) {
if ($field_id == 'coveo_data') {
// Extra magic data property.
// We wrap this in an HTML page because Coveo just doesn't listen to
// file type. Based on consulting and testing we need basically a full
// page around it because the parser tries to be clever. Things like a
// title from a svg will replace the entire record title or if there
// aren't enough tags, just treating it as plain text.
$document->setData('<html><head></head><body>' . $this->prepareFieldValues($field) . '</body></html>');
$document->setAdditionalProperty('fileExtension', '.html');
$document->setAdditionalProperty('filetype', 'html');
}
elseif (strtolower($field_id) == 'coveo_compressedbinarydatafileid') {
$filename = $this->prepareFieldValues($field);
if ($filename) {
try {
// @todo Move this to prepareFieldValues?
$file = new \SplFileObject($filename);
$document->binaryFile = $file;
}
catch (\Exception $e) {
DrupalError::logException(
$this->getLogger(),
$e,
level: LogLevel::WARNING,
);
}
}
}
else {
// Magically map Coveo fields to native fields.
$document->setAdditionalProperty(
$field_converter->convertCoveoMagicField($field_id),
$this->prepareFieldValues($field),
);
}
}
// Map normal fields.
else {
$document->setAdditionalProperty(
$field_converter->convertDrupalToCoveo($field->getFieldIdentifier()),
$this->prepareFieldValues($field),
);
}
}
}
/**
* Prepare fields for indexing.
*
* @param \Drupal\search_api\Item\FieldInterface $field
* Field being indexed.
*
* @return array|mixed
* Index value.
*/
private function prepareFieldValues(FieldInterface $field): mixed {
$type = $field->getType();
$values = NULL;
foreach ($field->getValues() as $field_value) {
switch ($type) {
case 'text':
case 'string':
case 'uri':
$field_value .= '';
// @todo This should be more defined.
if (mb_strlen($field_value) > 10000) {
$field_value = mb_substr(trim($field_value), 0, 10000);
}
$values[] = $field_value;
break;
case 'integer':
case 'duration':
case 'decimal':
if (is_numeric($field_value)) {
$values[] = 0 + $field_value;
}
else {
// @todo warn... something is wrong here and it could break things.
$values[] = $field_value;
}
break;
case 'boolean':
$values[] = (bool) $field_value;
break;
case 'date':
if (is_numeric($field_value)) {
$values[] = 0 + $field_value;
break;
}
$values[] = strtotime($field_value);
break;
default:
$values[] = $field_value;
}
}
if (!empty($values) && count($values) === 1) {
$values = reset($values);
}
return $values;
}
/**
* Applies custom modifications to indexed Coveo objects.
*
* This method allows subclasses to easily apply custom changes before the
* objects are sent to Coveo. The method is empty by default.
*
* @param \Drupal\coveo\DocumentBody[] $objects
* An array of objects to be indexed, generated from $items array.
* @param \Drupal\search_api\IndexInterface $index
* The search index for which items are being indexed.
* @param array $items
* An array of items being indexed.
*
* @see hook_coveo_objects_alter()
*/
protected function alterCoveoObjects(array &$objects, IndexInterface $index, array $items) {
$this->moduleHandler
->alter('coveo_objects', $objects, $index, $items);
}
/**
* {@inheritdoc}
*/
public function deleteItems(IndexInterface $index, array $ids) {
$organization = $this->getOrganization();
$organization_id = $organization->getOrganizationId();
$source_id = $organization->getPushSourceId();
$queries = array_map(
fn($id) => '@' . FieldConverter::COVEO_ITEM_ID_FIELD . '="' . $id . '"',
$ids,
);
$params = new RestQueryParameters([
// @todo limit to source?
'q' => '',
'aq' => implode(' OR ', $queries),
'cq' => '@' . FieldConverter::COVEO_PREFIX_FIELD . '=' . $organization->getPrefix() . ' AND @' . FieldConverter::COVEO_INDEX_ID_FIELD . '=' . $index->id(),
]);
$searchResponse = $this->getSearchApi()->searchUsingPost(
$organization_id,
(bool) ($this->configuration['view_all_content'] ?? FALSE),
$params,
);
if ($searchResponse->isSuccess()) {
$item_api = $this->getItemApi();
/** @var \NecLimDul\Coveo\SearchApi\Model\RestQueryResponse $search_result */
$search_result = $searchResponse->getData();
foreach ($search_result->getResults() as $result) {
$item_api->organizationsOrganizationIdSourcesSourceIdDocumentsDelete(
$source_id,
$result->getUri(),
$organization_id,
);
}
}
else {
ApiError::logError(
$this->getLogger(),
$searchResponse,
LogLevel::ERROR,
'Coveo lookup error. This can cause content to remain in coveo after delete or unpublish. ' . ApiError::DEFAULT_ERROR_MESSAGE,
);
}
}
/**
* {@inheritdoc}
*/
public function deleteAllIndexItems(?IndexInterface $index = NULL, $datasource_id = NULL): void {
// If read only, just tell SearchAPI we finished without sending anything.
if ($index && !$this->isReadOnly()) {
// @todo I think this clear's all indexes connected to this source which
// probably isn't ideal. Multiple "indexes" are in the same source.
$this->getItemApi()->organizationsOrganizationIdSourcesSourceIdDocumentsOlderthanDelete(
$this->getOrganization()->getPushSourceId(),
// Convert current time to microseconds. We don't use
// getCurrentMicroTime so we can round to avoid any server clock skew
// creating a value in the future.
(int) (1000 * $this->time->getCurrentTime()),
$this->getOrganizationId(),
// Shorter queue delay to avoid confusion. 15min might take unexpectedly
// long.
1
);
}
}
/**
* {@inheritdoc}
*/
public function search(QueryInterface $query): void {
$query_parameters = new RestQueryParameters();
// Get the keywords.
// @todo This is assuming one filter for now for demo.
$keys = $query->getKeys();
unset($keys['#conjunction']);
$query_parameters->setQ($keys ? $keys[0] : '');
// @todo Does this need to account for views filters as well?
$condition_group = $query->getConditionGroup();
$condition_group->addCondition(FieldConverter::COVEO_INDEX_ID_FIELD, $query->getIndex()->id());
if ($aq = $this->createFilterQuery($condition_group)) {
$query_parameters->setAq($aq);
}
// Set Facets to group by.
$query_parameters->setGroupBy($this->getFacets($query));
// Set the sorting.
$query_parameters->setSortCriteria($this->getSorts($query));
// @todo add index query to limit results.
// Do the search.
$response = $this->getSearchApi()->searchUsingPost(
$this->getOrganizationId(),
rest_query_parameters: $query_parameters,
);
if (!$response->isSuccess()) {
// $this->getLogger()->error('Something wrong here. Log it.');
ApiError::logError(
$this->getLogger(),
$response,
LogLevel::ERROR,
'Direct search error. ' . ApiError::DEFAULT_ERROR_MESSAGE,
);
return;
}
// WTF is this?!
$search_result = $response->getData();
$results = $query->getResults();
if (!$query->getOption('skip result count')) {
$results->setResultCount($search_result->getTotalCountFiltered());
}
$index = $query->getIndex();
foreach ($search_result->getResults() as $result) {
if (isset($result->getRaw()[FieldConverter::COVEO_ITEM_ID_FIELD])) {
$item_id = $result->getRaw()[FieldConverter::COVEO_ITEM_ID_FIELD];
$item = $this->getFieldsHelper()->createItem($index, $item_id);
$item->setScore($result->getScore());
$results->addResultItem($item);
}
}
if ($facet_results = $this->extractFacets($search_result)) {
$results->setExtraData('search_api_facets', $facet_results);
}
}
/**
* Creates a query filter based off condition groups.
*
* @param \Drupal\search_api\Query\ConditionGroupInterface $condition_group
* The Condition group to parse.
*
* @return string
* The query Filter.
*
* @see https://docs.coveo.com/en/2830/searching-with-coveo/about-the-query-expression
*/
private function createFilterQuery(ConditionGroupInterface $condition_group) {
$conditions = $condition_group->getConditions();
if (!empty($conditions)) {
$cg = '(';
$count = 1;
$total = count($conditions);
foreach ($conditions as $condition) {
// Nested Groups.
if ($condition instanceof ConditionGroupInterface) {
$cg .= $this->createFilterQuery($condition);
}
else {
$field = $condition->getField();
if (!$this->getFieldConverter()->isDrupalField($field)) {
$field = $this->getFieldConverter()->convertDrupalToCoveo($field);
}
$coveo_field = '@' . $field;
$operator = $condition->getOperator();
$values = $condition->getValue();
// @todo Add missing operators.
switch ($operator) {
case 'IN':
$cg .= $coveo_field . '=(' . implode(',', $values) . ')';
break;
case 'NOT IN':
$cg .= '(';
$i = 0;
foreach ($values as $value) {
if ($i) {
$cg .= ' AND ';
}
$cg .= $coveo_field . '<>' . $value;
$i++;
}
$cg .= ')';
break;
case '=':
$cg .= $coveo_field . '==' . $values;
break;
case '<>':
$cg .= $coveo_field . '<>' . $values;
break;
}
}
if ($count < $total) {
$cg .= ' ' . $condition_group->getConjunction() . ' ';
}
$count++;
}
$cg .= ')';
}
return !empty($cg) ? $cg : '';
}
/**
* Sets the current facets to group by.
*
* @param \Drupal\search_api\Query\QueryInterface $query
* The query.
*/
private function getFacets(QueryInterface $query): array {
$group_by = [];
$facet_fields = [];
$facets = $query->getOption('search_api_facets', []);
if (empty($facets)) {
return [];
}
// @todo this is happening in a hot path. Refactor to cache or prebuild
// field list is state. "Sync" process is a good candidate for this.
$fields_response = $this->getFieldsApi()->getFields(
$this->getOrganizationId(),
new FieldListingOptions()
);
if (!$fields_response->isSuccess()) {
// @todo log error?
return [];
}
$fields = $fields_response->getData();
foreach ($fields->getItems() as $field) {
if (!$this->getFieldConverter()->isDrupalField($field->getName())) {
continue;
}
if ($field->getFacet() || $field->getMultiValueFacet()) {
$facet_fields[$field->getName()] = $field;
}
}
// Avoid complexity if we don't have any external facets to match.
if (!empty($facet_fields)) {
foreach ($facets as $info) {
$prefixed_field = $this->getFieldConverter()->convertDrupalToCoveo($info['field']);
if (isset($facet_fields[$prefixed_field])) {
$group_by[] = [
'field' => '@' . $prefixed_field,
'maximumNumberOfValues' => empty($info['limit']) ? 100 : $info['limit'],
];
}
}
}
return $group_by;
}
/**
* Extracts facets from a Coveo result set.
*
* @todo This is only showing facets based on the results. In the case of an
* 'OR' Facet, we would need to show alternatives or a user can't select a
* different option.
*
* @return array
* An array describing facets that apply to the current results.
*/
private function extractFacets(RestQueryResponse $results): array {
$facets = [];
// @todo If this is an OR filter, we want non-scoped facets to show.
foreach ($results->getGroupByResults() as $facet) {
// Strip prefix.
$drupal_field = $this->getFieldConverter()->convertCoveoToDrupal($facet->getField());
foreach ($facet->getValues() as $facet_result) {
$value = $facet_result->getValue();
$facets[$drupal_field][] = [
'filter' => "\"$value\"",
'count' => $facet_result->getNumberOfResults(),
];
}
}
return $facets;
}
/**
* Sets the sort criteria.
*
* @param \Drupal\search_api\Query\QueryInterface $query
* The Query.
*/
private function getSorts(QueryInterface $query) {
$sorts = $query->getSorts();
// @todo This should be checking if a field in Coveo is sortable or else
// a false zero results could occur.
$sc = '';
foreach ($sorts as $field => $sort) {
$order = $sort === 'DESC' ? 'descending' : 'ascending';
// Coveo doesn't allow to search by relevancy asc.
if ($field === 'search_api_relevance') {
$sc .= 'relevancy';
}
else {
$coveo_field = '@' . $this->getFieldConverter()->convertDrupalToCoveo($field);
$sc .= $coveo_field . ' ' . $order;
}
$sc .= ',';
}
// Remove last comma.
$sc = rtrim($sc, ',');
return $sc;
}
/**
* {@inheritdoc}
*/
public function supportsDataType($type) {
return in_array($type, [
// Empty array.
]);
}
/**
* Get field converter helper.
*
* @return \Drupal\coveo\FieldConverter
* Field converter helper.
*/
private function getFieldConverter(): FieldConverter {
return $this->getOrganization()->getFieldConverter();
}
/**
* Get a Coveo FieldAPI instance connected to this backend.
*
* @return \NecLimDul\Coveo\FieldApi\Api\FieldsApi
* The FieldsAPI instance.
*/
public function getFieldsApi(): FieldsApi {
if (!isset($this->fieldsApi)) {
$this->fieldsApi = $this->getOrganization()
->fieldApiCreate(FieldsApi::class);
}
return $this->fieldsApi;
}
/**
* Get a Coveo Search V2 API instance.
*
* @return \NecLimDul\Coveo\SearchApi\Api\SearchV2Api
* The SearchV2API instance.
*/
public function getSearchApi(): SearchV2Api {
if (!isset($this->searchApi)) {
$this->searchApi = $this->searchFactory->create(SearchV2Api::class, $this->getSearchKey());
}
return $this->searchApi;
}
/**
* Get a Coveo Item API instance.
*
* @return \NecLimDul\Coveo\PushApi\Api\ItemApi
* The ItemAPI instance.
*/
protected function getItemApi(): ItemApi {
if (!isset($this->itemApi)) {
$this->itemApi = $this->getOrganization()
->pushApiCreate(ItemApi::class);
}
return $this->itemApi;
}
/**
* Get an indexing helper.
*
* @return \Drupal\coveo\Coveo\Index
* Coveo Index helper service instance.
*/
private function getIndexHelper(): Index {
if (!isset($this->coveoIndex)) {
$this->coveoIndex = $this->getOrganization()->getIndexHelper();
}
return $this->coveoIndex;
}
/**
* Get the organization configuration machine name.
*/
public function getOrganizationName() {
return $this->configuration['organization_name'];
}
/**
* Get the API key (provided by Coveo).
*/
private function getSearchKey() {
return $this->configuration['search_key'];
}
/**
* Get the organization ID (provided by Coveo).
*/
public function getOrganizationId(): string|null {
return $this->getOrganization()?->getOrganizationId();
}
/**
* Check if backend is in a read only state.
*
* @return bool
* True if the backend is in a read only state.
*/
public function isReadOnly(): bool {
// If read only, just tell SearchAPI we finished without sending anything.
$organization = $this->getOrganization();
// Check if organization is null to be safe. Some edge cases during config
// import can lead to this happening.
return $organization === NULL || $organization->isReadOnly();
}
/**
* Retrieves all available Coveo organizations.
*
* @return \Drupal\coveo\Entity\CoveoOrganizationInterface[]
* The available organizations.
*/
private function getOrganizations(): array {
try {
$storage = $this->entityTypeManager->getStorage('coveo_organization');
$q = $storage->getQuery()
->condition('push_source_id', '', '<>')
->condition('push_source_id', NULL, 'IS NOT NULL');
return $storage->loadMultiple($q->execute());
}
catch (InvalidPluginDefinitionException | PluginNotFoundException) {
// This should never happen.
return [];
}
}
/**
* Get the organization config associated with this backend.
*/
public function getOrganization(): CoveoOrganizationInterface|null {
$id = $this->getOrganizationName();
if ($id) {
return $this->entityTypeManager
->getStorage('coveo_organization')
->load($id);
}
return NULL;
}
}
