Merge pull request #9202 from backstage/add-search-document
search-common: add SearchDocument type for use in the frontend
This commit is contained in:
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -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 =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>;
|
||||
```
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user