feat(scaffolder): implement scaffolderServiceRef for credentials aware communication (#33044)

* add scaffolderServiceRef to scaffolder-node

Signed-off-by: benjdlambert <ben@blam.sh>

* make ScaffolderApi methods required, add tests

Signed-off-by: benjdlambert <ben@blam.sh>

* use request objects for single-string params in ScaffolderService

Signed-off-by: benjdlambert <ben@blam.sh>

* add scaffolderServiceMock test utility

Signed-off-by: benjdlambert <ben@blam.sh>

* adjust review comments

Signed-off-by: benjdlambert <ben@blam.sh>

* add scaffolderApiMock test utility to scaffolder-react

Signed-off-by: benjdlambert <ben@blam.sh>

* use items/totalItems response shape for listTasks

Signed-off-by: benjdlambert <ben@blam.sh>

* pass credentials through for autocomplete

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-02-27 17:24:49 +01:00
committed by GitHub
parent 1b3dea2092
commit f598909f0f
26 changed files with 1188 additions and 112 deletions
+1
View File
@@ -25,3 +25,4 @@ export * from './tasks';
export * from './files';
export * from './types';
export * from './extensions';
export * from './scaffolderService';
@@ -0,0 +1,205 @@
/*
* Copyright 2025 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 {
createBackendModule,
createServiceFactory,
createServiceRef,
} from '@backstage/backend-plugin-api';
import {
ServiceFactoryTester,
mockCredentials,
mockServices,
registerMswTestHooks,
startTestBackend,
} from '@backstage/backend-test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { scaffolderServiceRef } from './scaffolderService';
describe('scaffolderServiceRef', () => {
const server = setupServer();
registerMswTestHooks(server);
it('should return a scaffolder service', async () => {
expect.assertions(1);
const testModule = createBackendModule({
moduleId: 'test',
pluginId: 'test',
register(env) {
env.registerInit({
deps: {
scaffolder: scaffolderServiceRef,
},
async init({ scaffolder }) {
expect(scaffolder.getTask).toBeDefined();
},
});
},
});
await startTestBackend({
features: [testModule],
});
});
it('should inject token from user credentials', async () => {
expect.assertions(1);
server.use(
rest.get('*/api/scaffolder/v2/tasks/:taskId', (req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
mockCredentials.service.header({
onBehalfOf: mockCredentials.user(),
targetPluginId: 'scaffolder',
}),
);
return res(
ctx.json({
id: 'task-1',
spec: {},
status: 'completed',
createdAt: '2025-01-01T00:00:00Z',
}),
);
}),
);
const tester = ServiceFactoryTester.from(
createServiceFactory({
service: createServiceRef<void>({ id: 'unused-dummy' }),
deps: {},
factory() {},
}),
{ dependencies: [mockServices.discovery.factory()] },
);
const scaffolder = await tester.getService(scaffolderServiceRef);
await scaffolder.getTask(
{ taskId: 'task-1' },
{
credentials: mockCredentials.user(),
},
);
});
it('should inject token from service credentials', async () => {
expect.assertions(1);
server.use(
rest.get('*/api/scaffolder/v2/tasks/:taskId', (req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
mockCredentials.service.header({
onBehalfOf: mockCredentials.service(),
targetPluginId: 'scaffolder',
}),
);
return res(
ctx.json({
id: 'task-1',
spec: {},
status: 'completed',
createdAt: '2025-01-01T00:00:00Z',
}),
);
}),
);
const tester = ServiceFactoryTester.from(
createServiceFactory({
service: createServiceRef<void>({ id: 'unused-dummy' }),
deps: {},
factory() {},
}),
{ dependencies: [mockServices.discovery.factory()] },
);
const scaffolder = await tester.getService(scaffolderServiceRef);
await scaffolder.getTask(
{ taskId: 'task-1' },
{
credentials: mockCredentials.service(),
},
);
});
it('should pass credentials for direct HTTP calls like getLogs', async () => {
expect.assertions(1);
server.use(
rest.get('*/api/scaffolder/v2/tasks/:taskId/events', (req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
mockCredentials.service.header({
onBehalfOf: mockCredentials.user(),
targetPluginId: 'scaffolder',
}),
);
return res(ctx.json([]));
}),
);
const tester = ServiceFactoryTester.from(
createServiceFactory({
service: createServiceRef<void>({ id: 'unused-dummy' }),
deps: {},
factory() {},
}),
{ dependencies: [mockServices.discovery.factory()] },
);
const scaffolder = await tester.getService(scaffolderServiceRef);
await scaffolder.getLogs(
{ taskId: 'task-1' },
{ credentials: mockCredentials.user() },
);
});
it('should pass createdBy and pagination params for listTasks', async () => {
expect.assertions(4);
server.use(
rest.get('*/api/scaffolder/v2/tasks', (req, res, ctx) => {
expect(req.url.searchParams.get('createdBy')).toBe(
'user:default/guest',
);
expect(req.url.searchParams.get('limit')).toBe('10');
expect(req.url.searchParams.get('offset')).toBe('5');
return res(ctx.json({ tasks: [], totalTasks: 0 }));
}),
);
const tester = ServiceFactoryTester.from(
createServiceFactory({
service: createServiceRef<void>({ id: 'unused-dummy' }),
deps: {},
factory() {},
}),
{ dependencies: [mockServices.discovery.factory()] },
);
const scaffolder = await tester.getService(scaffolderServiceRef);
const result = await scaffolder.listTasks(
{ createdBy: 'user:default/guest', limit: 10, offset: 5 },
{ credentials: mockCredentials.user() },
);
expect(result).toEqual({ items: [], totalItems: 0 });
});
});
@@ -0,0 +1,338 @@
/*
* Copyright 2025 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 {
AuthService,
BackstageCredentials,
coreServices,
createServiceFactory,
createServiceRef,
DiscoveryService,
} from '@backstage/backend-plugin-api';
import { ResponseError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import {
ListActionsResponse,
ListTemplatingExtensionsResponse,
LogEvent,
ScaffolderClient,
ScaffolderDryRunOptions,
ScaffolderDryRunResponse,
ScaffolderRequestOptions,
ScaffolderScaffoldOptions,
ScaffolderScaffoldResponse,
ScaffolderTask,
ScaffolderTaskStatus,
} from '@backstage/plugin-scaffolder-common';
import type { TemplateParameterSchema } from '@backstage/plugin-scaffolder-common';
/**
* @public
*/
export interface ScaffolderServiceRequestOptions {
credentials: BackstageCredentials;
}
/**
* A backend service interface for the scaffolder that uses
* {@link @backstage/backend-plugin-api#BackstageCredentials} instead of tokens.
*
* @public
*/
export interface ScaffolderService {
getTemplateParameterSchema(
request: { templateRef: string },
options: ScaffolderServiceRequestOptions,
): Promise<TemplateParameterSchema>;
scaffold(
request: ScaffolderScaffoldOptions,
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderScaffoldResponse>;
getTask(
request: { taskId: string },
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderTask>;
cancelTask(
request: { taskId: string },
options: ScaffolderServiceRequestOptions,
): Promise<{ status?: ScaffolderTaskStatus }>;
retry(
request: { taskId: string },
options: ScaffolderServiceRequestOptions,
): Promise<{ id: string }>;
listTasks(
request: {
createdBy?: string;
limit?: number;
offset?: number;
},
options: ScaffolderServiceRequestOptions,
): Promise<{ items: ScaffolderTask[]; totalItems: number }>;
listActions(
request?: {},
options?: ScaffolderServiceRequestOptions,
): Promise<ListActionsResponse>;
listTemplatingExtensions(
request?: {},
options?: ScaffolderServiceRequestOptions,
): Promise<ListTemplatingExtensionsResponse>;
getLogs(
request: {
taskId: string;
after?: number;
},
options: ScaffolderServiceRequestOptions,
): Promise<LogEvent[]>;
dryRun(
request: ScaffolderDryRunOptions,
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderDryRunResponse>;
autocomplete(
request: {
token: string;
provider: string;
resource: string;
context: Record<string, string>;
},
options: ScaffolderServiceRequestOptions,
): Promise<{ results: { title?: string; id: string }[] }>;
}
class DefaultScaffolderService implements ScaffolderService {
readonly #auth: AuthService;
readonly #client: ScaffolderClient;
readonly #discovery: DiscoveryService;
constructor(options: {
auth: AuthService;
client: ScaffolderClient;
discovery: DiscoveryService;
}) {
this.#auth = options.auth;
this.#client = options.client;
this.#discovery = options.discovery;
}
async getTemplateParameterSchema(
request: { templateRef: string },
options: ScaffolderServiceRequestOptions,
): Promise<TemplateParameterSchema> {
return this.#client.getTemplateParameterSchema(
request.templateRef,
await this.#getOptions(options),
);
}
async scaffold(
request: ScaffolderScaffoldOptions,
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderScaffoldResponse> {
return this.#client.scaffold(request, await this.#getOptions(options));
}
async getTask(
request: { taskId: string },
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderTask> {
return this.#client.getTask(
request.taskId,
await this.#getOptions(options),
);
}
async cancelTask(
request: { taskId: string },
options: ScaffolderServiceRequestOptions,
): Promise<{ status?: ScaffolderTaskStatus }> {
return this.#client.cancelTask(
request.taskId,
await this.#getOptions(options),
);
}
async retry(
request: { taskId: string },
options: ScaffolderServiceRequestOptions,
): Promise<{ id: string }> {
return this.#client.retry(request.taskId, await this.#getOptions(options));
}
async listTasks(
request: {
createdBy?: string;
limit?: number;
offset?: number;
},
options: ScaffolderServiceRequestOptions,
): Promise<{ items: ScaffolderTask[]; totalItems: number }> {
const { token } = await this.#getOptions(options);
const baseUrl = await this.#discovery.getBaseUrl('scaffolder');
const params = new URLSearchParams();
if (request.createdBy) {
params.set('createdBy', request.createdBy);
}
if (request.limit !== undefined) {
params.set('limit', String(request.limit));
}
if (request.offset !== undefined) {
params.set('offset', String(request.offset));
}
const query = params.toString();
const url = `${baseUrl}/v2/tasks${query ? `?${query}` : ''}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const body = await response.json();
return {
items: body.tasks,
totalItems: body.totalTasks ?? 0,
};
}
async listActions(
_request?: {},
options?: ScaffolderServiceRequestOptions,
): Promise<ListActionsResponse> {
return this.#client.listActions(
options ? await this.#getOptions(options) : {},
);
}
async listTemplatingExtensions(
_request?: {},
options?: ScaffolderServiceRequestOptions,
): Promise<ListTemplatingExtensionsResponse> {
return this.#client.listTemplatingExtensions(
options ? await this.#getOptions(options) : {},
);
}
async getLogs(
request: {
taskId: string;
after?: number;
},
options: ScaffolderServiceRequestOptions,
): Promise<LogEvent[]> {
const { token } = await this.#getOptions(options);
const baseUrl = await this.#discovery.getBaseUrl('scaffolder');
const params = new URLSearchParams();
if (request.after !== undefined) {
params.set('after', String(request.after));
}
const query = params.toString();
const taskId = encodeURIComponent(request.taskId);
const url = `${baseUrl}/v2/tasks/${taskId}/events${
query ? `?${query}` : ''
}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return response.json();
}
async dryRun(
request: ScaffolderDryRunOptions,
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderDryRunResponse> {
return this.#client.dryRun(request, await this.#getOptions(options));
}
async autocomplete(
request: {
token: string;
provider: string;
resource: string;
context: Record<string, string>;
},
options: ScaffolderServiceRequestOptions,
): Promise<{ results: { title?: string; id: string }[] }> {
return this.#client.autocomplete(request, await this.#getOptions(options));
}
async #getOptions(
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderRequestOptions> {
return this.#auth.getPluginRequestToken({
onBehalfOf: options.credentials,
targetPluginId: 'scaffolder',
});
}
}
/**
* A service ref for the scaffolder client, to be used by backend plugins
* and modules that need to interact with the scaffolder API.
*
* @public
*/
export const scaffolderServiceRef = createServiceRef<ScaffolderService>({
id: 'scaffolder-client',
defaultFactory: async service =>
createServiceFactory({
service,
deps: {
auth: coreServices.auth,
discovery: coreServices.discovery,
config: coreServices.rootConfig,
},
async factory({ auth, discovery, config }) {
const integrations = ScmIntegrations.fromConfig(config);
const client = new ScaffolderClient({
discoveryApi: discovery,
fetchApi: { fetch },
scmIntegrationsApi: integrations,
});
return new DefaultScaffolderService({
auth,
client,
discovery,
});
},
}),
});
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2025 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.
*/
/**
* Backend test helpers for the Scaffolder plugin.
*
* @packageDocumentation
*/
export type {
ScaffolderService,
ScaffolderServiceRequestOptions,
} from './scaffolderService';
export { scaffolderServiceMock } from './testUtils/scaffolderServiceMock';
@@ -0,0 +1,35 @@
/*
* Copyright 2025 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 { scaffolderServiceMock } from './scaffolderServiceMock';
describe('scaffolderServiceMock', () => {
it('creates a mock with all methods as jest.fn()', () => {
const mock = scaffolderServiceMock.mock();
expect(mock.getTask).toHaveBeenCalledTimes(0);
});
it('supports overriding individual methods', async () => {
const mock = scaffolderServiceMock.mock({
getTask: jest.fn().mockResolvedValue({ id: 'task-1' }),
});
await expect(
mock.getTask({ taskId: 'task-1' }, { credentials: expect.anything() }),
).resolves.toEqual(expect.objectContaining({ id: 'task-1' }));
});
});
@@ -0,0 +1,46 @@
/*
* Copyright 2025 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 { createServiceMock } from '@backstage/backend-test-utils';
import { scaffolderServiceRef, ScaffolderService } from '../scaffolderService';
/**
* A collection of mock functionality for the scaffolder service.
*
* @public
*/
export namespace scaffolderServiceMock {
/**
* Creates a scaffolder service whose methods are mock functions, possibly with
* some of them overloaded by the caller.
*/
export const mock = createServiceMock<ScaffolderService>(
scaffolderServiceRef,
() => ({
getTemplateParameterSchema: jest.fn(),
scaffold: jest.fn(),
getTask: jest.fn(),
cancelTask: jest.fn(),
retry: jest.fn(),
listTasks: jest.fn(),
listActions: jest.fn(),
listTemplatingExtensions: jest.fn(),
getLogs: jest.fn(),
dryRun: jest.fn(),
autocomplete: jest.fn(),
}),
);
}