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
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-common': minor
---
**BREAKING PRODUCERS**: Made `retry`, `listTasks`, `listTemplatingExtensions`, `dryRun`, and `autocomplete` required methods on the `ScaffolderApi` interface. Implementations of `ScaffolderApi` must now provide these methods.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-react': patch
---
Added `scaffolderApiMock` test utility, exported from `@backstage/plugin-scaffolder-react/testUtils`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-node': patch
---
Added `scaffolderServiceRef` and `ScaffolderService` interface for backend plugins that need to interact with the scaffolder API using `BackstageCredentials` instead of raw tokens.
+93 -90
View File
@@ -58,7 +58,7 @@ export type LogEvent = {
// @public
export interface ScaffolderApi {
// (undocumented)
autocomplete?(
autocomplete(
request: {
token: string;
provider: string;
@@ -79,94 +79,6 @@ export interface ScaffolderApi {
status?: ScaffolderTaskStatus;
}>;
// (undocumented)
dryRun?(
request: ScaffolderDryRunOptions,
options?: ScaffolderRequestOptions,
): Promise<ScaffolderDryRunResponse>;
// (undocumented)
getIntegrationsList(
options: ScaffolderGetIntegrationsListOptions,
): Promise<ScaffolderGetIntegrationsListResponse>;
// (undocumented)
getTask(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<ScaffolderTask>;
// (undocumented)
getTemplateParameterSchema(
templateRef: string,
options?: ScaffolderRequestOptions,
): Promise<TemplateParameterSchema>;
listActions(options?: ScaffolderRequestOptions): Promise<ListActionsResponse>;
// (undocumented)
listTasks?(
request: {
filterByOwnership: 'owned' | 'all';
limit?: number;
offset?: number;
},
options?: ScaffolderRequestOptions,
): Promise<{
tasks: ScaffolderTask[];
totalTasks?: number;
}>;
listTemplatingExtensions?(
options?: ScaffolderRequestOptions,
): Promise<ListTemplatingExtensionsResponse>;
retry?(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<{
id: string;
}>;
scaffold(
request: ScaffolderScaffoldOptions,
options?: ScaffolderRequestOptions,
): Promise<ScaffolderScaffoldResponse>;
// (undocumented)
streamLogs(
request: ScaffolderStreamLogsOptions,
options?: ScaffolderRequestOptions,
): Observable<LogEvent>;
}
// @public
export class ScaffolderClient implements ScaffolderApi {
constructor(options: {
discoveryApi: {
getBaseUrl(pluginId: string): Promise<string>;
};
fetchApi: {
fetch: typeof fetch;
};
identityApi?: {
getBackstageIdentity(): Promise<{
type: 'user';
userEntityRef: string;
ownershipEntityRefs: string[];
}>;
};
scmIntegrationsApi: ScmIntegrationRegistry;
useLongPollingLogs?: boolean;
});
autocomplete(input: {
token: string;
provider: string;
resource: string;
context: Record<string, string>;
}): Promise<{
results: {
title?: string;
id: string;
}[];
}>;
cancelTask(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<{
status?: ScaffolderTaskStatus;
}>;
// (undocumented)
dryRun(
request: ScaffolderDryRunOptions,
options?: ScaffolderRequestOptions,
@@ -201,7 +113,98 @@ export class ScaffolderClient implements ScaffolderApi {
listTemplatingExtensions(
options?: ScaffolderRequestOptions,
): Promise<ListTemplatingExtensionsResponse>;
retry?(
retry(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<{
id: string;
}>;
scaffold(
request: ScaffolderScaffoldOptions,
options?: ScaffolderRequestOptions,
): Promise<ScaffolderScaffoldResponse>;
// (undocumented)
streamLogs(
request: ScaffolderStreamLogsOptions,
options?: ScaffolderRequestOptions,
): Observable<LogEvent>;
}
// @public
export class ScaffolderClient implements ScaffolderApi {
constructor(options: {
discoveryApi: {
getBaseUrl(pluginId: string): Promise<string>;
};
fetchApi: {
fetch: typeof fetch;
};
identityApi?: {
getBackstageIdentity(): Promise<{
type: 'user';
userEntityRef: string;
ownershipEntityRefs: string[];
}>;
};
scmIntegrationsApi: ScmIntegrationRegistry;
useLongPollingLogs?: boolean;
});
autocomplete(
input: {
token: string;
provider: string;
resource: string;
context: Record<string, string>;
},
options?: ScaffolderRequestOptions,
): Promise<{
results: {
title?: string;
id: string;
}[];
}>;
cancelTask(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<{
status?: ScaffolderTaskStatus;
}>;
// (undocumented)
dryRun(
request: ScaffolderDryRunOptions,
options?: ScaffolderRequestOptions,
): Promise<ScaffolderDryRunResponse>;
// (undocumented)
getIntegrationsList(
options: ScaffolderGetIntegrationsListOptions,
): Promise<ScaffolderGetIntegrationsListResponse>;
// (undocumented)
getTask(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<ScaffolderTask>;
// (undocumented)
getTemplateParameterSchema(
templateRef: string,
options?: ScaffolderRequestOptions,
): Promise<TemplateParameterSchema>;
listActions(options?: ScaffolderRequestOptions): Promise<ListActionsResponse>;
// (undocumented)
listTasks(
request: {
filterByOwnership: 'owned' | 'all';
limit?: number;
offset?: number;
},
options?: ScaffolderRequestOptions,
): Promise<{
tasks: ScaffolderTask[];
totalTasks?: number;
}>;
listTemplatingExtensions(
options?: ScaffolderRequestOptions,
): Promise<ListTemplatingExtensionsResponse>;
retry(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<{
@@ -381,7 +381,7 @@ export class ScaffolderClient implements ScaffolderApi {
/**
* {@inheritdoc ScaffolderApi.retry}
*/
async retry?(
async retry(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<{ id: string }> {
@@ -393,22 +393,28 @@ export class ScaffolderClient implements ScaffolderApi {
/**
* {@inheritdoc ScaffolderApi.retry}
*/
async autocomplete({
token,
resource,
provider,
context,
}: {
token: string;
provider: string;
resource: string;
context: Record<string, string>;
}): Promise<{ results: { title?: string; id: string }[] }> {
async autocomplete(
{
token,
resource,
provider,
context,
}: {
token: string;
provider: string;
resource: string;
context: Record<string, string>;
},
options?: ScaffolderRequestOptions,
): Promise<{ results: { title?: string; id: string }[] }> {
return await this.requestRequired(
await this.apiClient.autocomplete({
path: { provider, resource },
body: { token, context },
}),
await this.apiClient.autocomplete(
{
path: { provider, resource },
body: { token, context },
},
options,
),
);
}
+5 -5
View File
@@ -285,12 +285,12 @@ export interface ScaffolderApi {
*
* @param taskId - the id of the task
*/
retry?(
retry(
taskId: string,
options?: ScaffolderRequestOptions,
): Promise<{ id: string }>;
listTasks?(
listTasks(
request: {
filterByOwnership: 'owned' | 'all';
limit?: number;
@@ -311,7 +311,7 @@ export interface ScaffolderApi {
/**
* Returns a structure describing the available templating extensions.
*/
listTemplatingExtensions?(
listTemplatingExtensions(
options?: ScaffolderRequestOptions,
): Promise<ListTemplatingExtensionsResponse>;
@@ -320,12 +320,12 @@ export interface ScaffolderApi {
options?: ScaffolderRequestOptions,
): Observable<LogEvent>;
dryRun?(
dryRun(
request: ScaffolderDryRunOptions,
options?: ScaffolderRequestOptions,
): Promise<ScaffolderDryRunResponse>;
autocomplete?(
autocomplete(
request: {
token: string;
provider: string;
+14 -1
View File
@@ -27,6 +27,7 @@
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha/index.ts",
"./testUtils": "./src/testUtils.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
@@ -36,6 +37,9 @@
"alpha": [
"src/alpha/index.ts"
],
"testUtils": [
"src/testUtils.ts"
],
"package.json": [
"package.json"
]
@@ -79,6 +83,15 @@
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"@types/lodash": "^4.14.151"
"@types/lodash": "^4.14.151",
"msw": "^1.0.0"
},
"peerDependencies": {
"@backstage/backend-test-utils": "workspace:^"
},
"peerDependenciesMeta": {
"@backstage/backend-test-utils": {
"optional": true
}
}
}
@@ -0,0 +1,122 @@
## API Report File for "@backstage/plugin-scaffolder-node"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { ListActionsResponse } from '@backstage/plugin-scaffolder-common';
import { ListTemplatingExtensionsResponse } from '@backstage/plugin-scaffolder-common';
import { LogEvent } from '@backstage/plugin-scaffolder-common';
import { ScaffolderDryRunOptions } from '@backstage/plugin-scaffolder-common';
import { ScaffolderDryRunResponse } from '@backstage/plugin-scaffolder-common';
import { ScaffolderScaffoldOptions } from '@backstage/plugin-scaffolder-common';
import { ScaffolderScaffoldResponse } from '@backstage/plugin-scaffolder-common';
import { ScaffolderTask } from '@backstage/plugin-scaffolder-common';
import { ScaffolderTaskStatus } from '@backstage/plugin-scaffolder-common';
import { ServiceMock } from '@backstage/backend-test-utils';
import type { TemplateParameterSchema } from '@backstage/plugin-scaffolder-common';
// @public
export interface ScaffolderService {
// (undocumented)
autocomplete(
request: {
token: string;
provider: string;
resource: string;
context: Record<string, string>;
},
options: ScaffolderServiceRequestOptions,
): Promise<{
results: {
title?: string;
id: string;
}[];
}>;
// (undocumented)
cancelTask(
request: {
taskId: string;
},
options: ScaffolderServiceRequestOptions,
): Promise<{
status?: ScaffolderTaskStatus;
}>;
// (undocumented)
dryRun(
request: ScaffolderDryRunOptions,
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderDryRunResponse>;
// (undocumented)
getLogs(
request: {
taskId: string;
after?: number;
},
options: ScaffolderServiceRequestOptions,
): Promise<LogEvent[]>;
// (undocumented)
getTask(
request: {
taskId: string;
},
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderTask>;
// (undocumented)
getTemplateParameterSchema(
request: {
templateRef: string;
},
options: ScaffolderServiceRequestOptions,
): Promise<TemplateParameterSchema>;
// (undocumented)
listActions(
request?: {},
options?: ScaffolderServiceRequestOptions,
): Promise<ListActionsResponse>;
// (undocumented)
listTasks(
request: {
createdBy?: string;
limit?: number;
offset?: number;
},
options: ScaffolderServiceRequestOptions,
): Promise<{
items: ScaffolderTask[];
totalItems: number;
}>;
// (undocumented)
listTemplatingExtensions(
request?: {},
options?: ScaffolderServiceRequestOptions,
): Promise<ListTemplatingExtensionsResponse>;
// (undocumented)
retry(
request: {
taskId: string;
},
options: ScaffolderServiceRequestOptions,
): Promise<{
id: string;
}>;
// (undocumented)
scaffold(
request: ScaffolderScaffoldOptions,
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderScaffoldResponse>;
}
// @public
export namespace scaffolderServiceMock {
const mock: (
partialImpl?: Partial<ScaffolderService> | undefined,
) => ServiceMock<ScaffolderService>;
}
// @public (undocumented)
export interface ScaffolderServiceRequestOptions {
// (undocumented)
credentials: BackstageCredentials;
}
```
+115
View File
@@ -9,15 +9,26 @@ import { Expand } from '@backstage/types';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { ListActionsResponse } from '@backstage/plugin-scaffolder-common';
import { ListTemplatingExtensionsResponse } from '@backstage/plugin-scaffolder-common';
import { LogEvent } from '@backstage/plugin-scaffolder-common';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Observable } from '@backstage/types';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { ScaffolderDryRunOptions } from '@backstage/plugin-scaffolder-common';
import { ScaffolderDryRunResponse } from '@backstage/plugin-scaffolder-common';
import { ScaffolderScaffoldOptions } from '@backstage/plugin-scaffolder-common';
import { ScaffolderScaffoldResponse } from '@backstage/plugin-scaffolder-common';
import { ScaffolderTask } from '@backstage/plugin-scaffolder-common';
import { ScaffolderTaskStatus } from '@backstage/plugin-scaffolder-common';
import { Schema } from 'jsonschema';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { ScmIntegrations } from '@backstage/integration';
import { ServiceRef } from '@backstage/backend-plugin-api';
import { SpawnOptionsWithoutStdio } from 'node:child_process';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import { TemplateInfo } from '@backstage/plugin-scaffolder-common';
import type { TemplateParameterSchema } from '@backstage/plugin-scaffolder-common';
import { UpdateTaskCheckpointOptions } from '@backstage/plugin-scaffolder-node/alpha';
import { UrlReaderService } from '@backstage/backend-plugin-api';
import { UserEntity } from '@backstage/catalog-model';
@@ -316,6 +327,110 @@ export interface ScaffolderActionsExtensionPoint {
// @public
export const scaffolderActionsExtensionPoint: ExtensionPoint<ScaffolderActionsExtensionPoint>;
// @public
export interface ScaffolderService {
// (undocumented)
autocomplete(
request: {
token: string;
provider: string;
resource: string;
context: Record<string, string>;
},
options: ScaffolderServiceRequestOptions,
): Promise<{
results: {
title?: string;
id: string;
}[];
}>;
// (undocumented)
cancelTask(
request: {
taskId: string;
},
options: ScaffolderServiceRequestOptions,
): Promise<{
status?: ScaffolderTaskStatus;
}>;
// (undocumented)
dryRun(
request: ScaffolderDryRunOptions,
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderDryRunResponse>;
// (undocumented)
getLogs(
request: {
taskId: string;
after?: number;
},
options: ScaffolderServiceRequestOptions,
): Promise<LogEvent[]>;
// (undocumented)
getTask(
request: {
taskId: string;
},
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderTask>;
// (undocumented)
getTemplateParameterSchema(
request: {
templateRef: string;
},
options: ScaffolderServiceRequestOptions,
): Promise<TemplateParameterSchema>;
// (undocumented)
listActions(
request?: {},
options?: ScaffolderServiceRequestOptions,
): Promise<ListActionsResponse>;
// (undocumented)
listTasks(
request: {
createdBy?: string;
limit?: number;
offset?: number;
},
options: ScaffolderServiceRequestOptions,
): Promise<{
items: ScaffolderTask[];
totalItems: number;
}>;
// (undocumented)
listTemplatingExtensions(
request?: {},
options?: ScaffolderServiceRequestOptions,
): Promise<ListTemplatingExtensionsResponse>;
// (undocumented)
retry(
request: {
taskId: string;
},
options: ScaffolderServiceRequestOptions,
): Promise<{
id: string;
}>;
// (undocumented)
scaffold(
request: ScaffolderScaffoldOptions,
options: ScaffolderServiceRequestOptions,
): Promise<ScaffolderScaffoldResponse>;
}
// @public
export const scaffolderServiceRef: ServiceRef<
ScaffolderService,
'plugin',
'singleton'
>;
// @public (undocumented)
export interface ScaffolderServiceRequestOptions {
// (undocumented)
credentials: BackstageCredentials;
}
// @public (undocumented)
export interface SerializedFile {
// (undocumented)
+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(),
}),
);
}
+8
View File
@@ -31,6 +31,7 @@
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./testUtils": "./src/testUtils.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
@@ -40,6 +41,9 @@
"alpha": [
"src/alpha.ts"
],
"testUtils": [
"src/testUtils.ts"
],
"package.json": [
"package.json"
]
@@ -115,12 +119,16 @@
"swr": "^2.0.0"
},
"peerDependencies": {
"@backstage/frontend-test-utils": "workspace:^",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "^6.30.2"
},
"peerDependenciesMeta": {
"@backstage/frontend-test-utils": {
"optional": true
},
"@types/react": {
"optional": true
}
@@ -0,0 +1,15 @@
## API Report File for "@backstage/plugin-scaffolder-react"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiMock } from '@backstage/frontend-test-utils';
import { ScaffolderApi } from '@backstage/plugin-scaffolder-common';
// @public
export namespace scaffolderApiMock {
const mock: (
partialImpl?: Partial<ScaffolderApi> | undefined,
) => ApiMock<ScaffolderApi>;
}
```
@@ -37,6 +37,9 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
retry: jest.fn(),
listTemplatingExtensions: jest.fn(),
dryRun: jest.fn(),
};
const catalogApi = catalogApiMock.mock();
+23
View File
@@ -0,0 +1,23 @@
/*
* 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.
*/
/**
* Frontend test helpers for the Scaffolder plugin.
*
* @packageDocumentation
*/
export { scaffolderApiMock } from './testUtils/scaffolderApiMock';
@@ -0,0 +1,37 @@
/*
* 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 { scaffolderApiMock } from './scaffolderApiMock';
describe('scaffolderApiMock', () => {
it('creates a mock with all methods as jest.fn()', () => {
const mock = scaffolderApiMock.mock();
expect(mock.getTask).toHaveBeenCalledTimes(0);
expect(mock.scaffold).toHaveBeenCalledTimes(0);
expect(mock.listActions).toHaveBeenCalledTimes(0);
});
it('supports overriding individual methods', async () => {
const mock = scaffolderApiMock.mock({
getTask: jest.fn().mockResolvedValue({ id: 'task-1' }),
});
await expect(mock.getTask('task-1')).resolves.toEqual(
expect.objectContaining({ id: 'task-1' }),
);
});
});
@@ -0,0 +1,44 @@
/*
* 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 { createApiMock } from '@backstage/frontend-test-utils';
import { scaffolderApiRef } from '../api/ref';
/**
* A collection of mock functionality for the scaffolder API.
*
* @public
*/
export namespace scaffolderApiMock {
/**
* Creates a scaffolder API whose methods are mock functions, possibly with
* some of them overloaded by the caller.
*/
export const mock = createApiMock(scaffolderApiRef, () => ({
getTemplateParameterSchema: jest.fn(),
scaffold: jest.fn(),
getTask: jest.fn(),
cancelTask: jest.fn(),
retry: jest.fn(),
listTasks: jest.fn(),
getIntegrationsList: jest.fn(),
listActions: jest.fn(),
listTemplatingExtensions: jest.fn(),
streamLogs: jest.fn(),
dryRun: jest.fn(),
autocomplete: jest.fn(),
}));
}
@@ -38,6 +38,9 @@ describe('TemplateEditorToolbar', () => {
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
retry: jest.fn(),
listTemplatingExtensions: jest.fn(),
dryRun: jest.fn(),
};
scaffolderApiMock.listActions.mockResolvedValue([
@@ -54,6 +54,9 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
retry: jest.fn(),
listTemplatingExtensions: jest.fn(),
dryRun: jest.fn(),
};
const scaffolderDecoratorsMock: jest.Mocked<ScaffolderFormDecoratorsApi> = {
@@ -34,6 +34,9 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
listActions: jest.fn(),
listTasks: jest.fn(),
autocomplete: jest.fn(),
retry: jest.fn(),
listTemplatingExtensions: jest.fn(),
dryRun: jest.fn(),
};
const mockPermissionApi = { authorize: jest.fn() };
@@ -40,6 +40,8 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
listTemplatingExtensions,
listTasks: jest.fn(),
autocomplete: jest.fn(),
retry: jest.fn(),
dryRun: jest.fn(),
};
const mockPermissionApi = { authorize: jest.fn() };
+9
View File
@@ -7104,12 +7104,18 @@ __metadata:
isomorphic-git: "npm:^1.23.0"
jsonschema: "npm:^1.5.0"
lodash: "npm:^4.17.21"
msw: "npm:^1.0.0"
p-limit: "npm:^3.1.0"
tar: "npm:^7.5.6"
winston: "npm:^3.2.1"
winston-transport: "npm:^4.7.0"
zod: "npm:^3.25.76"
zod-to-json-schema: "npm:^3.25.1"
peerDependencies:
"@backstage/backend-test-utils": "workspace:^"
peerDependenciesMeta:
"@backstage/backend-test-utils":
optional: true
languageName: unknown
linkType: soft
@@ -7171,11 +7177,14 @@ __metadata:
zod: "npm:^3.25.76"
zod-to-json-schema: "npm:^3.25.1"
peerDependencies:
"@backstage/frontend-test-utils": "workspace:^"
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.30.2
peerDependenciesMeta:
"@backstage/frontend-test-utils":
optional: true
"@types/react":
optional: true
languageName: unknown