feat(actionsRegistry): Adding support for examples (#33551)

* feat(backend-plugin-api): add typed examples to actions registry

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

* fix: address review feedback for actions registry examples

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

* fix: remove empty examples from scaffolder action bridge

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

* chore: add changeset for scaffolder-backend

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

* fix: update router test to match removed examples field

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

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-03-24 18:16:23 +01:00
committed by GitHub
parent f29d18c2fd
commit 4559806b96
13 changed files with 201 additions and 2 deletions
@@ -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.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Removed unnecessary empty `examples` array from actions bridged via the actions registry.
@@ -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))
@@ -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',
@@ -20,6 +20,17 @@ export type ActionsRegistryActionContext<TInputSchema extends AnyZodObject> = {
credentials: BackstageCredentials;
};
// @alpha
export type ActionsRegistryActionExample<
TInputSchema extends AnyZodObject,
TOutputSchema extends AnyZodObject,
> = {
title: string;
description?: string;
input: z.infer<TInputSchema>;
output?: z.infer<TOutputSchema>;
};
// @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<ActionsRegistryActionExample<TInputSchema, TOutputSchema>>;
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;
@@ -29,6 +29,21 @@ export type ActionsRegistryActionContext<TInputSchema extends AnyZodObject> = {
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<TInputSchema>;
output?: z.infer<TOutputSchema>;
};
/**
* @alpha
*/
@@ -43,6 +58,7 @@ export type ActionsRegistryActionOptions<
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
examples?: Array<ActionsRegistryActionExample<TInputSchema, TOutputSchema>>;
visibilityPermission?: BasicPermission;
attributes?: {
destructive?: boolean;
@@ -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;
@@ -23,6 +23,7 @@ export type {
ActionsRegistryService,
ActionsRegistryActionOptions,
ActionsRegistryActionContext,
ActionsRegistryActionExample,
} from './ActionsRegistryService';
export type { ActionsService, ActionsServiceAction } from './ActionsService';
@@ -27,6 +27,21 @@ export type ActionsRegistryActionContext<TInputSchema extends AnyZodObject> = {
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<TInputSchema>;
output?: z.infer<TOutputSchema>;
};
/**
* @public
*/
@@ -41,6 +56,7 @@ export type ActionsRegistryActionOptions<
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
examples?: Array<ActionsRegistryActionExample<TInputSchema, TOutputSchema>>;
visibilityPermission?: BasicPermission;
attributes?: {
destructive?: boolean;
@@ -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;
@@ -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))
@@ -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,
@@ -281,7 +281,6 @@ describe('scaffolder router', () => {
expect(response.body).toContainEqual({
description: 'Test',
examples: [],
id: 'test:my-demo-action',
schema: {
input: {