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:
@@ -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.
|
||||
+1
@@ -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))
|
||||
|
||||
+124
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user