From 4559806b96357e214a5323e0760348a61c1e788c Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Tue, 24 Mar 2026 18:16:23 +0100 Subject: [PATCH] `feat(actionsRegistry)`: Adding support for examples (#33551) * feat(backend-plugin-api): add typed examples to actions registry Signed-off-by: benjdlambert * fix: address review feedback for actions registry examples Signed-off-by: benjdlambert * fix: remove empty examples from scaffolder action bridge Signed-off-by: benjdlambert * chore: add changeset for scaffolder-backend Signed-off-by: benjdlambert * fix: update router test to match removed examples field Signed-off-by: benjdlambert --------- Signed-off-by: benjdlambert --- .changeset/add-actions-registry-examples.md | 7 + ...remove-empty-examples-scaffolder-bridge.md | 5 + .../DefaultActionsRegistryService.ts | 1 + .../actionsRegistryServiceFactory.test.ts | 124 ++++++++++++++++++ .../backend-plugin-api/report-alpha.api.md | 18 +++ .../src/alpha/ActionsRegistryService.ts | 16 +++ .../src/alpha/ActionsService.ts | 6 + .../backend-plugin-api/src/alpha/index.ts | 1 + .../definitions/ActionsRegistryService.ts | 16 +++ .../services/definitions/ActionsService.ts | 6 + .../src/alpha/services/MockActionsRegistry.ts | 1 + .../actions/TemplateActionRegistry.ts | 1 - .../src/service/router.test.ts | 1 - 13 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-actions-registry-examples.md create mode 100644 .changeset/remove-empty-examples-scaffolder-bridge.md diff --git a/.changeset/add-actions-registry-examples.md b/.changeset/add-actions-registry-examples.md new file mode 100644 index 0000000000..5174bcc68d --- /dev/null +++ b/.changeset/add-actions-registry-examples.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-plugin-api': minor +'@backstage/backend-defaults': patch +'@backstage/backend-test-utils': patch +--- + +Added support for typed `examples` on actions registered via the actions registry. Action authors can now provide examples with compile-time-checked `input` and `output` values that match their schema definitions. diff --git a/.changeset/remove-empty-examples-scaffolder-bridge.md b/.changeset/remove-empty-examples-scaffolder-bridge.md new file mode 100644 index 0000000000..1eafeaebb9 --- /dev/null +++ b/.changeset/remove-empty-examples-scaffolder-bridge.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Removed unnecessary empty `examples` array from actions bridged via the actions registry. diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 01255db10c..1f6f108589 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -115,6 +115,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { idempotent: action.attributes?.idempotent ?? false, readOnly: action.attributes?.readOnly ?? false, }, + examples: action.examples, schema: { input: action.schema?.input ? zodToJsonSchema(action.schema.input(z)) diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 6311976a41..39392c101c 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -111,6 +111,57 @@ describe('actionsRegistryServiceFactory', () => { expect(true).toBe(true); }); + + it('should enforce types on example input and output', () => { + createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: actionsRegistryServiceRef, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + examples: [ + { + title: 'Valid example', + input: { name: 'test' }, + output: { ok: true }, + }, + { + title: 'Bad input', + // @ts-expect-error - name must be a string + input: { name: 123 }, + }, + { + title: 'Bad output', + input: { name: 'test' }, + // @ts-expect-error - ok must be a boolean + output: { ok: 'yes' }, + }, + ], + action: async () => ({ output: { ok: true } }), + }); + }, + }); + }, + }); + + expect(true).toBe(true); + }); }); describe('/.backstage/actions/v1/actions', () => { @@ -286,6 +337,79 @@ describe('actionsRegistryServiceFactory', () => { }); }); + it('should return examples in the action list', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: actionsRegistryServiceRef, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + examples: [ + { + title: 'Basic usage', + description: 'A simple example', + input: { name: 'world' }, + output: { ok: true }, + }, + { + title: 'Without output', + input: { name: 'test' }, + }, + ], + action: async () => ({ output: { ok: true } }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/actions/v1/actions', + ); + + expect(status).toBe(200); + + expect(body).toMatchObject({ + actions: [ + { + name: 'test', + examples: [ + { + title: 'Basic usage', + description: 'A simple example', + input: { name: 'world' }, + output: { ok: true }, + }, + { + title: 'Without output', + input: { name: 'test' }, + }, + ], + }, + ], + }); + }); + it('should forces registration of input and output schema as objects', async () => { const pluginSubject = createBackendPlugin({ pluginId: 'my-plugin', diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 72d9a2561e..d1db9c7eeb 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -20,6 +20,17 @@ export type ActionsRegistryActionContext = { credentials: BackstageCredentials; }; +// @alpha +export type ActionsRegistryActionExample< + TInputSchema extends AnyZodObject, + TOutputSchema extends AnyZodObject, +> = { + title: string; + description?: string; + input: z.infer; + output?: z.infer; +}; + // @alpha (undocumented) export type ActionsRegistryActionOptions< TInputSchema extends AnyZodObject, @@ -32,6 +43,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; + examples?: Array>; visibilityPermission?: BasicPermission; attributes?: { destructive?: boolean; @@ -92,6 +104,12 @@ export type ActionsServiceAction = { input: JSONSchema7; output: JSONSchema7; }; + examples?: Array<{ + title: string; + description?: string; + input: JsonObject; + output?: JsonObject; + }>; attributes: { readOnly: boolean; destructive: boolean; diff --git a/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts b/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts index 20b4d769cc..65f44be051 100644 --- a/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/alpha/ActionsRegistryService.ts @@ -29,6 +29,21 @@ export type ActionsRegistryActionContext = { credentials: BackstageCredentials; }; +/** + * An example of how to use an action registered in the actions registry. + * + * @alpha + */ +export type ActionsRegistryActionExample< + TInputSchema extends AnyZodObject, + TOutputSchema extends AnyZodObject, +> = { + title: string; + description?: string; + input: z.infer; + output?: z.infer; +}; + /** * @alpha */ @@ -43,6 +58,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; + examples?: Array>; visibilityPermission?: BasicPermission; attributes?: { destructive?: boolean; diff --git a/packages/backend-plugin-api/src/alpha/ActionsService.ts b/packages/backend-plugin-api/src/alpha/ActionsService.ts index 6e432e962f..7062de9c77 100644 --- a/packages/backend-plugin-api/src/alpha/ActionsService.ts +++ b/packages/backend-plugin-api/src/alpha/ActionsService.ts @@ -30,6 +30,12 @@ export type ActionsServiceAction = { input: JSONSchema7; output: JSONSchema7; }; + examples?: Array<{ + title: string; + description?: string; + input: JsonObject; + output?: JsonObject; + }>; attributes: { readOnly: boolean; destructive: boolean; diff --git a/packages/backend-plugin-api/src/alpha/index.ts b/packages/backend-plugin-api/src/alpha/index.ts index c487686338..9ec7ca5b09 100644 --- a/packages/backend-plugin-api/src/alpha/index.ts +++ b/packages/backend-plugin-api/src/alpha/index.ts @@ -23,6 +23,7 @@ export type { ActionsRegistryService, ActionsRegistryActionOptions, ActionsRegistryActionContext, + ActionsRegistryActionExample, } from './ActionsRegistryService'; export type { ActionsService, ActionsServiceAction } from './ActionsService'; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index 05f6879f07..39c62ce731 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -27,6 +27,21 @@ export type ActionsRegistryActionContext = { credentials: BackstageCredentials; }; +/** + * An example of how to use an action registered in the actions registry. + * + * @public + */ +export type ActionsRegistryActionExample< + TInputSchema extends AnyZodObject, + TOutputSchema extends AnyZodObject, +> = { + title: string; + description?: string; + input: z.infer; + output?: z.infer; +}; + /** * @public */ @@ -41,6 +56,7 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; + examples?: Array>; visibilityPermission?: BasicPermission; attributes?: { destructive?: boolean; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index d595008a42..3afa131915 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -29,6 +29,12 @@ export type ActionsServiceAction = { input: JSONSchema7; output: JSONSchema7; }; + examples?: Array<{ + title: string; + description?: string; + input: JsonObject; + output?: JsonObject; + }>; attributes: { readOnly: boolean; destructive: boolean; diff --git a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts index 03edb42fad..d3d94d8d9c 100644 --- a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts +++ b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts @@ -91,6 +91,7 @@ export class MockActionsRegistry idempotent: action.attributes?.idempotent ?? false, readOnly: action.attributes?.readOnly ?? false, }, + examples: action.examples, schema: { input: action.schema?.input ? zodToJsonSchema(action.schema.input(z)) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts index dab1447eee..90ee05cc17 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts @@ -93,7 +93,6 @@ export class DefaultTemplateActionRegistry implements TemplateActionRegistry { ret.set(action.id, { id: action.id, description: action.description, - examples: [], supportsDryRun: action.attributes?.readOnly === true && action.attributes?.destructive === false, diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 9da276c67b..e778645983 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -281,7 +281,6 @@ describe('scaffolder router', () => { expect(response.body).toContainEqual({ description: 'Test', - examples: [], id: 'test:my-demo-action', schema: { input: {