Merge pull request #9202 from backstage/add-search-document

search-common: add SearchDocument type for use in the frontend
This commit is contained in:
MT Lewis
2022-03-17 17:13:35 +00:00
committed by GitHub
17 changed files with 187 additions and 57 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': minor
---
**BREAKING**: The `authorization` property is no longer returned on search results when queried. Note: this will only result in a breaking change if you have custom code in your frontend that relies on the `authorization.resourceRef` property on documents.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-search-common': patch
---
- Introduce `SearchDocument` type. This type contains the subset of `IndexableDocument` properties relevant to the frontend, and is intended to be used for documents returned to the frontend from the search API.
- `SearchResultSet` is now a wrapper for documents of type `SearchDocument`, and is intended to be used in the frontend. This isn't a breaking change, since `IndexableDocument`s are valid `SearchDocument`s, so the old and new types are compatible.
- Introduce `IndexableResultSet` type, which wraps `IndexableDocument` instances in the same way as `SearchResultSet`.
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-search-backend': patch
'@backstage/plugin-search-backend-node': patch
'@backstage/plugin-search-backend-module-elasticsearch': patch
'@backstage/plugin-search-backend-module-pg': patch
---
Use new `IndexableResultSet` type as return type of query method in `SearchEngine` implementation.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Switch to `SearchDocument` type in `DefaultResultListItem` props
@@ -10,10 +10,10 @@ import { Client } from '@elastic/elasticsearch';
import { Config } from '@backstage/config';
import type { ConnectionOptions } from 'tls';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { IndexableResultSet } from '@backstage/plugin-search-common';
import { Logger } from 'winston';
import { SearchEngine } from '@backstage/plugin-search-common';
import { SearchQuery } from '@backstage/plugin-search-common';
import { SearchResultSet } from '@backstage/plugin-search-common';
// Warning: (ae-missing-release-tag) "ElasticSearchClientOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-search-backend-module-elasticsearch" does not have an export "ElasticSearchEngine"
@@ -119,7 +119,7 @@ export class ElasticSearchSearchEngine implements SearchEngine {
getIndexer(type: string): Promise<ElasticSearchSearchEngineIndexer>;
newClient<T>(create: (options: ElasticSearchClientOptions) => T): T;
// (undocumented)
query(query: SearchQuery): Promise<SearchResultSet>;
query(query: SearchQuery): Promise<IndexableResultSet>;
// Warning: (ae-forgotten-export) The symbol "ElasticSearchQueryTranslator" needs to be exported by the entry point index.d.ts
//
// (undocumented)
@@ -21,9 +21,9 @@ import {
import { Config } from '@backstage/config';
import {
IndexableDocument,
IndexableResultSet,
SearchEngine,
SearchQuery,
SearchResultSet,
} from '@backstage/plugin-search-common';
import { Client } from '@elastic/elasticsearch';
import esb from 'elastic-builder';
@@ -192,7 +192,7 @@ export class ElasticSearchSearchEngine implements SearchEngine {
return indexer;
}
async query(query: SearchQuery): Promise<SearchResultSet> {
async query(query: SearchQuery): Promise<IndexableResultSet> {
const { elasticSearchQuery, documentTypes, pageSize } =
this.translator(query);
const queryIndices = documentTypes
@@ -5,11 +5,11 @@
```ts
import { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { IndexableResultSet } from '@backstage/plugin-search-common';
import { Knex } from 'knex';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
import { SearchQuery } from '@backstage/plugin-search-common';
import { SearchResultSet } from '@backstage/plugin-search-common';
// Warning: (ae-missing-release-tag) "ConcretePgSearchQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -88,7 +88,7 @@ export class PgSearchEngine implements SearchEngine {
// (undocumented)
getIndexer(type: string): Promise<PgSearchEngineIndexer>;
// (undocumented)
query(query: SearchQuery): Promise<SearchResultSet>;
query(query: SearchQuery): Promise<IndexableResultSet>;
// (undocumented)
setTranslator(
translator: (query: SearchQuery) => ConcretePgSearchQuery,
@@ -15,7 +15,10 @@
*/
import { PluginDatabaseManager } from '@backstage/backend-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
import {
SearchQuery,
IndexableResultSet,
} from '@backstage/plugin-search-common';
import { PgSearchEngineIndexer } from './PgSearchEngineIndexer';
import {
DatabaseDocumentStore,
@@ -81,7 +84,7 @@ export class PgSearchEngine implements SearchEngine {
});
}
async query(query: SearchQuery): Promise<SearchResultSet> {
async query(query: SearchQuery): Promise<IndexableResultSet> {
const { pgQuery, pageSize } = this.translator(query);
const rows = await this.databaseStore.transaction(async tx =>
+2 -2
View File
@@ -9,13 +9,13 @@ import { DocumentCollatorFactory } from '@backstage/plugin-search-common';
import { DocumentDecoratorFactory } from '@backstage/plugin-search-common';
import { DocumentTypeInfo } from '@backstage/plugin-search-common';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { IndexableResultSet } from '@backstage/plugin-search-common';
import { Logger } from 'winston';
import { default as lunr_2 } from 'lunr';
import { QueryTranslator } from '@backstage/plugin-search-common';
import { Readable } from 'stream';
import { SearchEngine } from '@backstage/plugin-search-common';
import { SearchQuery } from '@backstage/plugin-search-common';
import { SearchResultSet } from '@backstage/plugin-search-common';
import { Transform } from 'stream';
import { Writable } from 'stream';
@@ -87,7 +87,7 @@ export class LunrSearchEngine implements SearchEngine {
// (undocumented)
protected lunrIndices: Record<string, lunr_2.Index>;
// (undocumented)
query(query: SearchQuery): Promise<SearchResultSet>;
query(query: SearchQuery): Promise<IndexableResultSet>;
// (undocumented)
setTranslator(translator: LunrQueryTranslator): void;
// (undocumented)
@@ -16,8 +16,8 @@
import {
IndexableDocument,
IndexableResultSet,
SearchQuery,
SearchResultSet,
QueryTranslator,
SearchEngine,
} from '@backstage/plugin-search-common';
@@ -147,7 +147,7 @@ export class LunrSearchEngine implements SearchEngine {
return indexer;
}
async query(query: SearchQuery): Promise<SearchResultSet> {
async query(query: SearchQuery): Promise<IndexableResultSet> {
const { lunrQueryBuilder, documentTypes, pageSize } = this.translator(
query,
) as ConcreteLunrQuery;
@@ -196,8 +196,8 @@ export class LunrSearchEngine implements SearchEngine {
? encodePageCursor({ page: page - 1 })
: undefined;
// Translate results into SearchResultSet
const realResultSet: SearchResultSet = {
// Translate results into IndexableResultSet
const realResultSet: IndexableResultSet = {
results: results.slice(offset, offset + pageSize).map(d => {
return { type: d.type, document: this.docStore[d.result.ref] };
}),
@@ -25,12 +25,12 @@ import {
} from '@backstage/plugin-permission-common';
import {
DocumentTypeInfo,
IndexableResult,
IndexableResultSet,
QueryRequestOptions,
QueryTranslator,
SearchEngine,
SearchQuery,
SearchResult,
SearchResultSet,
} from '@backstage/plugin-search-common';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
@@ -85,7 +85,7 @@ export class AuthorizedSearchEngine implements SearchEngine {
async query(
query: SearchQuery,
options: QueryRequestOptions,
): Promise<SearchResultSet> {
): Promise<IndexableResultSet> {
const queryStartTime = Date.now();
const authorizer = new DataLoader(
@@ -144,7 +144,7 @@ export class AuthorizedSearchEngine implements SearchEngine {
const { page } = decodePageCursor(query.pageCursor);
const targetResults = (page + 1) * this.pageSize;
let filteredResults: SearchResult[] = [];
let filteredResults: IndexableResult[] = [];
let nextPageCursor: string | undefined;
let latencyBudgetExhausted = false;
@@ -183,7 +183,7 @@ export class AuthorizedSearchEngine implements SearchEngine {
}
private async filterResults(
results: SearchResult[],
results: IndexableResult[],
typeDecisions: Record<string, AuthorizeDecision>,
authorizer: DataLoader<AuthorizeQuery, AuthorizeDecision>,
) {
@@ -19,7 +19,6 @@ import { ConfigReader } from '@backstage/config';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import {
IndexBuilder,
LunrSearchEngine,
SearchEngine,
} from '@backstage/plugin-search-backend-node';
import express from 'express';
@@ -39,8 +38,19 @@ describe('createRouter', () => {
beforeAll(async () => {
const logger = getVoidLogger();
const searchEngine = new LunrSearchEngine({ logger });
const indexBuilder = new IndexBuilder({ logger, searchEngine });
mockSearchEngine = {
getIndexer: jest.fn(),
setTranslator: jest.fn(),
query: jest.fn().mockResolvedValue({
results: [],
nextPageCursor: '',
previousPageCursor: '',
}),
};
const indexBuilder = new IndexBuilder({
logger,
searchEngine: mockSearchEngine,
});
const router = await createRouter({
engine: indexBuilder.getSearchEngine(),
@@ -56,7 +66,7 @@ describe('createRouter', () => {
});
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
describe('GET /query', () => {
@@ -101,6 +111,42 @@ describe('createRouter', () => {
});
});
it('removes backend-only properties from search documents', async () => {
mockSearchEngine.query.mockResolvedValue({
results: [
{
type: 'software-catalog',
document: {
text: 'foo',
title: 'bar baz',
location: '/catalog/default/component/example',
authorization: {
resourceRef: 'component:default/example',
},
},
},
],
nextPageCursor: '',
previousPageCursor: '',
});
const response = await request(app).get('/query');
expect(response.status).toEqual(200);
expect(response.body).toMatchObject({
results: [
{
type: 'software-catalog',
document: {
text: 'foo',
title: 'bar baz',
location: '/catalog/default/component/example',
},
},
],
});
});
describe('search result filtering', () => {
beforeAll(async () => {
const logger = getVoidLogger();
+13 -1
View File
@@ -26,6 +26,7 @@ import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-no
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import {
DocumentTypeInfo,
IndexableResultSet,
SearchResultSet,
} from '@backstage/plugin-search-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
@@ -89,6 +90,17 @@ export async function createRouter(
}),
});
const toSearchResults = (resultSet: IndexableResultSet): SearchResultSet => ({
...resultSet,
results: resultSet.results.map(result => ({
...result,
document: {
...result.document,
authorization: undefined,
},
})),
});
const router = Router();
router.get(
'/query',
@@ -116,7 +128,7 @@ export async function createRouter(
try {
const resultSet = await engine?.query(query, { token });
res.send(filterResultSet(resultSet));
res.send(filterResultSet(toSearchResults(resultSet)));
} catch (err) {
throw new Error(
`There was a problem performing the search query. ${err}`,
+36 -20
View File
@@ -30,14 +30,17 @@ export type DocumentTypeInfo = {
};
// @beta
export interface IndexableDocument {
export type IndexableDocument = SearchDocument & {
authorization?: {
resourceRef: string;
};
location: string;
text: string;
title: string;
}
};
// @beta (undocumented)
export type IndexableResult = Result<IndexableDocument>;
// @beta (undocumented)
export type IndexableResultSet = ResultSet<IndexableDocument>;
// @beta
export type QueryRequestOptions = {
@@ -47,13 +50,38 @@ export type QueryRequestOptions = {
// @beta
export type QueryTranslator = (query: SearchQuery) => unknown;
// @beta (undocumented)
export interface Result<TDocument extends SearchDocument> {
// (undocumented)
document: TDocument;
// (undocumented)
type: string;
}
// @beta (undocumented)
export interface ResultSet<TDocument extends SearchDocument> {
// (undocumented)
nextPageCursor?: string;
// (undocumented)
previousPageCursor?: string;
// (undocumented)
results: Result<TDocument>[];
}
// @beta
export interface SearchDocument {
location: string;
text: string;
title: string;
}
// @beta
export interface SearchEngine {
getIndexer(type: string): Promise<Writable>;
query(
query: SearchQuery,
options?: QueryRequestOptions,
): Promise<SearchResultSet>;
): Promise<IndexableResultSet>;
setTranslator(translator: QueryTranslator): void;
}
@@ -70,20 +98,8 @@ export interface SearchQuery {
}
// @beta (undocumented)
export interface SearchResult {
// (undocumented)
document: IndexableDocument;
// (undocumented)
type: string;
}
export type SearchResult = Result<SearchDocument>;
// @beta (undocumented)
export interface SearchResultSet {
// (undocumented)
nextPageCursor?: string;
// (undocumented)
previousPageCursor?: string;
// (undocumented)
results: SearchResult[];
}
export type SearchResultSet = ResultSet<SearchDocument>;
```
+37 -9
View File
@@ -31,26 +31,45 @@ export interface SearchQuery {
/**
* @beta
*/
export interface SearchResult {
export interface Result<TDocument extends SearchDocument> {
type: string;
document: IndexableDocument;
document: TDocument;
}
/**
* @beta
*/
export interface SearchResultSet {
results: SearchResult[];
export interface ResultSet<TDocument extends SearchDocument> {
results: Result<TDocument>[];
nextPageCursor?: string;
previousPageCursor?: string;
}
/**
* Base properties that all indexed documents must include, as well as some
* common properties that documents are encouraged to use where appropriate.
* @beta
*/
export interface IndexableDocument {
export type SearchResult = Result<SearchDocument>;
/**
* @beta
*/
export type SearchResultSet = ResultSet<SearchDocument>;
/**
* @beta
*/
export type IndexableResult = Result<IndexableDocument>;
/**
* @beta
*/
export type IndexableResultSet = ResultSet<IndexableDocument>;
/**
* Base properties that all search documents must include.
* @beta
*/
export interface SearchDocument {
/**
* The primary name of the document (e.g. name, title, identifier, etc).
*/
@@ -66,7 +85,16 @@ export interface IndexableDocument {
* is clicked).
*/
location: string;
}
/**
* Properties related to indexing of documents. This type is only useful for
* backends working directly with documents being inserted or retrieved from
* search indexes. When dealing with documents in the frontend, use
* {@link SearchDocument}.
* @beta
*/
export type IndexableDocument = SearchDocument & {
/**
* Optional authorization information to be used when determining whether this
* search result should be visible to a given user.
@@ -77,7 +105,7 @@ export interface IndexableDocument {
*/
resourceRef: string;
};
}
};
/**
* Information about a specific document type. Intended to be used in the
@@ -178,5 +206,5 @@ export interface SearchEngine {
query(
query: SearchQuery,
options?: QueryRequestOptions,
): Promise<SearchResultSet>;
): Promise<IndexableResultSet>;
}
+2 -2
View File
@@ -9,13 +9,13 @@ import { ApiRef } from '@backstage/core-plugin-api';
import { AsyncState } from 'react-use/lib/useAsync';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { InputBaseProps } from '@material-ui/core';
import { JsonObject } from '@backstage/types';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { SearchDocument } from '@backstage/plugin-search-common';
import { SearchQuery } from '@backstage/plugin-search-common';
import { SearchResult as SearchResult_2 } from '@backstage/plugin-search-common';
import { SearchResultSet } from '@backstage/plugin-search-common';
@@ -31,7 +31,7 @@ export const DefaultResultListItem: ({
}: {
icon?: ReactNode;
secondaryAction?: ReactNode;
result: IndexableDocument;
result: SearchDocument;
lineClamp?: number | undefined;
}) => JSX.Element;
@@ -15,7 +15,7 @@
*/
import React, { ReactNode } from 'react';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { SearchDocument } from '@backstage/plugin-search-common';
import {
ListItem,
ListItemIcon,
@@ -29,7 +29,7 @@ import TextTruncate from 'react-text-truncate';
type Props = {
icon?: ReactNode;
secondaryAction?: ReactNode;
result: IndexableDocument;
result: SearchDocument;
lineClamp?: number;
};