@glue42/search-api
Version:
Glue42 Search API
353 lines (247 loc) • 14.4 kB
text/typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable indent */
import { Glue42Core } from "@glue42/core";
import { nanoid } from "nanoid";
import { Glue42Search } from "../../search";
import { ActiveClientQuery, InteropServerProvider, LegacySearchResultItem } from "../shared/types";
import { GlueController } from "./glue";
import {
default as CallbackRegistryFactory,
CallbackRegistry
} from "callback-registry";
import { ProtocolProviderError, ProtocolSearchCompleted, ProtocolSearchResultsBatch, SEARCH_QUERY_STATUSES } from "../shared/protocol";
import { queryStatusDecoder, protocolProviderErrorDecoder, protocolSearchCompletedDecoder, protocolSearchResultsBatchDecoder } from "../shared/decoders";
import { ModelFactory } from "../services/model-factory";
export class ClientController {
private readonly registry: CallbackRegistry = CallbackRegistryFactory();
private readonly activeQueryLookup: { [key in string]: ActiveClientQuery } = {};
private readonly queryIdToMasterIdLookup: { [key in string]: string } = {};
private pendingDebounce: ({ resolve: (value: Glue42Search.Query) => void, reject: (reason?: any) => void })[] = [];
private debounceTimer?: NodeJS.Timer;
private debounceMS = 0;
constructor(
private readonly logger: Glue42Core.Logger.API,
private readonly glueController: GlueController,
private readonly modelFactory: ModelFactory
) {}
public setDebounceMS(data: { milliseconds: number, commandId: string }): void {
this.logger.info(`[${data.commandId}] Setting the debounceMS to: ${data.milliseconds}`);
this.debounceMS = data.milliseconds;
this.logger.info(`[${data.commandId}] debounceMS set to: ${data.milliseconds}`);
}
public getDebounceMS(data: { commandId: string }): number {
this.logger.info(`[${data.commandId}] Getting the debounceMS`);
return this.debounceMS;
}
public async query(data: { queryConfig: Glue42Search.QueryConfig, commandId: string }, skipDebounce?: boolean): Promise<Glue42Search.Query> {
if (this.debounceMS && !skipDebounce) {
return this.debounceQuery(data);
}
await this.glueController.registerMainClientMethod(this.handleProviderCall.bind(this));
const { queryConfig, commandId } = data;
this.logger.info(`[${commandId}] Initiating a query request`);
let allProvidersInfo = await this.glueController.getAllProvidersInfo();
this.logger.trace(`[${commandId}] Got all available providers: ${JSON.stringify(allProvidersInfo)}`);
if (queryConfig.providers) {
this.logger.info(`[${commandId}] Filtering providers by explicitly allowed providers.`);
allProvidersInfo = this.filterProvidersByAllowList(allProvidersInfo, queryConfig.providers);
}
if (queryConfig.types) {
this.logger.info(`[${commandId}] Filtering providers by explicitly allowed types.`);
allProvidersInfo = this.filterProvidersByAllowedTypes(allProvidersInfo, queryConfig.types);
}
if (!allProvidersInfo.length) {
this.logger.warn(`[${commandId}] There are no providers that can handle the query for ${data.queryConfig.search}`);
}
this.logger.info(`[${commandId}] Sending query request to providers: ${JSON.stringify(allProvidersInfo)}`);
const allQueryResponses = await this.glueController.sendQueryRequest(queryConfig, allProvidersInfo);
this.logger.info(`[${commandId}] Received responses from the providers: ${JSON.stringify(allQueryResponses)}`);
const masterQueryId = this.generateMasterQueryId();
const queryModel = this.modelFactory.buildClientQueryModel(masterQueryId, this);
this.logger.info(`[${commandId}] The query is in progress with master id: ${masterQueryId}`);
this.activeQueryLookup[masterQueryId] = {
servers: allQueryResponses,
model: queryModel
};
allQueryResponses.forEach((response) => {
this.queryIdToMasterIdLookup[response.queryId] = masterQueryId;
});
if (!allQueryResponses.length) {
// there are no providers that will handle this request
setTimeout(() => {
this.registry.execute(`on-query-completed-${masterQueryId}`);
this.cleanUpQuery(masterQueryId);
}, 0);
}
return queryModel.exposeFacade();
}
public async cancelQuery(masterQueryId: string, commandId: string): Promise<void> {
const activeQuery = this.activeQueryLookup[masterQueryId];
if (!activeQuery) {
throw new Error(`[${commandId}] Cannot cancel query: ${masterQueryId}, because this query does not exist`);
}
const interopIds = activeQuery.servers;
this.logger.info(`[${commandId}] Sending cancel query requests`);
await Promise.all(interopIds.map((serverId) => {
this.logger.trace(`[${commandId}] Sending cancel query request to ${serverId.interopId} with queryId: ${serverId.queryId}`);
return this.glueController.sendQueryCancelRequest({ id: serverId.queryId }, { instance: serverId.interopId });
}));
this.logger.info(`[${commandId}] The query was cancelled`);
}
public processClientOnResults(data: { callback: (resultBatch: Glue42Search.QueryResultBatch) => void, masterQueryId: string, commandId: string }): Glue42Search.UnsubscribeFunction {
return this.registry.add(`on-query-results-${data.masterQueryId}`, data.callback);
}
public processClientOnCompleted(data: { callback: () => void, masterQueryId: string, commandId: string }): Glue42Search.UnsubscribeFunction {
return this.registry.add(`on-query-completed-${data.masterQueryId}`, data.callback);
}
public processClientOnError(data: { callback: (error: Glue42Search.QueryError) => void, masterQueryId: string, commandId: string }): Glue42Search.UnsubscribeFunction {
return this.registry.add(`on-query-error-${data.masterQueryId}`, data.callback);
}
private async handleProviderCall(args: any): Promise<any> {
const { status } = args;
const validatedOperation = queryStatusDecoder.runWithException(status);
const commandId = nanoid(10);
switch (validatedOperation) {
case SEARCH_QUERY_STATUSES.done:
return this.handleQueryCompleted({ completedConfig: args, commandId });
case SEARCH_QUERY_STATUSES.inProgress:
return this.handleQueryResults({ resultsBatch: args, commandId });
case SEARCH_QUERY_STATUSES.error:
return this.handleQueryError({ error: args, commandId });
default:
throw new Error(`Unrecognized status: ${status}`);
}
}
private handleQueryResults(data: { resultsBatch: ProtocolSearchResultsBatch, commandId: string }): void {
const { resultsBatch, commandId } = data;
this.logger.trace(`[${commandId}] Processing a results batch from provider: ${resultsBatch.provider?.name} with id: ${resultsBatch.provider?.id}`);
const verifiedResultsBatch = protocolSearchResultsBatchDecoder.runWithException(resultsBatch);
const masterQueryId = this.queryIdToMasterIdLookup[verifiedResultsBatch.queryId];
if (!masterQueryId) {
this.logger.warn(`[${commandId}] Received results for an unknown query. Provider ${JSON.stringify(verifiedResultsBatch.provider)}, items: ${JSON.stringify(verifiedResultsBatch.items)}`);
return;
}
this.logger.trace(`[${commandId}] The results batch is validated, forwarding to the callbacks`);
const translatedResults: Glue42Search.QueryResult[] = this.checkTransformLegacyResults(verifiedResultsBatch.items);
const results: Glue42Search.QueryResultBatch = {
provider: verifiedResultsBatch.provider,
results: translatedResults
};
this.registry.execute(`on-query-results-${masterQueryId}`, results);
}
private handleQueryCompleted(data: { completedConfig: ProtocolSearchCompleted, commandId: string }): void {
const { completedConfig, commandId } = data;
this.logger.trace(`[${commandId}] Processing a query completed message from query id: ${completedConfig.queryId}`);
const verifiedCompleteConfig = protocolSearchCompletedDecoder.runWithException(completedConfig);
const masterQueryId = this.queryIdToMasterIdLookup[verifiedCompleteConfig.queryId];
if (!masterQueryId) {
this.logger.warn(`[${commandId}] Received completed message for an unknown query. Provider query id: ${JSON.stringify(verifiedCompleteConfig.queryId)}`);
return;
}
if (verifiedCompleteConfig.items.length) {
const translatedResults: Glue42Search.QueryResult[] = this.checkTransformLegacyResults(verifiedCompleteConfig.items);
const results: Glue42Search.QueryResultBatch = {
results: translatedResults
};
this.registry.execute(`on-query-results-${masterQueryId}`, results);
}
delete this.queryIdToMasterIdLookup[verifiedCompleteConfig.queryId];
const activeQuery = this.activeQueryLookup[masterQueryId];
activeQuery.servers = activeQuery.servers.filter((server) => server.queryId !== verifiedCompleteConfig.queryId);
if (activeQuery.servers.length) {
this.logger.trace(`[${commandId}] Waiting for more providers to complete`);
return;
}
this.logger.trace(`[${commandId}] All providers are done, marking this query as completed`);
this.registry.execute(`on-query-completed-${masterQueryId}`);
this.cleanUpQuery(masterQueryId);
}
private handleQueryError(data: { error: ProtocolProviderError, commandId: string }): void {
const { error, commandId } = data;
this.logger.trace(`[${commandId}] Processing an error message from query: ${error.queryId}`);
const validatedError = protocolProviderErrorDecoder.runWithException(error);
const masterQueryId = this.queryIdToMasterIdLookup[validatedError.queryId];
if (!masterQueryId) {
this.logger.warn(`[${commandId}] Received error message for an unknown query. Provider query id: ${JSON.stringify(validatedError.queryId)} and message: ${JSON.stringify(validatedError.errorMessage)}`);
return;
}
const queryError: Glue42Search.QueryError = {
error: validatedError.errorMessage,
provider: validatedError.provider
};
this.registry.execute(`on-query-error-${masterQueryId}`, queryError);
}
private filterProvidersByAllowList(servers: InteropServerProvider[], allowed: Glue42Search.ProviderData[]): InteropServerProvider[] {
const allowedLookup = allowed.reduce<{ [key in string]: boolean }>((lookup, allowedEntry) => {
lookup[allowedEntry.id] = true;
return lookup;
}, {});
return servers.filter((server) => {
const serverProviders = server.info.providers;
return serverProviders.some((provider) => allowedLookup[provider.id]);
});
}
private filterProvidersByAllowedTypes(servers: InteropServerProvider[], allowed: Glue42Search.SearchType[]): InteropServerProvider[] {
const allowedLookup = allowed.reduce<{ [key in string]: boolean }>((lookup, allowedEntry) => {
lookup[allowedEntry.name] = true;
return lookup;
}, {});
return servers.filter((server) => {
const allTypes = server.info.supportedTypes;
// this is an interop provider which might have multiple providers, some have defined types, some not
if (allTypes.some((searchType) => searchType === "*")) {
return true;
}
// allows providers with no defined type to receive all queries
if (!allTypes || !allTypes.length) {
return true;
}
return allTypes.some((supportedType) => allowedLookup[supportedType]);
});
}
private generateMasterQueryId(): string {
const queryId = nanoid(10);
if (this.activeQueryLookup[queryId]) {
return this.generateMasterQueryId();
}
return queryId;
}
private cleanUpQuery(masterQueryId: string): void {
this.registry.clearKey(`on-query-results-${masterQueryId}`);
this.registry.clearKey(`on-query-completed-${masterQueryId}`);
this.registry.clearKey(`on-query-error-${masterQueryId}`);
delete this.activeQueryLookup[masterQueryId];
}
private debounceQuery(data: { queryConfig: Glue42Search.QueryConfig, commandId: string }): Promise<Glue42Search.Query> {
return new Promise<Glue42Search.Query>((res, rej) => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
const currentPending = [...this.pendingDebounce];
this.pendingDebounce = [];
this.query(data, true)
.then((query) => currentPending.forEach(({ resolve }) => resolve(query)))
.catch((error) => currentPending.forEach(({ reject }) => reject(error)));
}, this.debounceMS);
this.pendingDebounce.push({ resolve: res, reject: rej });
});
}
private checkTransformLegacyResults(items: (Glue42Search.QueryResult | LegacySearchResultItem)[]): Glue42Search.QueryResult[] {
if (!items.length) {
return [];
}
const sampleItem = items[0];
if (!sampleItem || typeof sampleItem.type === "object") {
return items as Glue42Search.QueryResult[];
}
return (items as LegacySearchResultItem[]).map<Glue42Search.QueryResult>((item) => {
return {
type: { name: item.type, displayName: item.category },
id: item.id,
displayName: item.displayName,
description: item.description,
iconURL: item.iconURL,
action: item.action
};
});
}
}