diff --git a/app-config.yaml b/app-config.yaml index 5651b03f91..89e417fad3 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -35,6 +35,7 @@ backend: # auth: # keys: # - secret: ${BACKEND_SECRET} + auth: # TODO: once plugins have been migrated we can remove this, but right now it # is require for the backend-next to work in this repo diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts index de3959ec6c..4f11050646 100644 --- a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -47,15 +47,14 @@ export class DefaultActionsService implements ActionsService { return new DefaultActionsService(discovery, config, logger, auth); } - async listActions({ credentials }: { credentials: BackstageCredentials }) { + async list({ credentials }: { credentials: BackstageCredentials }) { const pluginSources = this.config.getOptionalStringArray('backend.actions.pluginSources') ?? []; const remoteActionsList = await Promise.all( pluginSources.map(async source => { - const pluginBaseUrl = await this.discovery.getBaseUrl(source); const response = await this.makeRequest({ - url: `${pluginBaseUrl}/.backstage/actions/v1/actions`, + path: `/.backstage/actions/v1/actions`, pluginId: source, credentials, }); @@ -76,17 +75,16 @@ export class DefaultActionsService implements ActionsService { return { actions: remoteActionsList.flat() }; } - async invokeAction(opts: { + async invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; }) { const pluginId = this.pluginIdFromActionId(opts.id); - - const baseUrl = await this.discovery.getBaseUrl(pluginId); - const response = await this.makeRequest({ - url: `${baseUrl}/.backstage/actions/v1/actions/${opts.id}/invoke`, + path: `/.backstage/actions/v1/actions/${encodeURIComponent( + opts.id, + )}/invoke`, pluginId, credentials: opts.credentials, options: { @@ -107,18 +105,20 @@ export class DefaultActionsService implements ActionsService { } private async makeRequest(opts: { - url: string; + path: string; pluginId: string; options?: RequestInit; credentials: BackstageCredentials; }) { - const { url, credentials, options } = opts; + const { path, pluginId, credentials, options } = opts; + const baseUrl = await this.discovery.getBaseUrl(pluginId); + const { token } = await this.auth.getPluginRequestToken({ onBehalfOf: credentials, targetPluginId: opts.pluginId, }); - return fetch(url, { + return fetch(`${baseUrl}${path}`, { ...options, headers: { ...options?.headers, diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts index f1bb54a184..7b7285c89a 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts @@ -66,6 +66,10 @@ describe('actionsServiceFactory', () => { id: 'my-plugin:test', name: 'testy', title: 'Test', + schema: { + input: {}, + output: {}, + }, }; beforeEach(() => { @@ -89,13 +93,13 @@ describe('actionsServiceFactory', () => { ); }); - describe('listActions', () => { + describe('list', () => { it('should list all plugins in config to find actions and handle failures gracefully', async () => { const subject = await ServiceFactoryTester.from(actionsServiceFactory, { dependencies: defaultServices, }).getSubject(); - const { actions } = await subject.listActions({ + const { actions } = await subject.list({ credentials: mockCredentials.service('user:default/mock'), }); @@ -106,7 +110,7 @@ describe('actionsServiceFactory', () => { }); }); - describe('invokeAction', () => { + describe('invoke', () => { it('should invoke the action and return the output', async () => { server.use( rest.post( @@ -118,7 +122,7 @@ describe('actionsServiceFactory', () => { dependencies: defaultServices, }).getSubject(); - const { output } = await subject.invokeAction({ + const { output } = await subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }); @@ -139,7 +143,7 @@ describe('actionsServiceFactory', () => { }).getSubject(); await expect( - subject.invokeAction({ + subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }), @@ -159,7 +163,7 @@ describe('actionsServiceFactory', () => { }).getSubject(); await expect( - subject.invokeAction({ + subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }), @@ -238,7 +242,7 @@ describe('actionsServiceFactory', () => { innerRouter.post('/invoke', async (req, res) => { const { id, input } = req.body; - const response = await actionsService.invokeAction({ + const response = await actionsService.invoke({ id, input, credentials: await httpAuth.credentials(req), @@ -247,7 +251,7 @@ describe('actionsServiceFactory', () => { }); innerRouter.get('/actions', async (req, res) => { - const response = await actionsService.listActions({ + const response = await actionsService.list({ credentials: await httpAuth.credentials(req), }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index bcc5c23805..a1777cb9b6 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -34,19 +34,15 @@ import { } from '@backstage/errors'; export class DefaultActionsRegistryService implements ActionsRegistryService { + private actions: Map> = + new Map(); + private constructor( - private readonly actions: Map< - string, - ActionsRegistryActionOptions - >, - private readonly router: Router, private readonly logger: LoggerService, private readonly httpAuth: HttpAuthService, private readonly auth: AuthService, private readonly metadata: PluginMetadataService, - ) { - this.bindRoutes(); - } + ) {} static create({ httpAuth, @@ -59,36 +55,14 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { auth: AuthService; metadata: PluginMetadataService; }): DefaultActionsRegistryService { - return new DefaultActionsRegistryService( - new Map(), - PromiseRouter(), - logger, - httpAuth, - auth, - metadata, - ); + return new DefaultActionsRegistryService(logger, httpAuth, auth, metadata); } - getRouter(): Router { - return this.router; - } + createRouter(): Router { + const router = PromiseRouter(); + router.use(json()); - register( - options: ActionsRegistryActionOptions, - ): void { - const id = `${this.metadata.getId()}:${options.name}`; - - if (this.actions.has(id)) { - throw new Error(`Action with id "${id}" is already registered`); - } - - this.actions.set(id, options); - } - - private bindRoutes() { - this.router.use(json()); - - this.router.get('/.backstage/actions/v1/actions', (_, res) => { + router.get('/.backstage/actions/v1/actions', (_, res) => { return res.json({ actions: Array.from(this.actions.entries()).map(([id, action]) => ({ id, @@ -105,7 +79,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { }); }); - this.router.post( + router.post( '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { const action = this.actions.get(req.params.actionId); @@ -165,5 +139,18 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { } }, ); + return router; + } + + register( + options: ActionsRegistryActionOptions, + ): void { + const id = `${this.metadata.getId()}:${options.name}`; + + if (this.actions.has(id)) { + throw new Error(`Action with id "${id}" is already registered`); + } + + this.actions.set(id, options); } } diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 832155f794..5d47ff055d 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -194,7 +194,11 @@ describe('actionsRegistryServiceFactory', () => { name: 'test', title: 'Test', description: 'Test', - action: async () => ({ output: { ok: true } }), + schema: { + input: z => z.undefined(), + output: z => z.string(), + }, + action: async () => ({ output: 'ok' }), }); }, }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index 70bfbf9986..85515a6046 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -40,7 +40,7 @@ export const actionsRegistryServiceFactory = createServiceFactory({ metadata, }); - httpRouter.use(actionsRegistryService.getRouter()); + httpRouter.use(actionsRegistryService.createRouter()); return actionsRegistryService; }, diff --git a/packages/backend-plugin-api/report.api.md b/packages/backend-plugin-api/report.api.md index b8c8534029..68597dcbe1 100644 --- a/packages/backend-plugin-api/report.api.md +++ b/packages/backend-plugin-api/report.api.md @@ -44,16 +44,16 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - schema?: { - input?: (zod: typeof z) => TInputSchema; - output?: (zod: typeof z) => TOutputSchema; + schema: { + input: (zod: typeof z) => TInputSchema; + output: (zod: typeof z) => TOutputSchema; }; action: (context: ActionsRegistryActionContext) => Promise< - TOutputSchema extends ZodType - ? { + z.infer extends void + ? void + : { output: z.infer; } - : void >; }; @@ -68,7 +68,7 @@ export interface ActionsRegistryService { // @public (undocumented) export interface ActionsService { // (undocumented) - invokeAction(opts: { + invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; @@ -76,7 +76,7 @@ export interface ActionsService { output: JsonValue; }>; // (undocumented) - listActions: (opts: { credentials: BackstageCredentials }) => Promise<{ + list: (opts: { credentials: BackstageCredentials }) => Promise<{ actions: ActionsServiceAction[]; }>; } @@ -87,9 +87,9 @@ export type ActionsServiceAction = { name: string; title: string; description: string; - schema?: { - input?: JSONSchema7; - output?: JSONSchema7; + schema: { + input: JSONSchema7; + output: JSONSchema7; }; }; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index ab6539a33e..3044c84ed8 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -36,14 +36,16 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - schema?: { - input?: (zod: typeof z) => TInputSchema; - output?: (zod: typeof z) => TOutputSchema; + schema: { + input: (zod: typeof z) => TInputSchema; + output: (zod: typeof z) => TOutputSchema; }; action: ( context: ActionsRegistryActionContext, ) => Promise< - TOutputSchema extends ZodType ? { output: z.infer } : void + z.infer extends void + ? void + : { output: z.infer } >; }; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index 156513ebc8..69b13afaaa 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -25,9 +25,9 @@ export type ActionsServiceAction = { name: string; title: string; description: string; - schema?: { - input?: JSONSchema7; - output?: JSONSchema7; + schema: { + input: JSONSchema7; + output: JSONSchema7; }; }; @@ -35,10 +35,10 @@ export type ActionsServiceAction = { * @public */ export interface ActionsService { - listActions: (opts: { + list: (opts: { credentials: BackstageCredentials; }) => Promise<{ actions: ActionsServiceAction[] }>; - invokeAction(opts: { + invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; diff --git a/packages/backend-test-utils/report.api.md b/packages/backend-test-utils/report.api.md index 7edcf6e1b7..379a2a70ac 100644 --- a/packages/backend-test-utils/report.api.md +++ b/packages/backend-test-utils/report.api.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ActionsRegistryService } from '@backstage/backend-plugin-api'; +import { ActionsService } from '@backstage/backend-plugin-api'; import { AuditorService } from '@backstage/backend-plugin-api'; import { AuthService } from '@backstage/backend-plugin-api'; import { Backend } from '@backstage/backend-app-api'; @@ -161,6 +163,28 @@ export function mockErrorHandler(): ErrorRequestHandler< // @public export namespace mockServices { + // (undocumented) + export namespace actions { + const // (undocumented) + factory: () => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } + // (undocumented) + export namespace actionsRegistry { + const // (undocumented) + factory: () => ServiceFactory< + ActionsRegistryService, + 'plugin', + 'singleton' + >; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } // (undocumented) export namespace auditor { const // (undocumented) diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index f68f1337b9..0c5b18bd1e 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -53,6 +53,8 @@ import { MockRootLoggerService } from './MockRootLoggerService'; import { MockUserInfoService } from './MockUserInfoService'; import { mockCredentials } from './mockCredentials'; import { MockEventsService } from './MockEventsService'; +import { actionsServiceFactory } from '@backstage/backend-defaults/actions'; +import { actionsRegistryServiceFactory } from '@backstage/backend-defaults/actionsRegistry'; /** @internal */ function createLoggerMock() { @@ -518,6 +520,20 @@ export namespace mockServices { search: jest.fn(), })); } + export namespace actions { + export const factory = () => actionsServiceFactory; + export const mock = simpleMock(coreServices.actions, () => ({ + list: jest.fn(), + invoke: jest.fn(), + })); + } + + export namespace actionsRegistry { + export const factory = () => actionsRegistryServiceFactory; + export const mock = simpleMock(coreServices.actionsRegistry, () => ({ + register: jest.fn(), + })); + } /** * Creates a functional mock implementation of the