From 021cfbb5152ccb97e008ff61ade65e71ceb48193 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 13 Apr 2023 18:40:10 -0400 Subject: [PATCH] Add an openapi spec to the search backend. Signed-off-by: Aramis Sennyey --- .changeset/quiet-bikes-smash.md | 5 + .changeset/twelve-zebras-repair.md | 5 + packages/backend-openapi-utils/api-report.md | 13 +- .../backend-openapi-utils/src/types/params.ts | 15 +- plugins/search-backend/package.json | 1 + .../src/schema/openapi.generated.ts | 211 ++++++++++++++++++ .../search-backend/src/schema/openapi.yaml | 135 +++++++++++ plugins/search-backend/src/service/router.ts | 76 +++---- yarn.lock | 1 + 9 files changed, 397 insertions(+), 65 deletions(-) create mode 100644 .changeset/quiet-bikes-smash.md create mode 100644 .changeset/twelve-zebras-repair.md create mode 100644 plugins/search-backend/src/schema/openapi.generated.ts create mode 100644 plugins/search-backend/src/schema/openapi.yaml diff --git a/.changeset/quiet-bikes-smash.md b/.changeset/quiet-bikes-smash.md new file mode 100644 index 0000000000..ca751adaf7 --- /dev/null +++ b/.changeset/quiet-bikes-smash.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-openapi-utils': patch +--- + +Corrected resolution of parameter nested schema to use central schemas. diff --git a/.changeset/twelve-zebras-repair.md b/.changeset/twelve-zebras-repair.md new file mode 100644 index 0000000000..8d74080c38 --- /dev/null +++ b/.changeset/twelve-zebras-repair.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +Added an OpenAPI 3.0 spec and enforced schema-first model on the router. diff --git a/packages/backend-openapi-utils/api-report.md b/packages/backend-openapi-utils/api-report.md index 180a7846ed..a4db8ac7bd 100644 --- a/packages/backend-openapi-utils/api-report.md +++ b/packages/backend-openapi-utils/api-report.md @@ -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 extends infer R +> = SchemaRef extends infer R ? R extends ImmutableSchemaObject ? R extends JSONSchema7 ? FromSchema @@ -549,16 +548,6 @@ type RequiredMap< [P in Exclude, undefined>]: NonNullable; }; -// @public (undocumented) -type ResolveDocParameterSchema< - Doc extends RequiredDoc, - Schema extends ImmutableParameterObject['schema'], -> = Schema extends ImmutableReferenceObject - ? 'parameters' extends ComponentTypes - ? ComponentRef - : never - : Schema; - // @public (undocumented) type Response_2< Doc extends RequiredDoc, diff --git a/packages/backend-openapi-utils/src/types/params.ts b/packages/backend-openapi-utils/src/types/params.ts index dce743e6e8..b737450db7 100644 --- a/packages/backend-openapi-utils/src/types/params.ts +++ b/packages/backend-openapi-utils/src/types/params.ts @@ -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 - ? ComponentRef - : never - : Schema; - /** * @public */ export type ParameterSchema< Doc extends RequiredDoc, Schema extends ImmutableParameterObject['schema'], -> = ResolveDocParameterSchema extends infer R +> = SchemaRef extends infer R ? R extends ImmutableSchemaObject ? R extends JSONSchema7 ? FromSchema diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index 5fa59077db..d3648c33bf 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -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", diff --git a/plugins/search-backend/src/schema/openapi.generated.ts b/plugins/search-backend/src/schema/openapi.generated.ts new file mode 100644 index 0000000000..b8f5f5b35e --- /dev/null +++ b/plugins/search-backend/src/schema/openapi.generated.ts @@ -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; diff --git a/plugins/search-backend/src/schema/openapi.yaml b/plugins/search-backend/src/schema/openapi.yaml new file mode 100644 index 0000000000..e7f44c9f11 --- /dev/null +++ b/plugins/search-backend/src/schema/openapi.yaml @@ -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 diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 10f47d180f..3643eec38a 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -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 = z.lazy(() => { const jsonValueSchema: z.ZodSchema = z.lazy(() => @@ -146,49 +148,43 @@ export async function createRouter( })), }); - const router = Router(); - router.get( - '/query', - async ( - req: express.Request, - res: express.Response, - ) => { - const parseResult = requestSchema.passthrough().safeParse(req.query); + const router = Router() as ApiRouter; + 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()); diff --git a/yarn.lock b/yarn.lock index fab9791710..e7e8717ff7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"