chore: small refactor to move the handlers out into an extension point
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -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",
|
||||
|
||||
+8
-8
@@ -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);
|
||||
});
|
||||
});
|
||||
+19
-15
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }[] }>;
|
||||
}
|
||||
|
||||
@@ -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: [] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user