UNPKG

@glue42/search-api

Version:

Glue42 Search API

353 lines (247 loc) 14.4 kB
/* 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 }; }); } }