[Search] Lunr search engine support (#5290)
* add lunr package Signed-off-by: Emma Indal <emma.indahl@gmail.com> * add search translator type and search engine interface Signed-off-by: Emma Indal <emma.indahl@gmail.com> * (wip) add support for lunr search engine Signed-off-by: Emma Indal <emma.indahl@gmail.com> * lunr search engine support Signed-off-by: Emma Indal <emma.indahl@gmail.com> * clean up todo comments Signed-off-by: Emma Indal <emma.indahl@gmail.com> * typing and cleanups Signed-off-by: Emma Indal <emma.indahl@gmail.com> * move lunr type package from dev deps to deps Signed-off-by: Emma Indal <emma.indahl@gmail.com> * check if documents exist to index Signed-off-by: Emma Indal <emma.indahl@gmail.com> * test fixup Signed-off-by: Emma Indal <emma.indahl@gmail.com> * changeset Signed-off-by: Emma Indal <emma.indahl@gmail.com> * move LunrSearchEngine.ts to /engines and add tests Signed-off-by: Emma Indal <emma.indahl@gmail.com> * update imports Signed-off-by: Emma Indal <emma.indahl@gmail.com> * update error message Signed-off-by: Emma Indal <emma.indahl@gmail.com> * add comment to index rotation Signed-off-by: Emma Indal <emma.indahl@gmail.com> * Update plugins/search-backend-node/src/types.ts Signed-off-by: Fredrik Adelöw freben@gmail.com Co-authored-by: Fredrik Adelöw <freben@gmail.com> Signed-off-by: Emma Indal <emma.indahl@gmail.com> * Update plugins/search-backend-node/src/engines/LunrSearchEngine.ts Signed-off-by: Emma Indal <emma.indahl@gmail.com> Co-authored-by: Fredrik Adelöw <freben@gmail.com> * Update plugins/search-backend-node/src/engines/LunrSearchEngine.ts Signed-off-by: Emma Indal <emma.indahl@gmail.com> Co-authored-by: Fredrik Adelöw <freben@gmail.com> * fix imports Signed-off-by: Emma Indal <emma.indahl@gmail.com> * use type assertion to specify more specific ConcreteLunrQuery type Signed-off-by: Emma Indal <emma.indahl@gmail.com> * fix imports Signed-off-by: Emma Indal <emma.indahl@gmail.com> * consistent naming Signed-off-by: Emma Indal <emma.indahl@gmail.com> * change search engine to be parameter of constructor in indexBuilder Signed-off-by: Emma Indal <emma.indahl@gmail.com> * make engine required in router options and pass it through in createRouter used in standalone server Signed-off-by: Emma Indal <emma.indahl@gmail.com> * fix tests Signed-off-by: Emma Indal <emma.indahl@gmail.com> * delete import Signed-off-by: Emma Indal <emma.indahl@gmail.com> * add types to SearchQuery interface to make it possible to scope to specific index + test Signed-off-by: Emma Indal <emma.indahl@gmail.com> * clean up tests Signed-off-by: Emma Indal <emma.indahl@gmail.com> * handle case when a filter is added on a field that does not exist on all documents + test Signed-off-by: Emma Indal <emma.indahl@gmail.com> Co-authored-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend': patch
|
||||
'@backstage/plugin-search-backend-node': patch
|
||||
---
|
||||
|
||||
Lunr Search Engine support
|
||||
@@ -15,7 +15,10 @@
|
||||
*/
|
||||
import { useHotCleanup } from '@backstage/backend-common';
|
||||
import { createRouter } from '@backstage/plugin-search-backend';
|
||||
import { IndexBuilder } from '@backstage/plugin-search-backend-node';
|
||||
import {
|
||||
IndexBuilder,
|
||||
LunrSearchEngine,
|
||||
} from '@backstage/plugin-search-backend-node';
|
||||
import { PluginEnvironment } from '../types';
|
||||
import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
|
||||
|
||||
@@ -23,7 +26,8 @@ export default async function createPlugin({
|
||||
logger,
|
||||
discovery,
|
||||
}: PluginEnvironment) {
|
||||
const indexBuilder = new IndexBuilder({ logger });
|
||||
const searchEngine = new LunrSearchEngine({ logger });
|
||||
const indexBuilder = new IndexBuilder({ logger, searchEngine });
|
||||
|
||||
indexBuilder.addCollator({
|
||||
type: 'software-catalog',
|
||||
@@ -38,6 +42,7 @@ export default async function createPlugin({
|
||||
useHotCleanup(module, () => clearInterval(timerId));
|
||||
|
||||
return await createRouter({
|
||||
engine: indexBuilder.getSearchEngine(),
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { JsonObject } from '@backstage/config';
|
||||
export interface SearchQuery {
|
||||
term: string;
|
||||
filters?: JsonObject;
|
||||
types?: string[];
|
||||
pageCursor: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/search-common": "^0.1.1",
|
||||
"winston": "^3.2.1"
|
||||
"winston": "^3.2.1",
|
||||
"lunr": "^2.3.9",
|
||||
"@types/lunr": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "^0.6.0",
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
|
||||
import { DocumentCollator, DocumentDecorator } from '@backstage/search-common';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import {
|
||||
RegisterCollatorParameters,
|
||||
RegisterDecoratorParameters,
|
||||
SearchEngine,
|
||||
} from './types';
|
||||
|
||||
interface CollatorEnvelope {
|
||||
@@ -27,18 +29,25 @@ interface CollatorEnvelope {
|
||||
}
|
||||
|
||||
type IndexBuilderOptions = {
|
||||
searchEngine: SearchEngine;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export class IndexBuilder {
|
||||
private collators: Record<string, CollatorEnvelope>;
|
||||
private decorators: Record<string, DocumentDecorator[]>;
|
||||
private searchEngine: SearchEngine;
|
||||
private logger: Logger;
|
||||
|
||||
constructor({ logger }: IndexBuilderOptions) {
|
||||
constructor({ logger, searchEngine }: IndexBuilderOptions) {
|
||||
this.collators = {};
|
||||
this.decorators = {};
|
||||
this.logger = logger;
|
||||
this.searchEngine = searchEngine;
|
||||
}
|
||||
|
||||
getSearchEngine(): SearchEngine {
|
||||
return this.searchEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +115,13 @@ export class IndexBuilder {
|
||||
documents = await decorators[i].execute(documents);
|
||||
}
|
||||
|
||||
// TODO: push documents to a configured search engine.
|
||||
if (!documents || documents.length === 0) {
|
||||
this.logger.info(`No documents for type "${type}" to index`);
|
||||
return;
|
||||
}
|
||||
|
||||
// pushing documents to index to a configured search engine.
|
||||
this.searchEngine.index(type, documents);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { LunrSearchEngine } from './LunrSearchEngine';
|
||||
import { SearchEngine } from '../types';
|
||||
|
||||
describe('LunrSearchEngine', () => {
|
||||
let testLunrSearchEngine: SearchEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
testLunrSearchEngine = new LunrSearchEngine({ logger: getVoidLogger() });
|
||||
});
|
||||
|
||||
describe('translator', () => {
|
||||
it('query translator invoked', async () => {
|
||||
const translatorSpy = jest.spyOn(testLunrSearchEngine, 'translator');
|
||||
|
||||
// Translate query and ensure the translator was invoked.
|
||||
await testLunrSearchEngine.translator({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
expect(translatorSpy).toHaveBeenCalled();
|
||||
expect(translatorSpy).toHaveBeenCalledWith({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
pageCursor: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return translated query', async () => {
|
||||
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
expect(mockedTranslatedQuery).toMatchObject({
|
||||
documentTypes: ['*'],
|
||||
lunrQueryString: 'testTerm',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return translated query with 1 filter', async () => {
|
||||
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
|
||||
term: 'testTerm',
|
||||
filters: { kind: 'testKind' },
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
expect(mockedTranslatedQuery).toMatchObject({
|
||||
documentTypes: ['*'],
|
||||
lunrQueryString: 'testTerm +kind:testKind',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return translated query with multiple filters', async () => {
|
||||
const mockedTranslatedQuery = await testLunrSearchEngine.translator({
|
||||
term: 'testTerm',
|
||||
filters: { kind: 'testKind', namespace: 'testNameSpace' },
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
expect(mockedTranslatedQuery).toMatchObject({
|
||||
documentTypes: ['*'],
|
||||
lunrQueryString: 'testTerm +kind:testKind +namespace:testNameSpace',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('should perform search query', async () => {
|
||||
const querySpy = jest.spyOn(testLunrSearchEngine, 'query');
|
||||
|
||||
// Perform search query and ensure the query func was invoked.
|
||||
const mockedSearchResult = await testLunrSearchEngine.query({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
expect(querySpy).toHaveBeenCalled();
|
||||
expect(querySpy).toHaveBeenCalledWith({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
// Should return 0 results as nothing is indexed here
|
||||
expect(mockedSearchResult).toMatchObject({ results: [] });
|
||||
});
|
||||
|
||||
it('should perform search query and return 0 results on no match', async () => {
|
||||
const mockDocuments = [
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock indexing of 1 document
|
||||
testLunrSearchEngine.index('test-index', mockDocuments);
|
||||
|
||||
// Perform search query
|
||||
const mockedSearchResult = await testLunrSearchEngine.query({
|
||||
term: 'testTerm',
|
||||
filters: {},
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
// Should return 0 results as we are mocking the indexing of 1 document but with no match on the fields
|
||||
expect(mockedSearchResult).toMatchObject({ results: [] });
|
||||
});
|
||||
|
||||
it('should perform search query and return search results on match', async () => {
|
||||
const mockDocuments = [
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock indexing of 1 document
|
||||
testLunrSearchEngine.index('test-index', mockDocuments);
|
||||
|
||||
// Perform search query
|
||||
const mockedSearchResult = await testLunrSearchEngine.query({
|
||||
term: 'testTitle',
|
||||
filters: {},
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
// Should return 1 result as we are mocking the indexing of 1 document with match on the title field
|
||||
expect(mockedSearchResult).toMatchObject({
|
||||
results: [
|
||||
{
|
||||
document: {
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform search query and return search results on match with filters', async () => {
|
||||
const mockDocuments = [
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location',
|
||||
},
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location2',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock indexing of 2 documents
|
||||
testLunrSearchEngine.index('test-index', mockDocuments);
|
||||
|
||||
// Perform search query
|
||||
const mockedSearchResult = await testLunrSearchEngine.query({
|
||||
term: 'testTitle',
|
||||
filters: { location: 'test/location2' },
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
// Should return 1 of 2 results as we are
|
||||
// 1. Mocking the indexing of 2 documents
|
||||
// 2. Matching on the location field with the filter { location: 'test/location2' }
|
||||
expect(mockedSearchResult).toMatchObject({
|
||||
results: [
|
||||
{
|
||||
document: {
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location2',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform search query and return search results on match with filter and not fail on missing field', async () => {
|
||||
const mockDocuments = [
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDocuments2 = [
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location2',
|
||||
extraField: 'testExtraField',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock 2 indices with 1 document each
|
||||
testLunrSearchEngine.index('test-index', mockDocuments);
|
||||
testLunrSearchEngine.index('test-index-2', mockDocuments2);
|
||||
// Perform search query scoped to "test-index-2" with a filter on the field "extraField"
|
||||
const mockedSearchResult = await testLunrSearchEngine.query({
|
||||
term: 'testTitle',
|
||||
pageCursor: '',
|
||||
filters: { extraField: 'testExtraField' },
|
||||
});
|
||||
|
||||
expect(mockedSearchResult).toMatchObject({
|
||||
results: [
|
||||
{
|
||||
document: {
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location2',
|
||||
extraField: 'testExtraField',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform search query and return search results on match, scoped to specific index', async () => {
|
||||
const mockDocuments = [
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location',
|
||||
},
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location2',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDocuments2 = [
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location3',
|
||||
},
|
||||
{
|
||||
title: 'testTitle',
|
||||
text: 'testText',
|
||||
location: 'test/location4',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock 2 indices with 2 documents each
|
||||
testLunrSearchEngine.index('test-index', mockDocuments);
|
||||
testLunrSearchEngine.index('test-index-2', mockDocuments2);
|
||||
|
||||
// Perform search query scoped to "test-index-2"
|
||||
const mockedSearchResult = await testLunrSearchEngine.query({
|
||||
term: 'testTitle',
|
||||
types: ['test-index-2'],
|
||||
pageCursor: '',
|
||||
});
|
||||
|
||||
expect(mockedSearchResult).toMatchObject({
|
||||
results: [
|
||||
{
|
||||
document: {
|
||||
location: 'test/location3',
|
||||
text: 'testText',
|
||||
title: 'testTitle',
|
||||
},
|
||||
},
|
||||
{
|
||||
document: {
|
||||
location: 'test/location4',
|
||||
text: 'testText',
|
||||
title: 'testTitle',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('index', () => {
|
||||
it('should index document', async () => {
|
||||
const indexSpy = jest.spyOn(testLunrSearchEngine, 'index');
|
||||
const mockDocuments = [
|
||||
{
|
||||
title: 'testTerm',
|
||||
text: 'testText',
|
||||
location: 'test/location',
|
||||
},
|
||||
];
|
||||
|
||||
// call index func and ensure the index func was invoked.
|
||||
testLunrSearchEngine.index('test-index', mockDocuments);
|
||||
expect(indexSpy).toHaveBeenCalled();
|
||||
expect(indexSpy).toHaveBeenCalledWith('test-index', [
|
||||
{ title: 'testTerm', text: 'testText', location: 'test/location' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
SearchQuery,
|
||||
IndexableDocument,
|
||||
SearchResultSet,
|
||||
} from '@backstage/search-common';
|
||||
import lunr from 'lunr';
|
||||
import { Logger } from 'winston';
|
||||
import { SearchEngine, QueryTranslator } from '../types';
|
||||
|
||||
type ConcreteLunrQuery = {
|
||||
lunrQueryString: string;
|
||||
documentTypes: string[];
|
||||
};
|
||||
|
||||
export class LunrSearchEngine implements SearchEngine {
|
||||
protected lunrIndices: Record<string, lunr.Index> = {};
|
||||
protected docStore: Record<string, IndexableDocument>;
|
||||
protected logger: Logger;
|
||||
|
||||
constructor({ logger }: { logger: Logger }) {
|
||||
this.logger = logger;
|
||||
this.docStore = {};
|
||||
}
|
||||
|
||||
translator: QueryTranslator = ({
|
||||
term,
|
||||
filters,
|
||||
types,
|
||||
}: SearchQuery): ConcreteLunrQuery => {
|
||||
let lunrQueryFilters;
|
||||
if (filters) {
|
||||
lunrQueryFilters = Object.entries(filters)
|
||||
.map(([key, value]) => ` +${key}:${value}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
return {
|
||||
lunrQueryString: `${term}${lunrQueryFilters || ''}`,
|
||||
documentTypes: types || ['*'],
|
||||
};
|
||||
};
|
||||
|
||||
index(type: string, documents: IndexableDocument[]): void {
|
||||
const lunrBuilder = new lunr.Builder();
|
||||
// Make this lunr index aware of all relevant fields.
|
||||
Object.keys(documents[0]).forEach(field => {
|
||||
lunrBuilder.field(field);
|
||||
});
|
||||
|
||||
// Set "location" field as reference field
|
||||
lunrBuilder.ref('location');
|
||||
|
||||
documents.forEach((document: IndexableDocument) => {
|
||||
// Add document to Lunar index
|
||||
lunrBuilder.add(document);
|
||||
// Store documents in memory to be able to look up document using the ref during query time
|
||||
// This is not how you should implement your SearchEngine implementation! Do not copy!
|
||||
this.docStore[document.location] = document;
|
||||
});
|
||||
|
||||
// "Rotate" the index by simply overwriting any existing index of the same name.
|
||||
this.lunrIndices[type] = lunrBuilder.build();
|
||||
}
|
||||
|
||||
query(query: SearchQuery): Promise<SearchResultSet> {
|
||||
const { lunrQueryString, documentTypes } = this.translator(
|
||||
query,
|
||||
) as ConcreteLunrQuery;
|
||||
|
||||
const results: lunr.Index.Result[] = [];
|
||||
|
||||
if (documentTypes.length === 1 && documentTypes[0] === '*') {
|
||||
// Iterate over all this.lunrIndex values.
|
||||
Object.values(this.lunrIndices).forEach(i => {
|
||||
try {
|
||||
results.push(...i.search(lunrQueryString));
|
||||
} catch (err) {
|
||||
// if a field does not exist on a index, we can see that as a no-match
|
||||
if (
|
||||
err instanceof lunr.QueryParseError &&
|
||||
err.message.startsWith('unrecognised field')
|
||||
)
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Iterate over the filtered list of this.lunrIndex keys.
|
||||
Object.keys(this.lunrIndices)
|
||||
.filter(d => documentTypes.includes(d))
|
||||
.forEach(d => {
|
||||
try {
|
||||
results.push(...this.lunrIndices[d].search(lunrQueryString));
|
||||
} catch (err) {
|
||||
// if a field does not exist on a index, we can see that as a no-match
|
||||
if (
|
||||
err instanceof lunr.QueryParseError &&
|
||||
err.message.startsWith('unrecognised field')
|
||||
)
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort results.
|
||||
results.sort((doc1, doc2) => {
|
||||
return doc2.score - doc1.score;
|
||||
});
|
||||
|
||||
// Translate results into SearchResultSet
|
||||
const resultSet: SearchResultSet = {
|
||||
results: results.map(d => {
|
||||
return { document: this.docStore[d.ref] };
|
||||
}),
|
||||
};
|
||||
|
||||
return Promise.resolve(resultSet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { LunrSearchEngine } from './LunrSearchEngine';
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IndexableDocument,
|
||||
} from '@backstage/search-common';
|
||||
import { IndexBuilder } from './IndexBuilder';
|
||||
import { LunrSearchEngine, SearchEngine } from './index';
|
||||
|
||||
class TestDocumentCollator implements DocumentCollator {
|
||||
async execute() {
|
||||
@@ -35,12 +36,18 @@ class TestDocumentDecorator implements DocumentDecorator {
|
||||
}
|
||||
|
||||
describe('IndexBuilder', () => {
|
||||
let testSearchEngine: SearchEngine;
|
||||
let testIndexBuilder: IndexBuilder;
|
||||
let testCollator: DocumentCollator;
|
||||
let testDecorator: DocumentDecorator;
|
||||
|
||||
beforeEach(() => {
|
||||
testIndexBuilder = new IndexBuilder({ logger: getVoidLogger() });
|
||||
const logger = getVoidLogger();
|
||||
testSearchEngine = new LunrSearchEngine({ logger });
|
||||
testIndexBuilder = new IndexBuilder({
|
||||
logger,
|
||||
searchEngine: testSearchEngine,
|
||||
});
|
||||
testCollator = new TestDocumentCollator();
|
||||
testDecorator = new TestDocumentDecorator();
|
||||
});
|
||||
|
||||
@@ -15,3 +15,5 @@
|
||||
*/
|
||||
|
||||
export { IndexBuilder } from './IndexBuilder';
|
||||
export { LunrSearchEngine } from './engines';
|
||||
export type { SearchEngine } from './types';
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DocumentCollator, DocumentDecorator } from '@backstage/search-common';
|
||||
import {
|
||||
DocumentCollator,
|
||||
DocumentDecorator,
|
||||
IndexableDocument,
|
||||
SearchQuery,
|
||||
SearchResultSet,
|
||||
} from '@backstage/search-common';
|
||||
|
||||
/**
|
||||
* Parameters required to register a collator.
|
||||
@@ -51,3 +57,20 @@ export interface RegisterDecoratorParameters {
|
||||
*/
|
||||
types?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A type of function responsible for translating an abstract search query into
|
||||
* a concrete query relevant to a particular search engine.
|
||||
*/
|
||||
export type QueryTranslator = (query: SearchQuery) => unknown;
|
||||
|
||||
/**
|
||||
* Interface that must be implemented by specific search engines, responsible
|
||||
* for performing indexing and querying and translating abstract queries into
|
||||
* concrete, search engine-specific queries.
|
||||
*/
|
||||
export interface SearchEngine {
|
||||
translator: QueryTranslator;
|
||||
index(type: string, documents: IndexableDocument[]): void;
|
||||
query(query: SearchQuery): Promise<SearchResultSet>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.6.0",
|
||||
"@backstage/search-common": "^0.1.1",
|
||||
"@backstage/plugin-search-backend-node": "^0.1.2",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import {
|
||||
IndexBuilder,
|
||||
LunrSearchEngine,
|
||||
} from '@backstage/plugin-search-backend-node';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
@@ -24,8 +28,12 @@ describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = getVoidLogger();
|
||||
const searchEngine = new LunrSearchEngine({ logger });
|
||||
const indexBuilder = new IndexBuilder({ logger, searchEngine });
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
engine: indexBuilder.getSearchEngine(),
|
||||
logger,
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
@@ -18,23 +18,24 @@ import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { SearchQuery, SearchResultSet } from '@backstage/search-common';
|
||||
import { SearchEngine } from '@backstage/plugin-search-backend-node';
|
||||
|
||||
type RouterOptions = {
|
||||
engine: SearchEngine;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export async function createRouter({
|
||||
engine,
|
||||
logger,
|
||||
}: RouterOptions): Promise<express.Router> {
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/query',
|
||||
async (
|
||||
req: express.Request<any, unknown, unknown, SearchQuery>,
|
||||
res: express.Response<SearchResultSet>,
|
||||
) => {
|
||||
// TODO: Actually transform req.params into search engine specific query.
|
||||
const { term, filters = {}, pageCursor = '' } = req.query;
|
||||
logger.info(
|
||||
`Search request received: ${term}, ${JSON.stringify(
|
||||
@@ -43,13 +44,12 @@ export async function createRouter({
|
||||
);
|
||||
|
||||
try {
|
||||
// TODO: Actually query search engine.
|
||||
// TODO: And actually transform results into frontend-readable result
|
||||
res.send({
|
||||
results: [],
|
||||
});
|
||||
const results = await engine?.query(req.query);
|
||||
res.send(results);
|
||||
} catch (err) {
|
||||
throw new Error(`There was a problem performing the search query.`);
|
||||
throw new Error(
|
||||
`There was a problem performing the search query. ${err}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,6 +18,10 @@ import { createServiceBuilder } from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
import {
|
||||
LunrSearchEngine,
|
||||
IndexBuilder,
|
||||
} from '@backstage/plugin-search-backend-node';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
@@ -29,8 +33,14 @@ export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'search-backend' });
|
||||
const searchEngine = new LunrSearchEngine({ logger });
|
||||
const indexBuilder = new IndexBuilder({ logger, searchEngine });
|
||||
logger.debug('Starting application server...');
|
||||
|
||||
// TODO: stub out some documents/indices?
|
||||
|
||||
const router = await createRouter({
|
||||
engine: indexBuilder.getSearchEngine(),
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -6210,6 +6210,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
|
||||
integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==
|
||||
|
||||
"@types/lunr@^2.3.3":
|
||||
version "2.3.3"
|
||||
resolved "https://registry.npmjs.org/@types/lunr/-/lunr-2.3.3.tgz#ec985618fd2712c010f8edab4f1ae7784ad7c583"
|
||||
integrity sha512-09sXZZVsB3Ib41U0fC+O1O+4UOZT1bl/e+/QubPxpqDWHNEchvx/DEb1KJMOwq6K3MTNzZFoNSzVdR++o1DVnw==
|
||||
|
||||
"@types/luxon@^1.25.0":
|
||||
version "1.26.0"
|
||||
resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.0.tgz#8e783986370ad3bb9f885d93eb1a91caeecaed36"
|
||||
@@ -17927,6 +17932,11 @@ lru-queue@^0.1.0:
|
||||
dependencies:
|
||||
es5-ext "~0.10.2"
|
||||
|
||||
lunr@^2.3.9:
|
||||
version "2.3.9"
|
||||
resolved "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1"
|
||||
integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==
|
||||
|
||||
luxon@1.25.0, luxon@^1.25.0:
|
||||
version "1.25.0"
|
||||
resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72"
|
||||
|
||||
Reference in New Issue
Block a user