diff --git a/.changeset/afraid-trees-stare.md b/.changeset/afraid-trees-stare.md index ac1a53b4e4..d8ee3489c0 100644 --- a/.changeset/afraid-trees-stare.md +++ b/.changeset/afraid-trees-stare.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend': minor --- -zod to json conversion funcitonality for action template schema +Added the ability to be able to define actions input schema using `zod` instead of hand writing types and jsonschema for the input of actions. diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index ffe3312501..65e2111f15 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -24,8 +24,53 @@ passed as `input` to the function. In `packages/backend/src/plugins/scaffolder/actions/custom.ts` we can create a new action. -```ts -import { createTemplateAction } from '@backstage/plugin-scaffolder-backend'; +```ts title="With Zod" +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import fs from 'fs-extra'; +import { z } from 'zod'; + +export const createNewFileAction = () => { + return createTemplateAction({ + id: 'mycompany:create-file', + schema: { + input: z.object({ + contents: z.string().describe('The contents of the file'), + filename: z + .string() + .describe('The filename of the file that will be created'), + }), + }, + + async handler(ctx) { + await fs.outputFile( + `${ctx.workspacePath}/${ctx.input.filename}`, + ctx.input.contents, + ); + }, + }); +}; +``` + +So let's break this down. The `createNewFileAction` is a function that returns a +`createTemplateAction`, and it's a good place to pass in dependencies which +close over the `TemplateAction`. Take a look at our +[built-in actions](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/actions/builtin) +for reference. + +The `createTemplateAction` takes an object which specifies the following: + +- `id` - a unique ID for your custom action. We encourage you to namespace these + in some way so that they won't collide with future built-in actions that we + may ship with the `scaffolder-backend` plugin. +- `schema.input` - A `zod` or JSON schema object for input values to your function +- `schema.output` - A `zod` or JSON schema object for values which are outputted from the + function using `ctx.output` +- `handler` - the actual code which is run part of the action, with a context + +You can also choose to define your custom action using JSON schema instead of `zod`: + +```ts title="With JSON Schema" +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import fs from 'fs-extra'; export const createNewFileAction = () => { @@ -59,27 +104,6 @@ export const createNewFileAction = () => { }; ``` -So let's break this down. The `createNewFileAction` is a function that returns a -`createTemplateAction`, and it's a good place to pass in dependencies which -close over the `TemplateAction`. Take a look at our -[built-in actions](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/actions/builtin) -for reference. - -We set the type generic to `{ contents: string, filename: string }` which is -there to set the type on the handler `ctx` `inputs` property so we get good type -checking. This could be generated from the next part of this guide, the `input` -schema, but it's not supported right now. Feel free to contribute 🚀 👍. - -The `createTemplateAction` takes an object which specifies the following: - -- `id` - a unique ID for your custom action. We encourage you to namespace these - in some way so that they won't collide with future built-in actions that we - may ship with the `scaffolder-backend` plugin. -- `schema.input` - A JSON schema for input values to your function -- `schema.output` - A JSON schema for values which are outputted from the - function using `ctx.output` -- `handler` - the actual code which is run part of the action, with a context - ### The context object When the action `handler` is called, we provide you a `context` as the only @@ -89,10 +113,10 @@ argument. It looks like the following: - `ctx.logger` - a Winston logger for additional logging inside your action - `ctx.logStream` - a stream version of the logger if needed - `ctx.workspacePath` - a string of the working directory of the template run -- `ctx.input` - an object which should match the JSON schema provided in the +- `ctx.input` - an object which should match the `zod` or JSON schema provided in the `schema.input` part of the action definition - `ctx.output` - a function which you can call to set outputs that match the - JSON schema in `schema.output` for ex. `ctx.output('downloadUrl', something)` + JSON schema or `zod` in `schema.output` for ex. `ctx.output('downloadUrl', myDownloadUrl)` - `createTemporaryDirectory` a function to call to give you a temporary directory somewhere on the runner so you can store some files there rather than polluting the `workspacePath` diff --git a/plugins/scaffolder-backend-module-cookiecutter/api-report.md b/plugins/scaffolder-backend-module-cookiecutter/api-report.md index 1b409b2af0..554732104d 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/api-report.md +++ b/plugins/scaffolder-backend-module-cookiecutter/api-report.md @@ -25,6 +25,7 @@ export function createFetchCookiecutterAction(options: { extensions?: string[] | undefined; imageName?: string | undefined; }, + {}, {} >; ``` diff --git a/plugins/scaffolder-backend-module-rails/api-report.md b/plugins/scaffolder-backend-module-rails/api-report.md index fc2e077055..c910d5ae79 100644 --- a/plugins/scaffolder-backend-module-rails/api-report.md +++ b/plugins/scaffolder-backend-module-rails/api-report.md @@ -22,6 +22,7 @@ export function createFetchRailsAction(options: { values: JsonObject; imageName?: string | undefined; }, + {}, {} >; ``` diff --git a/plugins/scaffolder-backend-module-sentry/api-report.md b/plugins/scaffolder-backend-module-sentry/api-report.md index 17e9a71fd5..c0ae268339 100644 --- a/plugins/scaffolder-backend-module-sentry/api-report.md +++ b/plugins/scaffolder-backend-module-sentry/api-report.md @@ -17,6 +17,7 @@ export function createSentryCreateProjectAction(options: { slug?: string | undefined; authToken?: string | undefined; }, + {}, {} >; diff --git a/plugins/scaffolder-backend-module-yeoman/api-report.md b/plugins/scaffolder-backend-module-yeoman/api-report.md index e71dfddd93..7e70ea8580 100644 --- a/plugins/scaffolder-backend-module-yeoman/api-report.md +++ b/plugins/scaffolder-backend-module-yeoman/api-report.md @@ -13,6 +13,7 @@ export function createRunYeomanAction(): TemplateAction< args?: string[] | undefined; options?: JsonObject | undefined; }, + {}, {} >; ``` diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index c344d64094..0169770649 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -71,6 +71,7 @@ export function createCatalogRegisterAction(options: { catalogInfoPath?: string | undefined; optional?: boolean | undefined; }, + {}, {} >; @@ -93,7 +94,8 @@ export function createCatalogWriteAction(): TemplateAction_2< filePath?: string | undefined; entity?: {} | undefined; } - > + >, + {} >; // @public @@ -102,6 +104,7 @@ export function createDebugLogAction(): TemplateAction_2< message?: string | undefined; listWorkspace?: boolean | undefined; }, + {}, {} >; @@ -113,6 +116,7 @@ export function createFetchCatalogEntityAction(options: { entityRef: string; optional?: boolean | undefined; }, + {}, {} >; @@ -125,6 +129,7 @@ export function createFetchPlainAction(options: { url: string; targetPath?: string | undefined; }, + {}, {} >; @@ -145,6 +150,7 @@ export function createFetchTemplateAction(options: { cookiecutterCompat?: boolean | undefined; replace?: boolean | undefined; }, + {}, {} >; @@ -153,6 +159,7 @@ export const createFilesystemDeleteAction: () => TemplateAction_2< { files: string[]; }, + {}, {} >; @@ -165,6 +172,7 @@ export const createFilesystemRenameAction: () => TemplateAction_2< overwrite?: boolean; }>; }, + {}, {} >; @@ -184,6 +192,7 @@ export function createGithubActionsDispatchAction(options: { | undefined; token?: string | undefined; }, + {}, {} >; @@ -198,6 +207,7 @@ export function createGithubIssuesLabelAction(options: { labels: string[]; token?: string | undefined; }, + {}, {} >; @@ -286,6 +296,7 @@ export function createGithubRepoCreateAction(options: { topics?: string[] | undefined; requireCommitSigning?: boolean | undefined; }, + {}, {} >; @@ -328,6 +339,7 @@ export function createGithubRepoPushAction(options: { token?: string | undefined; requiredCommitSigning?: boolean | undefined; }, + {}, {} >; @@ -347,6 +359,7 @@ export function createGithubWebhookAction(options: { insecureSsl?: boolean | undefined; token?: string | undefined; }, + {}, {} >; @@ -365,6 +378,7 @@ export function createPublishAzureAction(options: { gitAuthorName?: string | undefined; gitAuthorEmail?: string | undefined; }, + {}, {} >; @@ -385,6 +399,7 @@ export function createPublishBitbucketAction(options: { gitAuthorName?: string | undefined; gitAuthorEmail?: string | undefined; }, + {}, {} >; @@ -401,6 +416,7 @@ export function createPublishBitbucketCloudAction(options: { sourcePath?: string | undefined; token?: string | undefined; }, + {}, {} >; @@ -421,6 +437,7 @@ export function createPublishBitbucketServerAction(options: { gitAuthorName?: string | undefined; gitAuthorEmail?: string | undefined; }, + {}, {} >; @@ -438,6 +455,7 @@ export function createPublishGerritAction(options: { gitAuthorEmail?: string | undefined; sourcePath?: string | undefined; }, + {}, {} >; @@ -454,6 +472,7 @@ export function createPublishGerritReviewAction(options: { gitAuthorName?: string | undefined; gitAuthorEmail?: string | undefined; }, + {}, {} >; @@ -530,6 +549,7 @@ export function createPublishGithubAction(options: { topics?: string[] | undefined; requiredCommitSigning?: boolean | undefined; }, + {}, {} >; @@ -551,6 +571,7 @@ export const createPublishGithubPullRequestAction: ({ reviewers?: string[] | undefined; teamReviewers?: string[] | undefined; }, + {}, {} >; @@ -571,6 +592,7 @@ export function createPublishGitlabAction(options: { setUserAsOwner?: boolean | undefined; topics?: string[] | undefined; }, + {}, {} >; @@ -591,6 +613,7 @@ export const createPublishGitlabMergeRequestAction: (options: { removeSourceBranch?: boolean | undefined; assignee?: string | undefined; }, + {}, {} >; @@ -601,9 +624,10 @@ export function createRouter(options: RouterOptions): Promise; export const createTemplateAction: < TParams, TInputSchema extends ZodType | Schema = {}, + TOutputSchema extends ZodType | Schema = {}, >( - templateAction: TemplateAction_2, -) => TemplateAction_2; + templateAction: TemplateAction_2, +) => TemplateAction_2; // @public export type CreateWorkerOptions = { @@ -950,11 +974,11 @@ export type TemplateAction = // @public export class TemplateActionRegistry { // (undocumented) - get(actionId: string): TemplateAction_2; + get(actionId: string): TemplateAction_2; // (undocumented) - list(): TemplateAction_2[]; + list(): TemplateAction_2[]; // (undocumented) - register(action: TemplateAction_2): void; + register(action: TemplateAction_2): void; } // @public (undocumented) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts index 90e32ba6ee..3964c4b1f2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { JsonObject } from '@backstage/types'; import { ConflictError, NotFoundError } from '@backstage/errors'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import zodToJsonSchema from 'zod-to-json-schema'; @@ -23,9 +22,9 @@ import zodToJsonSchema from 'zod-to-json-schema'; * @public */ export class TemplateActionRegistry { - private readonly actions = new Map>(); + private readonly actions = new Map(); - register(action: TemplateAction) { + register(action: TemplateAction) { if (this.actions.has(action.id)) { throw new ConflictError( `Template action with ID '${action.id}' has already been registered`, @@ -35,21 +34,29 @@ export class TemplateActionRegistry { // It's better to convert the zod here, and just deal with jsonschema everywhere // rather than adding the zod check everywhere like the nunjucks engine, and the /actions/list // endpoint to create jsonschema for the frontend. - const templateAction = + const inputSchema = action.schema?.input && 'safeParseAsync' in action.schema.input - ? { - ...action, - schema: { - ...action.schema, - input: zodToJsonSchema(action.schema.input), - }, - } - : action; + ? zodToJsonSchema(action.schema.input) + : action.schema?.input; + + const outputSchema = + action.schema?.output && 'safeParseAsync' in action.schema.output + ? zodToJsonSchema(action.schema.output) + : action.schema?.output; + + const templateAction = { + ...action, + schema: { + ...action.schema, + input: inputSchema, + output: outputSchema, + }, + }; this.actions.set(action.id, templateAction); } - get(actionId: string): TemplateAction { + get(actionId: string): TemplateAction { const action = this.actions.get(actionId); if (!action) { throw new NotFoundError( @@ -59,7 +66,7 @@ export class TemplateActionRegistry { return action; } - list(): TemplateAction[] { + list(): TemplateAction[] { return [...this.actions.values()]; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 541531d159..516abe79e4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -25,7 +25,6 @@ import { InputError } from '@backstage/errors'; import { PassThrough } from 'stream'; import { generateExampleOutput, isTruthy } from './helper'; import { validate as validateJsonSchema } from 'jsonschema'; -import zodToJsonSchema from 'zod-to-json-schema'; import { parseRepoUrl } from '../actions/builtin/publish/util'; import { TemplateActionRegistry } from '../actions'; import { diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md index 977445f765..7e949030b6 100644 --- a/plugins/scaffolder-node/api-report.md +++ b/plugins/scaffolder-node/api-report.md @@ -36,9 +36,10 @@ export type ActionContext = { export const createTemplateAction: < TParams, TInputSchema extends z.ZodType | Schema = {}, + TOutputSchema extends z.ZodType | Schema = {}, >( - templateAction: TemplateAction, -) => TemplateAction; + templateAction: TemplateAction, +) => TemplateAction; // @alpha export interface ScaffolderActionsExtensionPoint { @@ -56,8 +57,9 @@ export type TaskSecrets = Record & { // @public (undocumented) export type TemplateAction< - TParams, + TParams = {}, TInputSchema extends Schema | z.ZodType = {}, + TOutputSchema extends Schema | z.ZodType = {}, > = { id: string; description?: string; @@ -68,7 +70,7 @@ export type TemplateAction< supportsDryRun?: boolean; schema?: { input?: TInputSchema; - output?: Schema; + output?: TOutputSchema; }; handler: ( ctx: ActionContext< diff --git a/plugins/scaffolder-node/src/actions/createTemplateAction.ts b/plugins/scaffolder-node/src/actions/createTemplateAction.ts index 2228ddb904..11c01a6086 100644 --- a/plugins/scaffolder-node/src/actions/createTemplateAction.ts +++ b/plugins/scaffolder-node/src/actions/createTemplateAction.ts @@ -17,7 +17,6 @@ import { TemplateAction } from './types'; import { z } from 'zod'; import { Schema } from 'jsonschema'; - /** * This function is used to create new template actions to get type safety. * @@ -26,9 +25,9 @@ import { Schema } from 'jsonschema'; export const createTemplateAction = < TParams, TInputSchema extends Schema | z.ZodType = {}, + TOutputSchema extends Schema | z.ZodType = {}, >( - templateAction: TemplateAction, -): TemplateAction => { - // TODO(blam): Can add some more validation here to validate the action later on + templateAction: TemplateAction, +): TemplateAction => { return templateAction; }; diff --git a/plugins/scaffolder-node/src/actions/types.ts b/plugins/scaffolder-node/src/actions/types.ts index e1d5096d03..2adad00bba 100644 --- a/plugins/scaffolder-node/src/actions/types.ts +++ b/plugins/scaffolder-node/src/actions/types.ts @@ -64,8 +64,9 @@ export type ActionContext = { /** @public */ export type TemplateAction< - TParams, + TParams = {}, TInputSchema extends Schema | z.ZodType = {}, + TOutputSchema extends Schema | z.ZodType = {}, > = { id: string; description?: string; @@ -73,7 +74,7 @@ export type TemplateAction< supportsDryRun?: boolean; schema?: { input?: TInputSchema; - output?: Schema; + output?: TOutputSchema; }; handler: ( ctx: ActionContext<