From 1fbd77fb56fc9c1031ff1bd679a9e9c821c01338 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 19 Jun 2024 17:09:53 +0200 Subject: [PATCH] chore: small refactor to move the handlers out into an extension point Signed-off-by: blam --- .../package.json | 1 + .../src/autocomplete}/autocomplete.test.ts | 16 ++--- .../src/autocomplete}/autocomplete.ts | 34 +++++----- .../src/module.ts | 14 ++++- .../src/service/router.test.ts | 2 +- .../scaffolder-backend/src/service/router.ts | 31 +++++----- plugins/scaffolder-node/src/alpha.ts | 32 ++++++---- plugins/scaffolder-react/src/api/types.ts | 12 ++-- plugins/scaffolder/src/api.ts | 36 +++++++---- .../RepoUrlPicker/BitbucketRepoPicker.tsx | 62 ++++++++++++------- 10 files changed, 144 insertions(+), 96 deletions(-) rename plugins/{scaffolder-backend/src/service => scaffolder-backend-module-bitbucket-cloud/src/autocomplete}/autocomplete.test.ts (81%) rename plugins/{scaffolder-backend/src/service => scaffolder-backend-module-bitbucket-cloud/src/autocomplete}/autocomplete.ts (69%) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json index f846f751cc..97a08e2741 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json @@ -46,6 +46,7 @@ "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", + "@backstage/plugin-bitbucket-cloud-common": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", "fs-extra": "^11.2.0", "node-fetch": "^2.6.7", diff --git a/plugins/scaffolder-backend/src/service/autocomplete.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts similarity index 81% rename from plugins/scaffolder-backend/src/service/autocomplete.test.ts rename to plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts index 53816f0126..50e218ce85 100644 --- a/plugins/scaffolder-backend/src/service/autocomplete.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.test.ts @@ -15,10 +15,10 @@ */ import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; -import { handleBitbucketCloudRequest } from './autocomplete'; +import { handleAutocompleteRequest } from './autocomplete'; import { InputError } from '@backstage/errors'; -describe('handleBitbucketCloudRequest', () => { +describe('handleAutocompleteRequest', () => { const client: Partial = { listWorkspaces: jest.fn().mockReturnValue({ iteratePages: jest @@ -43,7 +43,7 @@ describe('handleBitbucketCloudRequest', () => { it('should pass the token to the client', async () => { const accessToken = 'foo'; - await handleBitbucketCloudRequest(accessToken, 'workspaces', {}); + await handleAutocompleteRequest(accessToken, 'workspaces', {}); expect(fromConfig).toHaveBeenCalledWith( expect.objectContaining({ accessToken }), @@ -51,13 +51,13 @@ describe('handleBitbucketCloudRequest', () => { }); it('should return workspaces', async () => { - const result = await handleBitbucketCloudRequest('foo', 'workspaces', {}); + const result = await handleAutocompleteRequest('foo', 'workspaces', {}); expect(result).toEqual(['workspace1']); }); it('should return projects', async () => { - const result = await handleBitbucketCloudRequest('foo', 'projects', { + const result = await handleAutocompleteRequest('foo', 'projects', { workspace: 'workspace1', }); @@ -65,7 +65,7 @@ describe('handleBitbucketCloudRequest', () => { }); it('should return repositories', async () => { - const result = await handleBitbucketCloudRequest('foo', 'repositories', { + const result = await handleAutocompleteRequest('foo', 'repositories', { workspace: 'workspace1', project: 'project1', }); @@ -75,13 +75,13 @@ describe('handleBitbucketCloudRequest', () => { it('should throw an error when passing an invalid resource', async () => { await expect( - handleBitbucketCloudRequest('token', 'invalid', {}), + handleAutocompleteRequest('token', 'invalid', {}), ).rejects.toThrow(InputError); }); it('should throw an error when there are missing parameters', async () => { await expect( - handleBitbucketCloudRequest('token', 'projects', {}), + handleAutocompleteRequest('token', 'projects', {}), ).rejects.toThrow(InputError); }); }); diff --git a/plugins/scaffolder-backend/src/service/autocomplete.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts similarity index 69% rename from plugins/scaffolder-backend/src/service/autocomplete.ts rename to plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts index 131305d53e..50723fb41d 100644 --- a/plugins/scaffolder-backend/src/service/autocomplete.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete.ts @@ -17,11 +17,15 @@ import { InputError } from '@backstage/errors'; import { BitbucketCloudClient } from '@backstage/plugin-bitbucket-cloud-common'; -export async function handleAutocompleteRequest( - token: string, - resource: string, - parameters: Record, -): Promise { +export async function handleAutocompleteRequest({ + resource, + token, + context, +}: { + resource: string; + token: string; + context: Record; +}): Promise<{ results: { title: string }[] }> { const client = BitbucketCloudClient.fromConfig({ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0', @@ -37,41 +41,41 @@ export async function handleAutocompleteRequest( result.push(...slugs); } - return result; + return { results: result.map(title => ({ title })) }; } case 'projects': { - if (!parameters.workspace) - throw new InputError('Missing workspace query parameter'); + if (!context.workspace) + throw new InputError('Missing workspace context parameter'); const result: string[] = []; for await (const page of client - .listProjectsByWorkspace(parameters.workspace) + .listProjectsByWorkspace(context.workspace) .iteratePages()) { const keys = [...page.values!].map(p => p.key!); result.push(...keys); } - return result; + return { results: result.map(title => ({ title })) }; } case 'repositories': { - if (!parameters.workspace || !parameters.project) + if (!context.workspace || !context.project) throw new InputError( - 'Missing workspace and/or project query parameter', + 'Missing workspace and/or project context parameter', ); const result: string[] = []; for await (const page of client - .listRepositoriesByWorkspace(parameters.workspace, { - q: `project.key="${parameters.project}"`, + .listRepositoriesByWorkspace(context.workspace, { + q: `project.key="${context.project}"`, }) .iteratePages()) { const slugs = [...page.values!].map(p => p.slug!); result.push(...slugs); } - return result; + return { results: result.map(title => ({ title })) }; } default: throw new InputError(`Invalid resource: ${resource}`); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts index 81afbf8c88..5bf49f7cd9 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts @@ -17,12 +17,16 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; -import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; +import { + scaffolderActionsExtensionPoint, + scaffolderAutocompleteExtensionPoint, +} from '@backstage/plugin-scaffolder-node/alpha'; import { createBitbucketPipelinesRunAction, createPublishBitbucketCloudAction, } from './actions'; import { ScmIntegrations } from '@backstage/integration'; +import { handleAutocompleteRequest } from './autocomplete/autocomplete'; /** * @public @@ -35,15 +39,21 @@ export const bitbucketCloudModule = createBackendModule({ registerInit({ deps: { scaffolder: scaffolderActionsExtensionPoint, + autocomplete: scaffolderAutocompleteExtensionPoint, config: coreServices.rootConfig, }, - async init({ scaffolder, config }) { + async init({ scaffolder, config, autocomplete }) { const integrations = ScmIntegrations.fromConfig(config); scaffolder.addActions( createPublishBitbucketCloudAction({ integrations, config }), createBitbucketPipelinesRunAction({ integrations }), ); + + autocomplete.addAutocompleteProvider({ + id: 'bitbucket-cloud', + handler: handleAutocompleteRequest, + }); }, }); }, diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index f9ff1b2a04..10975d6b52 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -46,7 +46,7 @@ import { PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; -import { handleBitbucketCloudRequest } from './autocomplete'; +import { handleBitbucketCloudRequest } from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud/src/autocomplete/autocomplete'; const mockAccess = jest.fn(); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index dbccd6ddb5..e5f88c3482 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -94,7 +94,7 @@ import { } from '@backstage/plugin-auth-node'; import { InternalTaskSecrets } from '../scaffolder/tasks/types'; import { checkPermission } from '../util/checkPermissions'; -import { handleBitbucketCloudRequest } from './autocomplete'; +import { AutocompleteHandler } from '@backstage/plugin-scaffolder-node/alpha'; /** * @@ -167,6 +167,8 @@ export interface RouterOptions { httpAuth?: HttpAuthService; identity?: IdentityApi; discovery?: DiscoveryService; + + autocompleteHandlers?: Record; } function isSupportedTemplate(entity: TemplateEntityV1beta3) { @@ -274,6 +276,7 @@ export async function createRouter( permissionRules, discovery = HostDiscovery.fromConfig(config), identity = buildDefaultIdentityClient(options), + autocompleteHandlers = {}, } = options; const { auth, httpAuth } = createLegacyAuthAdapters({ @@ -773,28 +776,22 @@ export async function createRouter( })), }); }) - .get('/v2/autocomplete/:provider/:resource', async (req, res) => { - const { token, ...query } = req.query; + .post('/v2/autocomplete/:provider/:resource', async (req, res) => { + const { token, context } = req.body; const { provider, resource } = req.params; if (!token) throw new InputError('Missing token query parameter'); - let result: string[]; - - switch (provider) { - case 'bitbucketCloud': { - result = await handleBitbucketCloudRequest( - token as string, - resource, - query as Record, - ); - break; - } - default: - throw new InputError(`Unsupported provider: ${provider}`); + if (!autocompleteHandlers[provider]) { + throw new InputError(`Unsupported provider: ${provider}`); } + const { results } = await autocompleteHandlers[provider]({ + resource, + token, + context, + }); - res.status(200).json(result); + res.status(200).json({ results }); }); const app = express(); diff --git a/plugins/scaffolder-node/src/alpha.ts b/plugins/scaffolder-node/src/alpha.ts index 3644d48b7c..ef4d5ab289 100644 --- a/plugins/scaffolder-node/src/alpha.ts +++ b/plugins/scaffolder-node/src/alpha.ts @@ -80,22 +80,32 @@ export const scaffolderTemplatingExtensionPoint = id: 'scaffolder.templating', }); +/** + * Autocomplete handler for the scaffolder. + * @alpha + */ +export type AutocompleteHandler = ({ + resource, + token, + context, +}: { + resource: string; + token: string; + context: Record; +}) => Promise<{ results: { title: string }[] }>; + /** * Extension point for adding autocomplete handler providers * @alpha */ export interface ScaffolderAutocompleteExtensionPoint { - addAutocompleteProvider( - provider: ({ - type, - token, - query, - }: { - type: string; - token: string; - query: Record; - }) => Promise<{ results: { title: string }[] }>, - ): void; + addAutocompleteProvider({ + id, + handler, + }: { + id: string; + handler: AutocompleteHandler; + }): void; } /** diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index c79f98c780..eaef54ff1b 100644 --- a/plugins/scaffolder-react/src/api/types.ts +++ b/plugins/scaffolder-react/src/api/types.ts @@ -230,10 +230,10 @@ export interface ScaffolderApi { dryRun?(options: ScaffolderDryRunOptions): Promise; - autocomplete( - token: string, - provider: string, - resource: string, - params?: Record, - ): Promise; + autocomplete?(options: { + token: string; + provider: string; + resource: string; + context?: Record; + }): Promise<{ results: { title: string }[] }>; } diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index ff5c89e91e..1809ed4c15 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -338,27 +338,37 @@ export class ScaffolderClient implements ScaffolderApi { return await response.json(); } - async autocomplete( - token: string, - provider: string, - resource: string, - params?: Record, - ): Promise { + async autocomplete({ + token, + resource, + provider, + context, + }: { + token: string; + provider: string; + resource: string; + context?: Record; + }): Promise<{ results: { title: string }[] }> { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); - const query = new URLSearchParams({ - ...params, - token, - }); - const url = `${baseUrl}/v2/autocomplete/${provider}/${resource}?${query}`; + + const url = `${baseUrl}/v2/autocomplete/${provider}/${resource}`; const response = await this.fetchApi.fetch(url, { - method: 'GET', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + token, + context: context ?? {}, + }), }); if (!response.ok) { throw await ResponseError.fromResponse(response); } - return await response.json(); + const { results } = await response.json(); + return { results }; } } diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx index 51bd1f5c67..10d4a6e6c3 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -72,14 +72,19 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableWorkspaces = async () => { - if (host === 'bitbucket.org' && accessToken) { - const result = await scaffolderApi.autocomplete( - accessToken, - 'bitbucketCloud', - 'workspaces', - ); + if ( + host === 'bitbucket.org' && + accessToken && + scaffolderApi.autocomplete + ) { + const { results } = await scaffolderApi.autocomplete({ + token: accessToken, + resource: 'workspaces', + context: {}, + provider: 'bitbucket-cloud', + }); - setAvailableWorkspaces(result); + setAvailableWorkspaces(results.map(r => r.title)); } else { setAvailableWorkspaces([]); } @@ -95,15 +100,20 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableProjects = async () => { - if (host === 'bitbucket.org' && accessToken && workspace) { - const result = await scaffolderApi.autocomplete( - accessToken, - 'bitbucketCloud', - 'projects', - { workspace }, - ); + if ( + host === 'bitbucket.org' && + accessToken && + workspace && + scaffolderApi.autocomplete + ) { + const { results } = await scaffolderApi.autocomplete({ + token: accessToken, + resource: 'projects', + context: { workspace }, + provider: 'bitbucket-cloud', + }); - setAvailableProjects(result); + setAvailableProjects(results.map(r => r.title)); } else { setAvailableProjects([]); } @@ -119,15 +129,21 @@ export const BitbucketRepoPicker = (props: { useDebounce( () => { const updateAvailableRepositories = async () => { - if (host === 'bitbucket.org' && accessToken && workspace && project) { - const availableRepos = await scaffolderApi.autocomplete( - accessToken, - 'bitbucketCloud', - 'repositories', - { workspace, project }, - ); + if ( + host === 'bitbucket.org' && + accessToken && + workspace && + project && + scaffolderApi.autocomplete + ) { + const { results } = await scaffolderApi.autocomplete({ + token: accessToken, + resource: 'repositories', + context: { workspace, project }, + provider: 'bitbucket-cloud', + }); - onChange({ availableRepos }); + onChange({ availableRepos: results.map(r => r.title) }); } else { onChange({ availableRepos: [] }); }