From 439e2986be1edba30d66d569de09e40ac6bd953b Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Tue, 21 Mar 2023 09:52:44 +0100 Subject: [PATCH 01/19] add new scaffolder action to ensure a gitlab group exists Signed-off-by: Andreas Berger --- .changeset/moody-shrimps-train.md | 5 + .../README.md | 14 +- .../api-report.md | 28 +++- .../package.json | 3 +- ...reateGitlabGroupEnsureExistsAction.test.ts | 140 ++++++++++++++++++ .../createGitlabGroupEnsureExistsAction.ts | 93 ++++++++++++ .../createGitlabProjectAccessTokenAction.ts | 79 +++------- .../createGitlabProjectDeployTokenAction.ts | 82 +++------- .../createGitlabProjectVariableAction.ts | 125 +++++----------- .../src/commonGitlabConfig.ts | 26 ++++ .../src/index.ts | 5 +- .../src/util.ts | 11 +- yarn.lock | 8 + 13 files changed, 401 insertions(+), 218 deletions(-) create mode 100644 .changeset/moody-shrimps-train.md create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts create mode 100644 plugins/scaffolder-backend-module-gitlab/src/commonGitlabConfig.ts diff --git a/.changeset/moody-shrimps-train.md b/.changeset/moody-shrimps-train.md new file mode 100644 index 0000000000..397500e2cb --- /dev/null +++ b/.changeset/moody-shrimps-train.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': minor +--- + +Add a new scaffolder action for gitlab to ensure a group exists diff --git a/plugins/scaffolder-backend-module-gitlab/README.md b/plugins/scaffolder-backend-module-gitlab/README.md index 35353cf9c1..d071ca0cf6 100644 --- a/plugins/scaffolder-backend-module-gitlab/README.md +++ b/plugins/scaffolder-backend-module-gitlab/README.md @@ -45,6 +45,9 @@ const actions = [ createGitlabProjectDeployTokenAction({ integrations: integrations, }), + createGitlabGroupEnsureExistsAction({ + integrations: integrations, + }), ]; // Create Scaffolder Router @@ -104,13 +107,22 @@ spec: url: https://github.com/TEMPLATE values: name: ${{ parameters.name }} + - id: createGitlabGroup + name: Ensure Gitlab group exists + action: gitlab:group:ensureExists + input: + repoUrl: ${{ parameters.repoUrl }} + path: + - path + - to + - group - id: publish name: Publish action: publish:gitlab input: description: This is ${{ parameters.name }} - repoUrl: ${{ parameters.repoUrl }} + repoUrl: ${{ parameters.repoUrl }}?owner=${{ steps.createGitlabGroup.output.groupId }} sourcePath: pimcore defaultBranch: main diff --git a/plugins/scaffolder-backend-module-gitlab/api-report.md b/plugins/scaffolder-backend-module-gitlab/api-report.md index 2ec05e74de..ee7f1b3a21 100644 --- a/plugins/scaffolder-backend-module-gitlab/api-report.md +++ b/plugins/scaffolder-backend-module-gitlab/api-report.md @@ -7,6 +7,18 @@ import { JsonObject } from '@backstage/types'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +// @public +export const createGitlabGroupEnsureExistsAction: (options: { + integrations: ScmIntegrationRegistry; +}) => TemplateAction< + { + repoUrl: string; + token?: string | undefined; + } & { + path: string[]; + } +>; + // @public export const createGitlabProjectAccessTokenAction: (options: { integrations: ScmIntegrationRegistry; @@ -43,16 +55,18 @@ export const createGitlabProjectVariableAction: (options: { }) => TemplateAction< { repoUrl: string; - projectId: string | number; + token?: string | undefined; + } & { key: string; value: string; + projectId: string | number; variableType: string; - variableProtected: boolean; - masked: boolean; - raw: boolean; - environmentScope: string; - token?: string | undefined; - }, + variableProtected?: boolean | undefined; + masked?: boolean | undefined; + raw?: boolean | undefined; + environmentScope?: string | undefined; + } +, JsonObject >; ``` diff --git a/plugins/scaffolder-backend-module-gitlab/package.json b/plugins/scaffolder-backend-module-gitlab/package.json index e870ba57a0..22038427ad 100644 --- a/plugins/scaffolder-backend-module-gitlab/package.json +++ b/plugins/scaffolder-backend-module-gitlab/package.json @@ -35,7 +35,8 @@ "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", - "@gitbeaker/node": "^35.8.0" + "@gitbeaker/node": "^35.8.0", + "zod": "^3.21.4" }, "devDependencies": { "@backstage/backend-common": "workspace:^", diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts new file mode 100644 index 0000000000..6fb119413f --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2021 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. + */ + +import { PassThrough } from 'stream'; +import { createGitlabGroupEnsureExistsAction } from './createGitlabGroupEnsureExistsAction'; +import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/core-app-api'; +import { ScmIntegrations } from '@backstage/integration'; + +const mockGitlabClient = { + Groups: { + search: jest.fn(), + create: jest.fn(), + }, +}; +jest.mock('@gitbeaker/node', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:group:ensureExists', () => { + const mockContext = { + workspacePath: 'lol', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should create a new group if it does not exists', async () => { + mockGitlabClient.Groups.search.mockResolvedValue([ + { + id: 1, + full_path: 'repos/bar', + }, + { + id: 2, + full_path: 'repos/foo', + }, + ]); + + mockGitlabClient.Groups.create.mockResolvedValue({ + id: 3, + full_path: 'repos/foo/bar', + }); + + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://api.gitlab.com', + }, + ], + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabGroupEnsureExistsAction({ integrations }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com', + path: ['foo', 'bar'], + }, + }); + + expect(mockGitlabClient.Groups.create).toHaveBeenCalledWith('bar', 'bar', { + parent_id: 2, + }); + + expect(mockContext.output).toHaveBeenCalledWith('groupId', 3); + }); + + it('should return existing group if it does exists', async () => { + mockGitlabClient.Groups.search.mockResolvedValue([ + { + id: 1, + full_path: 'repos/bar', + }, + { + id: 2, + full_path: 'repos/foo', + }, + { + id: 42, + full_path: 'repos/foo/bar', + }, + ]); + + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://api.gitlab.com', + }, + ], + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabGroupEnsureExistsAction({ integrations }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com', + path: ['foo', 'bar'], + }, + }); + + expect(mockGitlabClient.Groups.create).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('groupId', 42); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts new file mode 100644 index 0000000000..8161b4d006 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2021 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. + */ + +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { Gitlab } from '@gitbeaker/node'; +import { GroupSchema } from '@gitbeaker/core/dist/types/resources/Groups'; +import commonGitlabConfig from '../commonGitlabConfig'; +import { getToken } from '../util'; +import { z } from 'zod'; + +const input = commonGitlabConfig.and( + z.object({ + path: z + .array(z.string(), { + description: 'A path of group names that is ensured to exist', + }) + .min(1), + }), +); + +const output = z.object({ + groupId: z.string({ description: 'The id of the innermost sub-group' }), +}); + +/** + * Creates an `gitlab:group:ensureExists` Scaffolder action. + * + * @public + */ +export const createGitlabGroupEnsureExistsAction = (options: { + integrations: ScmIntegrationRegistry; +}) => { + const { integrations } = options; + + return createTemplateAction>({ + id: 'gitlab:group:ensureExists', + description: 'Ensures a Gitlab group exists', + schema: { input, output }, + async handler(ctx) { + const { path } = ctx.input; + const { token, integrationConfig } = getToken(ctx.input, integrations); + + const api = new Gitlab({ + host: integrationConfig.config.baseUrl, + token: token, + }); + + let currentPath: string = 'repos'; + let parent: GroupSchema | null = null; + for (const pathElement of path) { + const fullPath = `${currentPath}/${pathElement}`; + const result = (await api.Groups.search( + fullPath, + )) as any as Array; + const subGroup = result.find( + searchPathElem => searchPathElem.full_path === fullPath, + ); + if (!subGroup) { + ctx.logger.info(`creating missing group ${fullPath}`); + parent = await api.Groups.create( + pathElement, + pathElement, + parent + ? { + parent_id: parent.id, + } + : {}, + ); + } else { + parent = subGroup; + } + currentPath = fullPath; + } + if (parent !== null) { + ctx.output('groupId', parent?.id); + } + }, + }); +}; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts index 0f39e3bc33..d295d7cc07 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts @@ -16,10 +16,27 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { ScmIntegrationRegistry } from '@backstage/integration'; +import commonGitlabConfig from '../commonGitlabConfig'; import { getToken } from '../util'; +import { z } from 'zod'; + +const input = commonGitlabConfig.and( + z.object({ + projectId: z.union([z.number(), z.string()], { description: 'Project ID' }), + name: z.string({ description: 'Deploy Token Name' }).optional(), + accessLevel: z + .string({ description: 'Access Level of the Token' }) + .optional(), + scopes: z.array(z.string(), { description: 'Scopes' }).optional(), + }), +); + +const output = z.object({ + access_token: z.string({ description: 'Access Token' }), +}); /** - * Creates a `gitlab:create-project-access-token` Scaffolder action. + * Creates a `gitlab:projectAccessToken:create` Scaffolder action. * * @param options - Templating configuration. * @public @@ -28,65 +45,13 @@ export const createGitlabProjectAccessTokenAction = (options: { integrations: ScmIntegrationRegistry; }) => { const { integrations } = options; - return createTemplateAction<{ - repoUrl: string; - projectId: string | number; - name: string; - accessLevel: number; - scopes: string[]; - token?: string; - }>({ + return createTemplateAction>({ id: 'gitlab:projectAccessToken:create', - schema: { - input: { - required: ['projectId', 'repoUrl'], - type: 'object', - properties: { - repoUrl: { - title: 'Repository Location', - type: 'string', - }, - projectId: { - title: 'Project ID', - type: ['string', 'number'], - }, - name: { - title: 'Deploy Token Name', - type: 'string', - }, - accessLevel: { - title: 'Access Level of the Token', - type: 'number', - }, - scopes: { - title: 'Scopes', - type: 'array', - }, - token: { - title: 'Authentication Token', - type: 'string', - description: 'The token to use for authorization to GitLab', - }, - }, - }, - output: { - type: 'object', - properties: { - access_token: { - title: 'Access Token', - type: 'string', - }, - }, - }, - }, + schema: { input, output }, async handler(ctx) { ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`); - const { repoUrl, projectId, name, accessLevel, scopes } = ctx.input; - const { token, integrationConfig } = getToken( - repoUrl, - ctx.input.token, - integrations, - ); + const { projectId, name, accessLevel, scopes } = ctx.input; + const { token, integrationConfig } = getToken(ctx.input, integrations); const response = await fetch( `${integrationConfig.config.baseUrl}/api/v4/projects/${projectId}/access_tokens`, diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.ts index 35a53c87e2..46feaad39f 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.ts @@ -18,11 +18,27 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { Gitlab } from '@gitbeaker/node'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { DeployTokenScope } from '@gitbeaker/core/dist/types/templates/ResourceDeployTokens'; +import commonGitlabConfig from '../commonGitlabConfig'; import { getToken } from '../util'; import { InputError } from '@backstage/errors'; +import { z } from 'zod'; + +const input = commonGitlabConfig.and( + z.object({ + projectId: z.union([z.number(), z.string()], { description: 'Project ID' }), + name: z.string({ description: 'Deploy Token Name' }), + username: z.string({ description: 'Deploy Token Username' }).optional(), + scopes: z.array(z.string(), { description: 'Scopes' }).optional(), + }), +); + +const output = z.object({ + deploy_token: z.string({ description: 'Deploy Token' }), + user: z.string({ description: 'User' }), +}); /** - * Creates a `gitlab:create-project-deploy-token` Scaffolder action. + * Creates a `gitlab:projectDeployToken:create` Scaffolder action. * * @param options - Templating configuration. * @public @@ -31,69 +47,13 @@ export const createGitlabProjectDeployTokenAction = (options: { integrations: ScmIntegrationRegistry; }) => { const { integrations } = options; - return createTemplateAction<{ - repoUrl: string; - projectId: string | number; - name: string; - username: string; - scopes: string[]; - token?: string; - }>({ + return createTemplateAction>({ id: 'gitlab:projectDeployToken:create', - schema: { - input: { - required: ['projectId', 'repoUrl'], - type: 'object', - properties: { - repoUrl: { - title: 'Repository Location', - type: 'string', - }, - projectId: { - title: 'Project ID', - type: ['string', 'number'], - }, - name: { - title: 'Deploy Token Name', - type: 'string', - }, - username: { - title: 'Deploy Token Username', - type: 'string', - }, - scopes: { - title: 'Scopes', - type: 'array', - }, - token: { - title: 'Authentication Token', - type: 'string', - description: 'The token to use for authorization to GitLab', - }, - }, - }, - output: { - type: 'object', - properties: { - deploy_token: { - title: 'Deploy Token', - type: 'string', - }, - user: { - title: 'User', - type: 'string', - }, - }, - }, - }, + schema: { input, output }, async handler(ctx) { ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`); - const { repoUrl, projectId, name, username, scopes } = ctx.input; - const { token, integrationConfig } = getToken( - repoUrl, - ctx.input.token, - integrations, - ); + const { projectId, name, username, scopes } = ctx.input; + const { token, integrationConfig } = getToken(ctx.input, integrations); const api = new Gitlab({ host: integrationConfig.config.baseUrl, diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectVariableAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectVariableAction.ts index 847bbc2057..a66d6242ee 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectVariableAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectVariableAction.ts @@ -16,11 +16,43 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { getToken } from '../util'; import { Gitlab } from '@gitbeaker/node'; +import { getToken } from '../util'; +import commonGitlabConfig from '../commonGitlabConfig'; +import { z } from 'zod'; + +const input = commonGitlabConfig.and( + z.object({ + projectId: z.union([z.number(), z.string()], { description: 'Project ID' }), + key: z + .string({ + description: + 'The key of a variable; must have no more than 255 characters; only A-Z, a-z, 0-9, and _ are allowed', + }) + .regex(/^[A-Za-z0-9_]{1,255}$/), + value: z.string({ description: 'The value of a variable' }), + variableType: z.string({ description: 'Variable Type (env_var or file)' }), + variableProtected: z + .boolean({ description: 'Whether the variable is protected' }) + .default(false) + .optional(), + masked: z + .boolean({ description: 'Whether the variable is masked' }) + .default(false) + .optional(), + raw: z + .boolean({ description: 'Whether the variable is expandable' }) + .default(false) + .optional(), + environmentScope: z + .string({ description: 'The environment_scope of the variable' }) + .default('*') + .optional(), + }), +); /** - * Creates a `gitlab:create-project-variable` Scaffolder action. + * Creates a `gitlab:projectVariable:create` Scaffolder action. * * @param options - Templating configuration. * @public @@ -29,96 +61,21 @@ export const createGitlabProjectVariableAction = (options: { integrations: ScmIntegrationRegistry; }) => { const { integrations } = options; - return createTemplateAction<{ - repoUrl: string; - projectId: string | number; - key: string; - value: string; - variableType: string; - variableProtected: boolean; - masked: boolean; - raw: boolean; - environmentScope: string; - token?: string; - }>({ + return createTemplateAction>({ id: 'gitlab:projectVariable:create', - schema: { - input: { - required: [ - 'repoUrl', - 'projectId', - 'key', - 'value', - 'variableType', - 'variableProtected', - 'masked', - 'raw', - 'environmentScope', - ], - type: 'object', - properties: { - repoUrl: { - title: 'Repository Location', - type: 'string', - }, - projectId: { - title: 'Project ID', - type: ['string', 'number'], - }, - key: { - title: - 'The key of a variable; must have no more than 255 characters; only A-Z, a-z, 0-9, and _ are allowed', - type: 'string', - }, - value: { - title: 'The value of a variable', - type: 'string', - }, - variableType: { - title: 'Variable Type (env_var or file)', - type: 'string', - }, - variableProtected: { - title: 'Whether the variable is protected. Default: false', - type: 'boolean', - }, - masked: { - title: 'Whether the variable is masked. Default: false', - type: 'boolean', - }, - raw: { - title: 'Whether the variable is expandable. Default: false', - type: 'boolean', - }, - environmentScope: { - title: 'The environment_scope of the variable. Default: *', - type: 'string', - }, - token: { - title: 'Authentication Token', - type: 'string', - description: 'The token to use for authorization to GitLab', - }, - }, - }, - }, + schema: { input }, async handler(ctx) { const { - repoUrl, projectId, key, value, variableType, - variableProtected, - masked, - raw, - environmentScope, + variableProtected = false, + masked = false, + raw = false, + environmentScope = '*', } = ctx.input; - const { token, integrationConfig } = getToken( - repoUrl, - ctx.input.token, - integrations, - ); + const { token, integrationConfig } = getToken(ctx.input, integrations); const api = new Gitlab({ host: integrationConfig.config.baseUrl, diff --git a/plugins/scaffolder-backend-module-gitlab/src/commonGitlabConfig.ts b/plugins/scaffolder-backend-module-gitlab/src/commonGitlabConfig.ts new file mode 100644 index 0000000000..02724a3674 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/commonGitlabConfig.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021 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. + */ + +import { z } from 'zod'; + +const commonGitlabConfig = z.object({ + repoUrl: z.string({ description: 'Repository Location' }), + token: z + .string({ description: 'The token to use for authorization to GitLab' }) + .optional(), +}); + +export default commonGitlabConfig; diff --git a/plugins/scaffolder-backend-module-gitlab/src/index.ts b/plugins/scaffolder-backend-module-gitlab/src/index.ts index 11209e19bd..4fd4dee35b 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/index.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/index.ts @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + /** - * A module for the scaffolder backend that lets you create gitlab project access tokens or deploy tokens + * A module for the scaffolder backend that lets you interact with gitlab * * @packageDocumentation */ - +export * from './actions/createGitlabGroupEnsureExistsAction'; export * from './actions/createGitlabProjectDeployTokenAction'; export * from './actions/createGitlabProjectAccessTokenAction'; export * from './actions/createGitlabProjectVariableAction'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/util.ts b/plugins/scaffolder-backend-module-gitlab/src/util.ts index fc6667d6d3..13c95e48e8 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/util.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/util.ts @@ -19,6 +19,8 @@ import { GitLabIntegration, ScmIntegrationRegistry, } from '@backstage/integration'; +import { z } from 'zod'; +import commonGitlabConfig from './commonGitlabConfig'; export const parseRepoHost = (repoUrl: string): string => { let parsed; @@ -33,11 +35,10 @@ export const parseRepoHost = (repoUrl: string): string => { }; export const getToken = ( - repoUrl: string, - inputToken: string | null | undefined, + config: z.infer, integrations: ScmIntegrationRegistry, ): { token: string; integrationConfig: GitLabIntegration } => { - const host = parseRepoHost(repoUrl); + const host = parseRepoHost(config.repoUrl); const integrationConfig = integrations.gitlab.byHost(host); if (!integrationConfig) { @@ -46,8 +47,8 @@ export const getToken = ( ); } - const token = inputToken || integrationConfig.config.token!; - const tokenType = inputToken ? 'oauthToken' : 'token'; + const token = config.token || integrationConfig.config.token!; + const tokenType = config.token ? 'oauthToken' : 'token'; if (tokenType === 'oauthToken') { throw new InputError(`OAuth Token is currently not supported`); diff --git a/yarn.lock b/yarn.lock index 9c8dd2b64e..70a7ed3f3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7855,6 +7855,7 @@ __metadata: "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@gitbeaker/node": ^35.8.0 + zod: ^3.21.4 languageName: unknown linkType: soft @@ -40593,6 +40594,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.21.4": + version: 3.21.4 + resolution: "zod@npm:3.21.4" + checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f + languageName: node + linkType: hard + "zod@npm:~3.18.0": version: 3.18.0 resolution: "zod@npm:3.18.0" From a6cb0629bddff9d38dd7d94f4baef5e50b7712a7 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Tue, 28 Mar 2023 15:52:57 +0200 Subject: [PATCH 02/19] Simplify generics after #16910 was merged Signed-off-by: Andreas Berger --- .../api-report.md | 33 +++++---- .../createGitlabGroupEnsureExistsAction.ts | 33 ++++----- .../createGitlabProjectAccessTokenAction.ts | 35 +++++----- .../createGitlabProjectDeployTokenAction.ts | 35 +++++----- .../createGitlabProjectVariableAction.ts | 68 ++++++++++--------- yarn.lock | 7 -- 6 files changed, 111 insertions(+), 100 deletions(-) diff --git a/plugins/scaffolder-backend-module-gitlab/api-report.md b/plugins/scaffolder-backend-module-gitlab/api-report.md index ee7f1b3a21..09d061f17b 100644 --- a/plugins/scaffolder-backend-module-gitlab/api-report.md +++ b/plugins/scaffolder-backend-module-gitlab/api-report.md @@ -16,6 +16,9 @@ export const createGitlabGroupEnsureExistsAction: (options: { token?: string | undefined; } & { path: string[]; + }, + { + groupId?: number | undefined; } >; @@ -25,13 +28,16 @@ export const createGitlabProjectAccessTokenAction: (options: { }) => TemplateAction< { repoUrl: string; - projectId: string | number; - name: string; - accessLevel: number; - scopes: string[]; token?: string | undefined; + } & { + projectId: string | number; + name?: string | undefined; + accessLevel?: string | undefined; + scopes?: string[] | undefined; }, - JsonObject + { + access_token: string; + } >; // @public @@ -40,13 +46,17 @@ export const createGitlabProjectDeployTokenAction: (options: { }) => TemplateAction< { repoUrl: string; - projectId: string | number; - name: string; - username: string; - scopes: string[]; token?: string | undefined; + } & { + name: string; + projectId: string | number; + username?: string | undefined; + scopes?: string[] | undefined; }, - JsonObject + { + user: string; + deploy_token: string; + } >; // @public @@ -65,8 +75,7 @@ export const createGitlabProjectVariableAction: (options: { masked?: boolean | undefined; raw?: boolean | undefined; environmentScope?: string | undefined; - } -, + }, JsonObject >; ``` diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts index 8161b4d006..6e883a0fcc 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts @@ -22,20 +22,6 @@ import commonGitlabConfig from '../commonGitlabConfig'; import { getToken } from '../util'; import { z } from 'zod'; -const input = commonGitlabConfig.and( - z.object({ - path: z - .array(z.string(), { - description: 'A path of group names that is ensured to exist', - }) - .min(1), - }), -); - -const output = z.object({ - groupId: z.string({ description: 'The id of the innermost sub-group' }), -}); - /** * Creates an `gitlab:group:ensureExists` Scaffolder action. * @@ -46,10 +32,25 @@ export const createGitlabGroupEnsureExistsAction = (options: { }) => { const { integrations } = options; - return createTemplateAction>({ + return createTemplateAction({ id: 'gitlab:group:ensureExists', description: 'Ensures a Gitlab group exists', - schema: { input, output }, + schema: { + input: commonGitlabConfig.and( + z.object({ + path: z + .array(z.string(), { + description: 'A path of group names that is ensured to exist', + }) + .min(1), + }), + ), + output: z.object({ + groupId: z + .number({ description: 'The id of the innermost sub-group' }) + .optional(), + }), + }, async handler(ctx) { const { path } = ctx.input; const { token, integrationConfig } = getToken(ctx.input, integrations); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts index d295d7cc07..1e0b8db900 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts @@ -20,21 +20,6 @@ import commonGitlabConfig from '../commonGitlabConfig'; import { getToken } from '../util'; import { z } from 'zod'; -const input = commonGitlabConfig.and( - z.object({ - projectId: z.union([z.number(), z.string()], { description: 'Project ID' }), - name: z.string({ description: 'Deploy Token Name' }).optional(), - accessLevel: z - .string({ description: 'Access Level of the Token' }) - .optional(), - scopes: z.array(z.string(), { description: 'Scopes' }).optional(), - }), -); - -const output = z.object({ - access_token: z.string({ description: 'Access Token' }), -}); - /** * Creates a `gitlab:projectAccessToken:create` Scaffolder action. * @@ -45,9 +30,25 @@ export const createGitlabProjectAccessTokenAction = (options: { integrations: ScmIntegrationRegistry; }) => { const { integrations } = options; - return createTemplateAction>({ + return createTemplateAction({ id: 'gitlab:projectAccessToken:create', - schema: { input, output }, + schema: { + input: commonGitlabConfig.and( + z.object({ + projectId: z.union([z.number(), z.string()], { + description: 'Project ID', + }), + name: z.string({ description: 'Deploy Token Name' }).optional(), + accessLevel: z + .string({ description: 'Access Level of the Token' }) + .optional(), + scopes: z.array(z.string(), { description: 'Scopes' }).optional(), + }), + ), + output: z.object({ + access_token: z.string({ description: 'Access Token' }), + }), + }, async handler(ctx) { ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`); const { projectId, name, accessLevel, scopes } = ctx.input; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.ts index 46feaad39f..3010bd33d2 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.ts @@ -23,20 +23,6 @@ import { getToken } from '../util'; import { InputError } from '@backstage/errors'; import { z } from 'zod'; -const input = commonGitlabConfig.and( - z.object({ - projectId: z.union([z.number(), z.string()], { description: 'Project ID' }), - name: z.string({ description: 'Deploy Token Name' }), - username: z.string({ description: 'Deploy Token Username' }).optional(), - scopes: z.array(z.string(), { description: 'Scopes' }).optional(), - }), -); - -const output = z.object({ - deploy_token: z.string({ description: 'Deploy Token' }), - user: z.string({ description: 'User' }), -}); - /** * Creates a `gitlab:projectDeployToken:create` Scaffolder action. * @@ -47,9 +33,26 @@ export const createGitlabProjectDeployTokenAction = (options: { integrations: ScmIntegrationRegistry; }) => { const { integrations } = options; - return createTemplateAction>({ + return createTemplateAction({ id: 'gitlab:projectDeployToken:create', - schema: { input, output }, + schema: { + input: commonGitlabConfig.and( + z.object({ + projectId: z.union([z.number(), z.string()], { + description: 'Project ID', + }), + name: z.string({ description: 'Deploy Token Name' }), + username: z + .string({ description: 'Deploy Token Username' }) + .optional(), + scopes: z.array(z.string(), { description: 'Scopes' }).optional(), + }), + ), + output: z.object({ + deploy_token: z.string({ description: 'Deploy Token' }), + user: z.string({ description: 'User' }), + }), + }, async handler(ctx) { ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`); const { projectId, name, username, scopes } = ctx.input; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectVariableAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectVariableAction.ts index a66d6242ee..a6043bea01 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectVariableAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectVariableAction.ts @@ -21,36 +21,6 @@ import { getToken } from '../util'; import commonGitlabConfig from '../commonGitlabConfig'; import { z } from 'zod'; -const input = commonGitlabConfig.and( - z.object({ - projectId: z.union([z.number(), z.string()], { description: 'Project ID' }), - key: z - .string({ - description: - 'The key of a variable; must have no more than 255 characters; only A-Z, a-z, 0-9, and _ are allowed', - }) - .regex(/^[A-Za-z0-9_]{1,255}$/), - value: z.string({ description: 'The value of a variable' }), - variableType: z.string({ description: 'Variable Type (env_var or file)' }), - variableProtected: z - .boolean({ description: 'Whether the variable is protected' }) - .default(false) - .optional(), - masked: z - .boolean({ description: 'Whether the variable is masked' }) - .default(false) - .optional(), - raw: z - .boolean({ description: 'Whether the variable is expandable' }) - .default(false) - .optional(), - environmentScope: z - .string({ description: 'The environment_scope of the variable' }) - .default('*') - .optional(), - }), -); - /** * Creates a `gitlab:projectVariable:create` Scaffolder action. * @@ -61,9 +31,43 @@ export const createGitlabProjectVariableAction = (options: { integrations: ScmIntegrationRegistry; }) => { const { integrations } = options; - return createTemplateAction>({ + return createTemplateAction({ id: 'gitlab:projectVariable:create', - schema: { input }, + schema: { + input: commonGitlabConfig.and( + z.object({ + projectId: z.union([z.number(), z.string()], { + description: 'Project ID', + }), + key: z + .string({ + description: + 'The key of a variable; must have no more than 255 characters; only A-Z, a-z, 0-9, and _ are allowed', + }) + .regex(/^[A-Za-z0-9_]{1,255}$/), + value: z.string({ description: 'The value of a variable' }), + variableType: z.string({ + description: 'Variable Type (env_var or file)', + }), + variableProtected: z + .boolean({ description: 'Whether the variable is protected' }) + .default(false) + .optional(), + masked: z + .boolean({ description: 'Whether the variable is masked' }) + .default(false) + .optional(), + raw: z + .boolean({ description: 'Whether the variable is expandable' }) + .default(false) + .optional(), + environmentScope: z + .string({ description: 'The environment_scope of the variable' }) + .default('*') + .optional(), + }), + ), + }, async handler(ctx) { const { projectId, diff --git a/yarn.lock b/yarn.lock index 70a7ed3f3c..45879eb030 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40594,13 +40594,6 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.21.4": - version: 3.21.4 - resolution: "zod@npm:3.21.4" - checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f - languageName: node - linkType: hard - "zod@npm:~3.18.0": version: 3.18.0 resolution: "zod@npm:3.18.0" From dff74223cf329164e5c53e9e126ddccd2d21f4ec Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 5 Apr 2023 13:29:51 +0200 Subject: [PATCH 03/19] Adjustments ts after review Signed-off-by: Andreas Berger --- plugins/scaffolder-backend-module-gitlab/api-report.md | 2 +- .../src/actions/createGitlabGroupEnsureExistsAction.ts | 2 +- .../src/actions/createGitlabProjectAccessTokenAction.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend-module-gitlab/api-report.md b/plugins/scaffolder-backend-module-gitlab/api-report.md index 09d061f17b..96ec3445dc 100644 --- a/plugins/scaffolder-backend-module-gitlab/api-report.md +++ b/plugins/scaffolder-backend-module-gitlab/api-report.md @@ -32,7 +32,7 @@ export const createGitlabProjectAccessTokenAction: (options: { } & { projectId: string | number; name?: string | undefined; - accessLevel?: string | undefined; + accessLevel?: number | undefined; scopes?: string[] | undefined; }, { diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts index 6e883a0fcc..698d402697 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.ts @@ -66,7 +66,7 @@ export const createGitlabGroupEnsureExistsAction = (options: { const fullPath = `${currentPath}/${pathElement}`; const result = (await api.Groups.search( fullPath, - )) as any as Array; + )) as any as Array; // recast since the return type for search is wrong in the gitbeaker typings const subGroup = result.find( searchPathElem => searchPathElem.full_path === fullPath, ); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts index 1e0b8db900..832eb4cac7 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts @@ -40,7 +40,7 @@ export const createGitlabProjectAccessTokenAction = (options: { }), name: z.string({ description: 'Deploy Token Name' }).optional(), accessLevel: z - .string({ description: 'Access Level of the Token' }) + .number({ description: 'Access Level of the Token' }) .optional(), scopes: z.array(z.string(), { description: 'Scopes' }).optional(), }), From 7d9870df7e05febc425648eadec1c383b4c130c8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 May 2023 10:12:52 +0000 Subject: [PATCH 04/19] fix(deps): update dependency @roadiehq/backstage-plugin-github-pull-requests to v2.5.10 Signed-off-by: Renovate Bot --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0cd30db248..8b46926fc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13782,8 +13782,8 @@ __metadata: linkType: hard "@roadiehq/backstage-plugin-github-pull-requests@npm:^2.2.7": - version: 2.5.9 - resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.5.9" + version: 2.5.10 + resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.5.10" dependencies: "@backstage/catalog-model": ^1.3.0 "@backstage/core-components": ^0.13.0 @@ -13806,7 +13806,7 @@ __metadata: react: ^16.13.1 || ^17.0.0 react-dom: ^16.13.1 || ^17.0.0 react-router: 6.0.0-beta.0 || ^6.3.0 - checksum: 5a60f2c67c095a0fbf081b4097e0dfd4d6c22f59514e66800eba8460ef32ee43cf1eb7105a36a56f7b63d8ac44f5343f7e4ec66279ba2009945b828e6698e00e + checksum: 0f273ede63024635f850fc9df662941589f5f8848307507e3f6e376ea0bd9b7e17026e1182808d5c193be90ee9a2725c83a0850475547b364cd3d7cf35bf7b74 languageName: node linkType: hard From e6231859bccec657d12694ab139c9908cd595c40 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 26 Apr 2023 23:50:03 +0200 Subject: [PATCH 05/19] catalog-backend: fix filters clashing with pagination clause Signed-off-by: Vincenzo Scamporlino --- .../service/DefaultEntitiesCatalog.test.ts | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 08ae295834..18796ab24f 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -1418,6 +1418,87 @@ describe('DefaultEntitiesCatalog', () => { }, ); + it.each(databases.eachSupportedId())( + 'should exclude filtered entities when paginating, %p', + async databaseId => { + await createDatabase(databaseId); + + await Promise.all([ + addEntityToSearch(entityFrom('AA', { uid: '1' })), + addEntityToSearch( + entityFrom('AA', { + namespace: 'namespace2', + kind: 'included', + uid: '2', + }), + ), + addEntityToSearch( + entityFrom('AA', { + namespace: 'ns', + kind: 'excluded', + uid: '3', + }), + ), + addEntityToSearch( + entityFrom('AA', { + namespace: 'namespace3', + uid: '4', + kind: 'included', + }), + ), + addEntityToSearch( + entityFrom('AA', { + namespace: 'namespace4', + uid: '5', + kind: 'included', + }), + ), + addEntityToSearch(entityFrom('CC', { uid: '6', kind: 'included' })), + addEntityToSearch(entityFrom('DD', { uid: '7', kind: 'included' })), + ]); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: getVoidLogger(), + stitcher, + }); + + const limit = 2; + + // initial request + const request1: QueryEntitiesInitialRequest = { + limit, + filter: { + key: 'kind', + values: ['included'], + }, + orderFields: [{ field: 'metadata.name', order: 'asc' }], + }; + const response1 = await catalog.queryEntities(request1); + expect(response1.items).toMatchObject([ + entityFrom('AA', { uid: '1' }), + entityFrom('AA', { uid: '2' }), + ]); + expect(response1.pageInfo.nextCursor).toBeDefined(); + expect(response1.pageInfo.prevCursor).toBeUndefined(); + expect(response1.totalItems).toBe(6); + + // second request (forward) + const request2: QueryEntitiesCursorRequest = { + cursor: response1.pageInfo.nextCursor!, + limit, + }; + const response2 = await catalog.queryEntities(request2); + expect(response2.items).toMatchObject([ + entityFrom('AA', { uid: '4' }), + entityFrom('AA', { uid: '5' }), + ]); + expect(response2.pageInfo.nextCursor).toBeDefined(); + expect(response2.pageInfo.prevCursor).toBeDefined(); + expect(response2.totalItems).toBe(6); + }, + ); + it.each(databases.eachSupportedId())( 'should paginate results without sort fields, %p', async databaseId => { @@ -1754,11 +1835,12 @@ function entityFrom( uid, namespace, title, - }: { uid?: string; namespace?: string; title?: string } = {}, + kind = 'k', + }: { uid?: string; namespace?: string; title?: string; kind?: string } = {}, ) { return { apiVersion: 'a', - kind: 'k', + kind, metadata: { name, ...(!!namespace && { namespace }), From 2b99c76ef5b0a975766c44bcb85f658bcb9ef15d Mon Sep 17 00:00:00 2001 From: Ke Ma Date: Tue, 25 Apr 2023 13:36:53 +0200 Subject: [PATCH 06/19] fix: db query Signed-off-by: Ke Ma --- .../src/service/DefaultEntitiesCatalog.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index ba165f96af..5fca950cac 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -413,17 +413,18 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { const isOrderingDescending = sortField.order === 'desc'; if (prevItemOrderFieldValue) { - dbQuery.andWhere( - 'value', - isFetchingBackwards !== isOrderingDescending ? '<' : '>', - prevItemOrderFieldValue, - ); - dbQuery.orWhere(function nested() { - this.where('value', '=', prevItemOrderFieldValue).andWhere( - 'search.entity_id', + dbQuery.andWhere(function nested() { + this.where( + 'value', isFetchingBackwards !== isOrderingDescending ? '<' : '>', - prevItemUid, - ); + prevItemOrderFieldValue, + ) + .orWhere('value', '=', prevItemOrderFieldValue) + .andWhere( + 'search.entity_id', + isFetchingBackwards !== isOrderingDescending ? '<' : '>', + prevItemUid, + ); }); } From 3587a968dcd78cfc0871ea57d2f5b504fc4fd552 Mon Sep 17 00:00:00 2001 From: Ke Ma Date: Tue, 25 Apr 2023 14:39:44 +0200 Subject: [PATCH 07/19] add changelog Signed-off-by: Ke Ma --- .changeset/fifty-grapes-explode.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fifty-grapes-explode.md diff --git a/.changeset/fifty-grapes-explode.md b/.changeset/fifty-grapes-explode.md new file mode 100644 index 0000000000..e717340c03 --- /dev/null +++ b/.changeset/fifty-grapes-explode.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fix a bug in an SQL query where the AND and OR logic is incorrect. From b2d1bdb8026f58f9ff40547cfe1f10ee41a703e7 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 27 Apr 2023 09:23:25 +0200 Subject: [PATCH 08/19] Update .changeset/fifty-grapes-explode.md Co-authored-by: Vincenzo Scamporlino Signed-off-by: Mark --- .changeset/fifty-grapes-explode.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fifty-grapes-explode.md b/.changeset/fifty-grapes-explode.md index e717340c03..999a105c4f 100644 --- a/.changeset/fifty-grapes-explode.md +++ b/.changeset/fifty-grapes-explode.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': patch --- -Fix a bug in an SQL query where the AND and OR logic is incorrect. +Fixed a bug in the `queryEntities` endpoint that was causing filtered entities to be included in cursor requests. From 243a4bcbceeb036123c7df272e512b83ad475d86 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 27 Apr 2023 15:07:12 +0200 Subject: [PATCH 09/19] catalog-backend: add missing kind Signed-off-by: Vincenzo Scamporlino --- .../src/service/DefaultEntitiesCatalog.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 18796ab24f..93a37fb04a 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -1424,7 +1424,7 @@ describe('DefaultEntitiesCatalog', () => { await createDatabase(databaseId); await Promise.all([ - addEntityToSearch(entityFrom('AA', { uid: '1' })), + addEntityToSearch(entityFrom('AA', { uid: '1', kind: 'included' })), addEntityToSearch( entityFrom('AA', { namespace: 'namespace2', @@ -1476,8 +1476,8 @@ describe('DefaultEntitiesCatalog', () => { }; const response1 = await catalog.queryEntities(request1); expect(response1.items).toMatchObject([ - entityFrom('AA', { uid: '1' }), - entityFrom('AA', { uid: '2' }), + entityFrom('AA', { uid: '1', kind: 'included' }), + entityFrom('AA', { uid: '2', kind: 'included' }), ]); expect(response1.pageInfo.nextCursor).toBeDefined(); expect(response1.pageInfo.prevCursor).toBeUndefined(); @@ -1490,8 +1490,8 @@ describe('DefaultEntitiesCatalog', () => { }; const response2 = await catalog.queryEntities(request2); expect(response2.items).toMatchObject([ - entityFrom('AA', { uid: '4' }), - entityFrom('AA', { uid: '5' }), + entityFrom('AA', { uid: '4', kind: 'included' }), + entityFrom('AA', { uid: '5', kind: 'included' }), ]); expect(response2.pageInfo.nextCursor).toBeDefined(); expect(response2.pageInfo.prevCursor).toBeDefined(); From 8e3b675f8f2abbd435a0ba3bcd51555a73ba96f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 May 2023 22:41:06 +0000 Subject: [PATCH 10/19] fix(deps): update dependency @apollo/server to v4.7.1 Signed-off-by: Renovate Bot --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0bd97d81f8..5af2ace854 100644 --- a/yarn.lock +++ b/yarn.lock @@ -182,8 +182,8 @@ __metadata: linkType: hard "@apollo/server@npm:^4.0.0": - version: 4.7.0 - resolution: "@apollo/server@npm:4.7.0" + version: 4.7.1 + resolution: "@apollo/server@npm:4.7.1" dependencies: "@apollo/cache-control-types": ^1.0.2 "@apollo/server-gateway-interface": ^1.1.0 @@ -213,7 +213,7 @@ __metadata: whatwg-mimetype: ^3.0.0 peerDependencies: graphql: ^16.6.0 - checksum: 68f94a3859b99b931d8c6ae36f8684ec85eb7524a74e2a814b36ae3ae87773ff73562657021eaa2c2d382ec32bb6b1d9f9f7975fdc0a4509f24cddd71d33953e + checksum: 6c8bdd240a4c688641bfec0520375db0eb78d2495286f30f962d7b6e97d7db70f105c09364e56103e1b93543e5760621666655eaa8ae7218312ac54ca7200d4b languageName: node linkType: hard From cae751760eb2a5d6c43d936441eeaff3dd506ce9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 May 2023 00:35:11 +0000 Subject: [PATCH 11/19] fix(deps): update dependency tar to v6.1.14 Signed-off-by: Renovate Bot --- yarn.lock | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 49be66a6e4..b224da554f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30968,6 +30968,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass@npm:5.0.0" + checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea + languageName: node + linkType: hard + "minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -38137,16 +38144,16 @@ __metadata: linkType: hard "tar@npm:^6.0.2, tar@npm:^6.1.0, tar@npm:^6.1.11, tar@npm:^6.1.12, tar@npm:^6.1.2": - version: 6.1.13 - resolution: "tar@npm:6.1.13" + version: 6.1.14 + resolution: "tar@npm:6.1.14" dependencies: chownr: ^2.0.0 fs-minipass: ^2.0.0 - minipass: ^4.0.0 + minipass: ^5.0.0 minizlib: ^2.1.1 mkdirp: ^1.0.3 yallist: ^4.0.0 - checksum: 8a278bed123aa9f53549b256a36b719e317c8b96fe86a63406f3c62887f78267cea9b22dc6f7007009738509800d4a4dccc444abd71d762287c90f35b002eb1c + checksum: a1be0815a9bdc97dfca7c6c2d71d1b836f8ba9314684e2c412832f0f59cc226d4c13da303d6bc30925e82f634cc793f40da79ae72f3e96fb87c23d0f4efd5207 languageName: node linkType: hard From 8635f87ee300ef925508bed89d77af1453b06311 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 May 2023 06:28:24 +0000 Subject: [PATCH 12/19] fix(deps): update dependency postcss to v8.4.23 Signed-off-by: Renovate Bot --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index b224da554f..81cc6a5199 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31261,12 +31261,12 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.1.23, nanoid@npm:^3.3.4": - version: 3.3.4 - resolution: "nanoid@npm:3.3.4" +"nanoid@npm:^3.1.23, nanoid@npm:^3.3.4, nanoid@npm:^3.3.6": + version: 3.3.6 + resolution: "nanoid@npm:3.3.6" bin: nanoid: bin/nanoid.cjs - checksum: 2fddd6dee994b7676f008d3ffa4ab16035a754f4bb586c61df5a22cf8c8c94017aadd360368f47d653829e0569a92b129979152ff97af23a558331e47e37cd9c + checksum: 7d0eda657002738aa5206107bd0580aead6c95c460ef1bdd0b1a87a9c7ae6277ac2e9b945306aaa5b32c6dcb7feaf462d0f552e7f8b5718abfc6ead5c94a71b3 languageName: node linkType: hard @@ -33715,13 +33715,13 @@ __metadata: linkType: hard "postcss@npm:^8.1.0, postcss@npm:^8.4.19": - version: 8.4.21 - resolution: "postcss@npm:8.4.21" + version: 8.4.23 + resolution: "postcss@npm:8.4.23" dependencies: - nanoid: ^3.3.4 + nanoid: ^3.3.6 picocolors: ^1.0.0 source-map-js: ^1.0.2 - checksum: e39ac60ccd1542d4f9d93d894048aac0d686b3bb38e927d8386005718e6793dbbb46930f0a523fe382f1bbd843c6d980aaea791252bf5e176180e5a4336d9679 + checksum: 8bb9d1b2ea6e694f8987d4f18c94617971b2b8d141602725fedcc2222fdc413b776a6e1b969a25d627d7b2681ca5aabb56f59e727ef94072e1b6ac8412105a2f languageName: node linkType: hard From ce8d203235b02d8570349938655bac42377755bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 3 May 2023 10:49:49 +0200 Subject: [PATCH 13/19] Ensure that entity cache state is only written to the database when actually changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/chilled-queens-wonder.md | 5 +++++ .../src/processing/DefaultCatalogProcessingEngine.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/chilled-queens-wonder.md diff --git a/.changeset/chilled-queens-wonder.md b/.changeset/chilled-queens-wonder.md new file mode 100644 index 0000000000..a10212792d --- /dev/null +++ b/.changeset/chilled-queens-wonder.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Ensure that entity cache state is only written to the database when actually changed diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts index ab1e0b96c9..e89d53f79f 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingEngine.ts @@ -150,7 +150,10 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { track.markProcessorsCompleted(result); if (result.ok) { - if (stableStringify(state) !== stableStringify(result.state)) { + const { ttl: _, ...stateWithoutTtl } = state ?? {}; + if ( + stableStringify(stateWithoutTtl) !== stableStringify(result.state) + ) { await this.processingDatabase.transaction(async tx => { await this.processingDatabase.updateEntityCache(tx, { id, From 603ed96a25ee1a47e91b81eac6d0c4dcf3322e59 Mon Sep 17 00:00:00 2001 From: Kenny Johnson <105308787+papercircuit@users.noreply.github.com> Date: Thu, 20 Apr 2023 19:07:16 -0400 Subject: [PATCH 14/19] Fix Lifecyce text-overlow issue in SearchFilter component. Signed-off-by: Kenny Johnson <105308787+papercircuit@users.noreply.github.com> --- .../components/SearchFilter/SearchFilter.tsx | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx index 78ef981ebc..b6dc077a89 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx @@ -37,6 +37,16 @@ const useStyles = makeStyles({ label: { textTransform: 'capitalize', }, + checkboxWrapper: { + display: 'flex', + alignItems: 'center', + width: '100%', + }, + textWrapper: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, }); /** @@ -120,17 +130,21 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => { +
+ +
+ {value} +
+
} - label={value} /> ))} @@ -197,7 +211,7 @@ export const SelectFilter = (props: SearchFilterComponentProps) => { {values.map((value: string) => ( - {value} + {value} ))} From adb31096bc277610e55a11057e9e9608c661d94d Mon Sep 17 00:00:00 2001 From: Kenny Johnson <105308787+papercircuit@users.noreply.github.com> Date: Thu, 20 Apr 2023 19:13:58 -0400 Subject: [PATCH 15/19] Add changeset Signed-off-by: Kenny Johnson <105308787+papercircuit@users.noreply.github.com> --- .changeset/thin-ways-exist.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thin-ways-exist.md diff --git a/.changeset/thin-ways-exist.md b/.changeset/thin-ways-exist.md new file mode 100644 index 0000000000..bba76f0424 --- /dev/null +++ b/.changeset/thin-ways-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': major +--- + +Fix text-overflow UI issue for Lifecycle spans in SearchFilter checkbox labels. From 6dd9deeefbd8f9610180d2436a890e02b8cfa0c3 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 2 May 2023 14:30:59 +0200 Subject: [PATCH 16/19] changeset(plugin-search-react): Using patch instead of major Signed-off-by: Renan Mendes Carvalho --- .changeset/thin-ways-exist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/thin-ways-exist.md b/.changeset/thin-ways-exist.md index bba76f0424..fd1ecb62a4 100644 --- a/.changeset/thin-ways-exist.md +++ b/.changeset/thin-ways-exist.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-search-react': major +'@backstage/plugin-search-react': patch --- Fix text-overflow UI issue for Lifecycle spans in SearchFilter checkbox labels. From 48181827dc7feba0743600286d74d1000808bcf3 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 3 May 2023 13:54:45 +0200 Subject: [PATCH 17/19] fix(plugin-search-react): Fix tsc errors and use classes prop This patch intends to fix the tsc errors in the build and change the usage of FormControlLabel to use the classes property to inject the new styles. Signed-off-by: Renan Mendes Carvalho --- .../components/SearchFilter/SearchFilter.tsx | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx index b6dc077a89..e7cde794c6 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx @@ -24,6 +24,7 @@ import { Select, MenuItem, FormLabel, + Typography, } from '@material-ui/core'; import { useSearch } from '../../context'; @@ -129,21 +130,18 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => { {values.map((value: string) => ( - -
- {value} -
- + } /> ))} @@ -211,7 +209,7 @@ export const SelectFilter = (props: SearchFilterComponentProps) => { {values.map((value: string) => ( - {value} + {value} ))} From 326cdb42da71391599b00367622bee099d7f3ecc Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 3 May 2023 14:16:36 +0200 Subject: [PATCH 18/19] lint(plugin-search-react): Fix prettier issues Signed-off-by: Renan Mendes Carvalho --- .../src/components/SearchFilter/SearchFilter.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx index e7cde794c6..6194ab8387 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx @@ -130,7 +130,10 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => { {values.map((value: string) => ( { {values.map((value: string) => ( - {value} + + {value} + ))} From 021cfbb5152ccb97e008ff61ade65e71ceb48193 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 13 Apr 2023 18:40:10 -0400 Subject: [PATCH 19/19] 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:^"