chore: small refactor to move the handlers out into an extension point

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2024-06-19 17:09:53 +02:00
parent a82cbab3c4
commit 1fbd77fb56
10 changed files with 144 additions and 96 deletions
@@ -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",
@@ -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<BitbucketCloudClient> = {
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);
});
});
@@ -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<string, string>,
): Promise<string[]> {
export async function handleAutocompleteRequest({
resource,
token,
context,
}: {
resource: string;
token: string;
context: Record<string, string>;
}): 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}`);
@@ -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,
});
},
});
},
@@ -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();
@@ -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<string, AutocompleteHandler>;
}
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<string, string>,
);
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();
+21 -11
View File
@@ -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<string, string>;
}) => 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<string, string>;
}) => Promise<{ results: { title: string }[] }>,
): void;
addAutocompleteProvider({
id,
handler,
}: {
id: string;
handler: AutocompleteHandler;
}): void;
}
/**
+6 -6
View File
@@ -230,10 +230,10 @@ export interface ScaffolderApi {
dryRun?(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
autocomplete(
token: string,
provider: string,
resource: string,
params?: Record<string, string>,
): Promise<string[]>;
autocomplete?(options: {
token: string;
provider: string;
resource: string;
context?: Record<string, string>;
}): Promise<{ results: { title: string }[] }>;
}
+23 -13
View File
@@ -338,27 +338,37 @@ export class ScaffolderClient implements ScaffolderApi {
return await response.json();
}
async autocomplete(
token: string,
provider: string,
resource: string,
params?: Record<string, string>,
): Promise<string[]> {
async autocomplete({
token,
resource,
provider,
context,
}: {
token: string;
provider: string;
resource: string;
context?: Record<string, string>;
}): 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 };
}
}
@@ -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: [] });
}