Add an openapi spec to the search backend.

Signed-off-by: Aramis Sennyey <sennyeya@amazon.com>
This commit is contained in:
Aramis Sennyey
2023-04-13 18:40:10 -04:00
committed by Renan Mendes Carvalho
parent 3b45c59744
commit 021cfbb515
9 changed files with 397 additions and 65 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-openapi-utils': patch
---
Corrected resolution of parameter nested schema to use central schemas.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': patch
---
Added an OpenAPI 3.0 spec and enforced schema-first model on the router.
+1 -12
View File
@@ -333,7 +333,6 @@ declare namespace internal {
ImmutableSchemaObject,
DocParameter,
DocParameters,
ResolveDocParameterSchema,
ParameterSchema,
MapToSchema,
ParametersSchema,
@@ -412,7 +411,7 @@ type OptionalMap<
type ParameterSchema<
Doc extends RequiredDoc,
Schema extends ImmutableParameterObject['schema'],
> = ResolveDocParameterSchema<Doc, Schema> extends infer R
> = SchemaRef<Doc, Schema> extends infer R
? R extends ImmutableSchemaObject
? R extends JSONSchema7
? FromSchema<R>
@@ -549,16 +548,6 @@ type RequiredMap<
[P in Exclude<PickRequiredKeys<T>, undefined>]: NonNullable<T[P]>;
};
// @public (undocumented)
type ResolveDocParameterSchema<
Doc extends RequiredDoc,
Schema extends ImmutableParameterObject['schema'],
> = Schema extends ImmutableReferenceObject
? 'parameters' extends ComponentTypes<Doc>
? ComponentRef<Doc, 'parameters', Schema>
: never
: Schema;
// @public (undocumented)
type Response_2<
Doc extends RequiredDoc,
@@ -34,6 +34,7 @@ import {
MapDiscriminatedUnion,
PathTemplate,
RequiredDoc,
SchemaRef,
} from './common';
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
@@ -76,25 +77,13 @@ export type DocParameters<
}
: never;
/**
* @public
*/
export type ResolveDocParameterSchema<
Doc extends RequiredDoc,
Schema extends ImmutableParameterObject['schema'],
> = Schema extends ImmutableReferenceObject
? 'parameters' extends ComponentTypes<Doc>
? ComponentRef<Doc, 'parameters', Schema>
: never
: Schema;
/**
* @public
*/
export type ParameterSchema<
Doc extends RequiredDoc,
Schema extends ImmutableParameterObject['schema'],
> = ResolveDocParameterSchema<Doc, Schema> extends infer R
> = SchemaRef<Doc, Schema> extends infer R
? R extends ImmutableSchemaObject
? R extends JSONSchema7
? FromSchema<R>
+1
View File
@@ -57,6 +57,7 @@
"zod": "^3.21.4"
},
"devDependencies": {
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/supertest": "^2.0.8",
@@ -0,0 +1,211 @@
/*
* Copyright 2023 The Backstage Authors
*
* 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.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
export default {
openapi: '3.0.3',
info: {
title: '@backstage/plugin-search-backend',
version: '1',
description:
'The Backstage backend plugin that provides search functionality.',
license: {
name: 'Apache-2.0',
url: 'http://www.apache.org/licenses/LICENSE-2.0.html',
},
contact: {},
},
servers: [
{
url: '/',
},
],
components: {
examples: {},
headers: {},
parameters: {},
requestBodies: {},
responses: {},
schemas: {
JsonObject: {
type: 'object',
properties: {},
additionalProperties: {},
},
},
securitySchemes: {
JWT: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
paths: {
'/query': {
get: {
operationId: 'Query',
responses: {
'200': {
description: 'Ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
results: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'string',
description: 'The "type" of the given document.',
},
document: {
type: 'object',
description:
'The raw value of the document, as indexed.',
properties: {
title: {
type: 'string',
description:
'The primary name of the document (e.g. name, title, identifier, etc).',
},
text: {
type: 'string',
description:
'Free-form text of the document (e.g. description, content, etc).',
},
location: {
type: 'string',
description:
'The relative or absolute URL of the document (target when a search result is clicked).',
},
},
},
highlight: {
type: 'object',
description:
'Optional result highlight. Useful for improving the search result\ndisplay/experience.',
},
rank: {
type: 'integer',
description:
'Optional result rank, where 1 is the first/top result returned. \nUseful for understanding search effectiveness in analytics.',
},
},
required: ['type', 'document'],
additionalProperties: false,
},
},
nextPageCursor: {
type: 'string',
},
previousPageCursor: {
type: 'string',
},
numberOfResults: {
type: 'integer',
},
},
required: ['results'],
},
},
},
},
'400': {
description: 'Bad request',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: {
type: 'object',
properties: {
message: {
type: 'string',
},
},
},
},
},
},
},
},
},
security: [
{},
{
JWT: [],
},
],
parameters: [
{
name: 'term',
in: 'query',
required: false,
schema: {
type: 'string',
default: '',
},
},
{
name: 'filters',
in: 'query',
required: false,
style: 'deepObject',
explode: true,
schema: {
$ref: '#/components/schemas/JsonObject',
},
},
{
name: 'types',
in: 'query',
required: false,
schema: {
type: 'array',
items: {
type: 'string',
},
},
},
{
name: 'pageCursor',
in: 'query',
required: false,
schema: {
type: 'string',
},
},
{
name: 'pageLimit',
in: 'query',
required: false,
schema: {
type: 'integer',
},
},
],
},
},
},
} as const;
@@ -0,0 +1,135 @@
openapi: 3.0.3
info:
title: '@backstage/plugin-search-backend'
version: '1'
description: The Backstage backend plugin that provides search functionality.
license:
name: Apache-2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
contact: {}
servers:
- url: /
components:
examples: {}
headers: {}
parameters: {}
requestBodies: {}
responses: {}
schemas:
JsonObject:
type: object
properties: {}
# Free form object.
additionalProperties: {}
securitySchemes:
JWT:
type: http
scheme: bearer
bearerFormat: JWT
paths:
/query:
get:
operationId: Query
responses:
'200':
description: Ok
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
type:
type: string
description: The "type" of the given document.
document:
type: object
description: The raw value of the document, as indexed.
properties:
title:
type: string
description: The primary name of the document (e.g. name, title, identifier, etc).
text:
type: string
description: Free-form text of the document (e.g. description, content, etc).
location:
type: string
description: The relative or absolute URL of the document (target when a search result is clicked).
highlight:
type: object
description: |-
Optional result highlight. Useful for improving the search result
display/experience.
rank:
type: integer
description: |-
Optional result rank, where 1 is the first/top result returned.
Useful for understanding search effectiveness in analytics.
required:
- type
- document
additionalProperties: false
nextPageCursor:
type: string
previousPageCursor:
type: string
numberOfResults:
type: integer
required:
- results
'400':
description: Bad request
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
message:
type: string
security:
- {}
- JWT: []
parameters:
- name: term
in: query
required: false
schema:
type: string
default: ''
- name: filters
in: query
required: false
style: deepObject
explode: true
schema:
# JsonObject is used here instead of the full ZOD schema definition as
# resolution of recursive schemas isn't possible with the library we're using
# and it causes a performance hit when _trying_ to resolve them.
$ref: '#/components/schemas/JsonObject'
- name: types
in: query
required: false
schema:
type: array
items:
type: string
- name: pageCursor
in: query
required: false
schema:
type: string
- name: pageLimit
in: query
required: false
schema:
type: integer
+36 -40
View File
@@ -19,7 +19,7 @@ import Router from 'express-promise-router';
import { Logger } from 'winston';
import { z } from 'zod';
import { errorHandler } from '@backstage/backend-common';
import { ErrorResponseBody, InputError } from '@backstage/errors';
import { InputError } from '@backstage/errors';
import { Config } from '@backstage/config';
import { JsonObject, JsonValue } from '@backstage/types';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
@@ -35,6 +35,8 @@ import {
} from '@backstage/plugin-search-common';
import { SearchEngine } from '@backstage/plugin-search-common';
import { AuthorizedSearchEngine } from './AuthorizedSearchEngine';
import type { ApiRouter } from '@backstage/backend-openapi-utils';
import spec from '../schema/openapi.generated';
const jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {
const jsonValueSchema: z.ZodSchema<JsonValue> = z.lazy(() =>
@@ -146,49 +148,43 @@ export async function createRouter(
})),
});
const router = Router();
router.get(
'/query',
async (
req: express.Request,
res: express.Response<SearchResultSet | ErrorResponseBody>,
) => {
const parseResult = requestSchema.passthrough().safeParse(req.query);
const router = Router() as ApiRouter<typeof spec>;
router.get('/query', async (req, res) => {
const parseResult = requestSchema.passthrough().safeParse(req.query);
if (!parseResult.success) {
throw new InputError(`Invalid query string: ${parseResult.error}`);
if (!parseResult.success) {
throw new InputError(`Invalid query string: ${parseResult.error}`);
}
const query = parseResult.data;
logger.info(
`Search request received: term="${query.term}", filters=${JSON.stringify(
query.filters,
)}, types=${query.types ? query.types.join(',') : ''}, pageCursor=${
query.pageCursor ?? ''
}`,
);
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
try {
const resultSet = await engine?.query(query, { token });
res.json(filterResultSet(toSearchResults(resultSet)));
} catch (error) {
if (error.name === 'MissingIndexError') {
// re-throw and let the default error handler middleware captures it and serializes it with the right response code on the standard form
throw error;
}
const query = parseResult.data;
logger.info(
`Search request received: term="${
query.term
}", filters=${JSON.stringify(query.filters)}, types=${
query.types ? query.types.join(',') : ''
}, pageCursor=${query.pageCursor ?? ''}`,
throw new Error(
`There was a problem performing the search query: ${error.message}`,
);
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
try {
const resultSet = await engine?.query(query, { token });
res.json(filterResultSet(toSearchResults(resultSet)));
} catch (error) {
if (error.name === 'MissingIndexError') {
// re-throw and let the default error handler middleware captures it and serializes it with the right response code on the standard form
throw error;
}
throw new Error(
`There was a problem performing the search query: ${error.message}`,
);
}
},
);
}
});
router.use(errorHandler());
+1
View File
@@ -8510,6 +8510,7 @@ __metadata:
resolution: "@backstage/plugin-search-backend@workspace:plugins/search-backend"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-openapi-utils": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"