diff --git a/.changeset/dull-olives-itch.md b/.changeset/dull-olives-itch.md new file mode 100644 index 0000000000..679ed584d7 --- /dev/null +++ b/.changeset/dull-olives-itch.md @@ -0,0 +1,72 @@ +--- +'@backstage/plugin-scaffolder-node': minor +--- + +**DEPRECATION**: We've deprecated the old way of defining actions using `createTemplateAction` with raw `JSONSchema` and type parameters, as well as using `zod` through an import. You can now use the new format to define `createTemplateActions` with `zod` provided by the framework. This change also removes support for `logStream` in the `context` as well as moving the `logger` to an instance of `LoggerService`. + +Before: + +```ts +createTemplateAction<{ repoUrl: string }, { test: string }>({ + id: 'test', + schema: { + input: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { type: 'string' }, + }, + }, + output: { + type: 'object', + required: ['test'], + properties: { + test: { type: 'string' }, + }, + }, + }, + handler: async ctx => { + ctx.logStream.write('blob'); + }, +}); + +// or + +createTemplateAction({ + id: 'test', + schema: { + input: z.object({ + repoUrl: z.string(), + }), + output: z.object({ + test: z.string(), + }), + }, + handler: async ctx => { + ctx.logStream.write('something'); + }, +}); +``` + +After: + +```ts +createTemplateAction({ + id: 'test', + schema: { + input: { + repoUrl: d => d.string(), + }, + output: { + test: d => d.string(), + }, + }, + handler: async ctx => { + // you can just use ctx.logger.log('...'), or if you really need a log stream you can do this: + const logStream = new PassThrough(); + logStream.on('data', chunk => { + ctx.logger.info(chunk.toString()); + }); + }, +}); +``` diff --git a/.changeset/gorgeous-keys-shop.md b/.changeset/gorgeous-keys-shop.md new file mode 100644 index 0000000000..f319f0bdbf --- /dev/null +++ b/.changeset/gorgeous-keys-shop.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node-test-utils': minor +--- + +Use update `createTemplateAction` kinds diff --git a/.changeset/olive-dragons-guess.md b/.changeset/olive-dragons-guess.md new file mode 100644 index 0000000000..c1e3fa6286 --- /dev/null +++ b/.changeset/olive-dragons-guess.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Support new `createTemplateAction` type, and convert `catalog:fetch` action to new way of defining actions. diff --git a/.changeset/slimy-rockets-jog.md b/.changeset/slimy-rockets-jog.md new file mode 100644 index 0000000000..09cdae53d0 --- /dev/null +++ b/.changeset/slimy-rockets-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch +--- + +Fixing spelling mistake in `jsonschema` diff --git a/plugins/scaffolder-backend-module-azure/report.api.md b/plugins/scaffolder-backend-module-azure/report.api.md index 9e7592e145..159bf02a00 100644 --- a/plugins/scaffolder-backend-module-azure/report.api.md +++ b/plugins/scaffolder-backend-module-azure/report.api.md @@ -29,6 +29,7 @@ export function createPublishAzureAction(options: { gitAuthorEmail?: string; signCommit?: boolean; }, - JsonObject + JsonObject, + 'v1' >; ``` diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md index 428e21c027..8deda92860 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md @@ -23,7 +23,12 @@ export const createBitbucketPipelinesRunAction: (options: { body?: object; token?: string; }, - JsonObject + { + buildNumber: number; + repoUrl: string; + pipelinesUrl: string; + }, + 'v1' >; // @public @@ -41,7 +46,8 @@ export function createPublishBitbucketCloudAction(options: { token?: string; signCommit?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -59,6 +65,7 @@ export function createPublishBitbucketCloudPullRequestAction(options: { gitAuthorName?: string; gitAuthorEmail?: string; }, - JsonObject + JsonObject, + 'v1' >; ``` diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts index edabe2a7ba..83d959e67f 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts @@ -65,9 +65,11 @@ describe('bitbucket:pipelines:run', () => { const actionNoCreds = createBitbucketPipelinesRunAction({ integrations: integrationsNoCreds, }); + const testContext = Object.assign({}, mockContext, { input: { workspace, repo_slug }, }); + await expect(actionNoCreds.handler(testContext)).rejects.toThrow( /Authorization has not been provided for Bitbucket Cloud/, ); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts index 8525774692..11064f4743 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts @@ -30,12 +30,19 @@ export const createBitbucketPipelinesRunAction = (options: { integrations: ScmIntegrationRegistry; }) => { const { integrations } = options; - return createTemplateAction<{ - workspace: string; - repo_slug: string; - body?: object; - token?: string; - }>({ + return createTemplateAction< + { + workspace: string; + repo_slug: string; + body?: object; + token?: string; + }, + { + buildNumber: number; + repoUrl: string; + pipelinesUrl: string; + } + >({ id, description: 'Run a bitbucket cloud pipeline', examples, @@ -58,7 +65,7 @@ export const createBitbucketPipelinesRunAction = (options: { type: 'number', }, repoUrl: { - title: 'A URL to the pipeline repositry', + title: 'A URL to the pipeline repository', type: 'string', }, repoContentsUrl: { diff --git a/plugins/scaffolder-backend-module-bitbucket-server/report.api.md b/plugins/scaffolder-backend-module-bitbucket-server/report.api.md index 21eee65d1c..2f4d68c95d 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-server/report.api.md @@ -31,7 +31,8 @@ export function createPublishBitbucketServerAction(options: { gitAuthorEmail?: string; signCommit?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -50,6 +51,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { gitAuthorName?: string; gitAuthorEmail?: string; }, - JsonObject + JsonObject, + 'v1' >; ``` diff --git a/plugins/scaffolder-backend-module-bitbucket/report.api.md b/plugins/scaffolder-backend-module-bitbucket/report.api.md index 0b19806b51..dd45ae5c01 100644 --- a/plugins/scaffolder-backend-module-bitbucket/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket/report.api.md @@ -25,7 +25,12 @@ export const createBitbucketPipelinesRunAction: (options: { body?: object; token?: string; }, - JsonObject + { + buildNumber: number; + repoUrl: string; + pipelinesUrl: string; + }, + 'v1' >; // @public @deprecated @@ -46,7 +51,8 @@ export function createPublishBitbucketAction(options: { gitAuthorEmail?: string; signCommit?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @deprecated (undocumented) diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/report.api.md b/plugins/scaffolder-backend-module-confluence-to-markdown/report.api.md index 2dd5e244dd..64584ad4b5 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/report.api.md +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/report.api.md @@ -24,6 +24,7 @@ export const createConfluenceToMarkdownAction: (options: { confluenceUrls: string[]; repoUrl: string; }, - JsonObject + JsonObject, + 'v1' >; ``` diff --git a/plugins/scaffolder-backend-module-cookiecutter/report.api.md b/plugins/scaffolder-backend-module-cookiecutter/report.api.md index 36339ea722..4f963ce818 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/report.api.md +++ b/plugins/scaffolder-backend-module-cookiecutter/report.api.md @@ -54,6 +54,7 @@ export function createFetchCookiecutterAction(options: { extensions?: string[]; imageName?: string; }, - JsonObject + JsonObject, + 'v1' >; ``` diff --git a/plugins/scaffolder-backend-module-gerrit/report.api.md b/plugins/scaffolder-backend-module-gerrit/report.api.md index b5af8f0acf..937df6f435 100644 --- a/plugins/scaffolder-backend-module-gerrit/report.api.md +++ b/plugins/scaffolder-backend-module-gerrit/report.api.md @@ -24,7 +24,8 @@ export function createPublishGerritAction(options: { sourcePath?: string; signCommit?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -41,7 +42,8 @@ export function createPublishGerritReviewAction(options: { gitAuthorEmail?: string; signCommit?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public diff --git a/plugins/scaffolder-backend-module-gitea/report.api.md b/plugins/scaffolder-backend-module-gitea/report.api.md index 9a057dfb45..dede7f013a 100644 --- a/plugins/scaffolder-backend-module-gitea/report.api.md +++ b/plugins/scaffolder-backend-module-gitea/report.api.md @@ -25,7 +25,8 @@ export function createPublishGiteaAction(options: { sourcePath?: string; signCommit?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index e2d124eb6f..f1b7f98a30 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -30,7 +30,8 @@ export function createGithubActionsDispatchAction(options: { }; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -45,7 +46,8 @@ export function createGithubAutolinksAction(options: { isAlphanumeric?: boolean; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -81,7 +83,8 @@ export function createGithubBranchProtectionAction(options: { requiredLinearHistory?: boolean; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -96,7 +99,8 @@ export function createGithubDeployKeyAction(options: { privateKeySecretName?: string; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -125,7 +129,8 @@ export function createGithubEnvironmentAction(options: { preventSelfReview?: boolean; reviewers?: string[]; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -139,7 +144,8 @@ export function createGithubIssuesLabelAction(options: { labels: string[]; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -154,7 +160,8 @@ export function createGithubPagesEnableAction(options: { sourcePath?: '/' | '/docs'; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -255,7 +262,8 @@ export function createGithubRepoCreateAction(options: { }; subscribe?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -299,7 +307,8 @@ export function createGithubRepoPushAction(options: { requiredLinearHistory?: boolean; requireLastPushApproval?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -318,7 +327,8 @@ export function createGithubWebhookAction(options: { insecureSsl?: boolean; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -404,7 +414,8 @@ export function createPublishGithubAction(options: { }; subscribe?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -431,7 +442,8 @@ export const createPublishGithubPullRequestAction: ( forceEmptyGitAuthor?: boolean; createWhenEmpty?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 1ba70a7ba6..cdb6065a3b 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -26,7 +26,8 @@ export const createGitlabGroupEnsureExistsAction: (options: { }, { groupId?: number | undefined; - } + }, + 'v1' >; // @public @@ -55,7 +56,8 @@ export const createGitlabIssueAction: (options: { issueUrl: string; issueId: number; issueIid: number; - } + }, + 'v1' >; // @public @@ -73,7 +75,8 @@ export const createGitlabProjectAccessTokenAction: (options: { }, { access_token: string; - } + }, + 'v1' >; // @public @@ -91,7 +94,8 @@ export const createGitlabProjectDeployTokenAction: (options: { { user: string; deploy_token: string; - } + }, + 'v1' >; // @public @@ -110,7 +114,8 @@ export const createGitlabProjectVariableAction: (options: { environmentScope?: string | undefined; variableProtected?: boolean | undefined; }, - JsonObject + any, + 'v1' >; // @public @@ -126,7 +131,8 @@ export const createGitlabRepoPushAction: (options: { token?: string; commitAction?: 'create' | 'delete' | 'update'; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -177,7 +183,8 @@ export function createPublishGitlabAction(options: { environment_scope?: string; }>; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -200,7 +207,8 @@ export const createPublishGitlabMergeRequestAction: (options: { reviewers?: string[]; assignReviewersFromApprovalRules?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -217,7 +225,8 @@ export const createTriggerGitlabPipelineAction: (options: { }, { pipelineUrl: string; - } + }, + 'v1' >; // @public @@ -253,7 +262,8 @@ export const editGitlabIssueAction: (options: { issueUrl: string; issueId: number; issueIid: number; - } + }, + 'v1' >; // @public diff --git a/plugins/scaffolder-backend-module-notifications/report.api.md b/plugins/scaffolder-backend-module-notifications/report.api.md index 1c2bcf5f64..cb796e5033 100644 --- a/plugins/scaffolder-backend-module-notifications/report.api.md +++ b/plugins/scaffolder-backend-module-notifications/report.api.md @@ -23,7 +23,8 @@ export function createSendNotificationAction(options: { scope?: string; optional?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public diff --git a/plugins/scaffolder-backend-module-rails/report.api.md b/plugins/scaffolder-backend-module-rails/report.api.md index 846e2fe77f..0d7618852d 100644 --- a/plugins/scaffolder-backend-module-rails/report.api.md +++ b/plugins/scaffolder-backend-module-rails/report.api.md @@ -49,7 +49,8 @@ export function createFetchRailsAction(options: { values: JsonObject; imageName?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public diff --git a/plugins/scaffolder-backend-module-sentry/report.api.md b/plugins/scaffolder-backend-module-sentry/report.api.md index ff31bd62c3..973ef9844c 100644 --- a/plugins/scaffolder-backend-module-sentry/report.api.md +++ b/plugins/scaffolder-backend-module-sentry/report.api.md @@ -19,7 +19,8 @@ export function createSentryCreateProjectAction(options: { slug?: string; authToken?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public diff --git a/plugins/scaffolder-backend-module-yeoman/report.api.md b/plugins/scaffolder-backend-module-yeoman/report.api.md index ac262abcf6..57498bbe3a 100644 --- a/plugins/scaffolder-backend-module-yeoman/report.api.md +++ b/plugins/scaffolder-backend-module-yeoman/report.api.md @@ -14,7 +14,8 @@ export function createRunYeomanAction(): TemplateAction< args?: string[]; options?: JsonObject; }, - JsonObject + JsonObject, + 'v1' >; // @public diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 9e5fae8e3c..e1d7627326 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -97,7 +97,7 @@ "globby": "^11.0.0", "isbinaryfile": "^5.0.0", "isolated-vm": "^5.0.1", - "jsonschema": "^1.2.6", + "jsonschema": "^1.5.0", "knex": "^3.0.0", "lodash": "^4.17.21", "logform": "^2.3.2", diff --git a/plugins/scaffolder-backend/report.api.md b/plugins/scaffolder-backend/report.api.md index 18813154f2..37afe780a9 100644 --- a/plugins/scaffolder-backend/report.api.md +++ b/plugins/scaffolder-backend/report.api.md @@ -30,6 +30,7 @@ import { createPublishGerritAction as createPublishGerritAction_2 } from '@backs import { createPublishGerritReviewAction as createPublishGerritReviewAction_2 } from '@backstage/plugin-scaffolder-backend-module-gerrit'; import { createPublishGithubAction as createPublishGithubAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; import { createPublishGitlabAction as createPublishGitlabAction_2 } from '@backstage/plugin-scaffolder-backend-module-gitlab'; +import { createTemplateAction as createTemplateAction_2 } from '@backstage/plugin-scaffolder-node'; import { DatabaseService } from '@backstage/backend-plugin-api'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { Duration } from 'luxon'; @@ -54,7 +55,6 @@ import { RESOURCE_TYPE_SCAFFOLDER_ACTION } from '@backstage/plugin-scaffolder-co import { RESOURCE_TYPE_SCAFFOLDER_TEMPLATE } from '@backstage/plugin-scaffolder-common/alpha'; import { ScaffolderEntitiesProcessor as ScaffolderEntitiesProcessor_2 } from '@backstage/plugin-catalog-backend-module-scaffolder-entity-model'; import { SchedulerService } from '@backstage/backend-plugin-api'; -import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; import { SerializedTask as SerializedTask_2 } from '@backstage/plugin-scaffolder-node'; @@ -71,14 +71,12 @@ import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { TaskSpecV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TaskStatus as TaskStatus_2 } from '@backstage/plugin-scaffolder-node'; import { TemplateAction as TemplateAction_2 } from '@backstage/plugin-scaffolder-node'; -import { TemplateActionOptions } from '@backstage/plugin-scaffolder-node'; import { TemplateEntityStepV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateFilter as TemplateFilter_2 } from '@backstage/plugin-scaffolder-node'; import { TemplateGlobal as TemplateGlobal_2 } from '@backstage/plugin-scaffolder-node'; import { TemplateParametersV1beta3 } from '@backstage/plugin-scaffolder-common'; import { UrlReaderService } from '@backstage/backend-plugin-api'; import { WorkspaceProvider } from '@backstage/plugin-scaffolder-node/alpha'; -import { ZodType } from 'zod'; // @public @deprecated (undocumented) export type ActionContext = ActionContext_2; @@ -125,7 +123,8 @@ export function createCatalogRegisterAction(options: { catalogInfoPath?: string; optional?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -134,17 +133,12 @@ export function createCatalogWriteAction(): TemplateAction_2< entity: Record; filePath?: string | undefined; }, - JsonObject + any, + 'v1' >; // @public -export function createDebugLogAction(): TemplateAction_2< - { - message?: string; - listWorkspace?: boolean | 'with-filenames' | 'with-contents'; - }, - JsonObject ->; +export function createDebugLogAction(): TemplateAction_2; // @public export function createFetchCatalogEntityAction(options: { @@ -152,16 +146,17 @@ export function createFetchCatalogEntityAction(options: { auth?: AuthService; }): TemplateAction_2< { + entityRef?: string | undefined; + entityRefs?: string[] | undefined; optional?: boolean | undefined; defaultKind?: string | undefined; defaultNamespace?: string | undefined; - entityRef?: string | undefined; - entityRefs?: string[] | undefined; }, { - entities?: any[] | undefined; entity?: any; - } + entities?: any[] | undefined; + }, + 'v2' >; // @public @@ -174,7 +169,8 @@ export function createFetchPlainAction(options: { targetPath?: string; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -187,7 +183,8 @@ export function createFetchPlainFileAction(options: { targetPath: string; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -210,7 +207,8 @@ export function createFetchTemplateAction(options: { lstripBlocks?: boolean; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -230,7 +228,8 @@ export function createFetchTemplateFileAction(options: { lstripBlocks?: boolean; token?: string; }, - JsonObject + JsonObject, + 'v1' >; // @public @@ -238,14 +237,15 @@ export const createFilesystemDeleteAction: () => TemplateAction_2< { files: string[]; }, - JsonObject + JsonObject, + 'v1' >; // @public export const createFilesystemReadDirAction: () => TemplateAction_2< { + recursive: boolean; paths: string[]; - recursive?: boolean | undefined; }, { files: { @@ -258,7 +258,8 @@ export const createFilesystemReadDirAction: () => TemplateAction_2< path: string; fullPath: string; }[]; - } + }, + 'v1' >; // @public @@ -270,7 +271,8 @@ export const createFilesystemRenameAction: () => TemplateAction_2< overwrite?: boolean; }>; }, - JsonObject + JsonObject, + 'v1' >; // @public @deprecated (undocumented) @@ -346,7 +348,8 @@ export const createPublishGithubPullRequestAction: ( forceEmptyGitAuthor?: boolean; createWhenEmpty?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @deprecated (undocumented) @@ -372,45 +375,20 @@ export const createPublishGitlabMergeRequestAction: (options: { reviewers?: string[]; assignReviewersFromApprovalRules?: boolean; }, - JsonObject + JsonObject, + 'v1' >; // @public @deprecated export function createRouter(options: RouterOptions): Promise; // @public @deprecated (undocumented) -export const createTemplateAction: < - TInputParams extends JsonObject = JsonObject, - TOutputParams extends JsonObject = JsonObject, - TInputSchema extends Schema | ZodType = {}, - TOutputSchema extends Schema | ZodType = {}, - TActionInput extends JsonObject = TInputSchema extends ZodType< - any, - any, - infer IReturn - > - ? IReturn - : TInputParams, - TActionOutput extends JsonObject = TOutputSchema extends ZodType< - any, - any, - infer IReturn_1 - > - ? IReturn_1 - : TOutputParams, ->( - action: TemplateActionOptions< - TActionInput, - TActionOutput, - TInputSchema, - TOutputSchema - >, -) => TemplateAction_2; +export const createTemplateAction: typeof createTemplateAction_2; // @public export function createWaitAction(options?: { maxWaitTime?: Duration | HumanDuration; -}): TemplateAction_2; +}): TemplateAction_2; // @public export type CreateWorkerOptions = { @@ -547,7 +525,7 @@ export const fetchContents: typeof fetchContents_2; // @public @deprecated export interface RouterOptions { // (undocumented) - actions?: TemplateAction_2[]; + actions?: TemplateAction_2[]; // (undocumented) additionalTemplateFilters?: | Record @@ -866,11 +844,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 @deprecated (undocumented) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts index f00d7afad1..11d6234083 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts @@ -23,7 +23,7 @@ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; export class TemplateActionRegistry { 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`, @@ -33,7 +33,7 @@ export class TemplateActionRegistry { this.actions.set(action.id, action); } - get(actionId: string): TemplateAction { + get(actionId: string): TemplateAction { const action = this.actions.get(actionId); if (!action) { throw new NotFoundError( @@ -43,7 +43,7 @@ export class TemplateActionRegistry { return action; } - list(): TemplateAction[] { + list(): TemplateAction[] { return [...this.actions.values()]; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts index 824ca4deb3..e21930ed78 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.ts @@ -16,7 +16,6 @@ import { CatalogApi } from '@backstage/catalog-client'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import { z } from 'zod'; import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; import { examples } from './fetch.examples'; import { AuthService } from '@backstage/backend-plugin-api'; @@ -41,48 +40,54 @@ export function createFetchCatalogEntityAction(options: { examples, supportsDryRun: true, schema: { - input: z.object({ - entityRef: z - .string({ - description: 'Entity reference of the entity to get', - }) - .optional(), - entityRefs: z - .array(z.string(), { - description: 'Entity references of the entities to get', - }) - .optional(), - optional: z - .boolean({ - description: - 'Allow the entity or entities to optionally exist. Default: false', - }) - .optional(), - defaultKind: z.string({ description: 'The default kind' }).optional(), - defaultNamespace: z - .string({ description: 'The default namespace' }) - .optional(), - }), - output: z.object({ - entity: z - .any({ - description: - 'Object containing same values used in the Entity schema. Only when used with `entityRef` parameter.', - }) - .optional(), - entities: z - .array( - z.any({ + input: { + entityRef: z => + z + .string({ + description: 'Entity reference of the entity to get', + }) + .optional(), + entityRefs: z => + z + .array(z.string(), { + description: 'Entity references of the entities to get', + }) + .optional(), + optional: z => + z + .boolean({ description: - 'Array containing objects with same values used in the Entity schema. Only when used with `entityRefs` parameter.', - }), - ) - .optional(), - }), + 'Allow the entity or entities to optionally exist. Default: false', + }) + .optional(), + defaultKind: z => + z.string({ description: 'The default kind' }).optional(), + defaultNamespace: z => + z.string({ description: 'The default namespace' }).optional(), + }, + output: { + entity: z => + z + .any({ + description: + 'Object containing same values used in the Entity schema. Only when used with `entityRef` parameter.', + }) + .optional(), + entities: z => + z + .array( + z.any({ + description: + 'Array containing objects with same values used in the Entity schema. Only when used with `entityRefs` parameter.', + }), + ) + .optional(), + }, }, async handler(ctx) { const { entityRef, entityRefs, optional, defaultKind, defaultNamespace } = ctx.input; + if (!entityRef && !entityRefs) { if (optional) { return; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 8614f99388..2d6ed2d57c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -104,33 +104,37 @@ describe('NunjucksWorkflowRunner', () => { fakeActionHandler = jest.fn(); fakeTaskLog = jest.fn(); - actionRegistry.register({ - id: 'jest-mock-action', - description: 'Mock action for testing', - handler: fakeActionHandler, - }); - - actionRegistry.register({ - id: 'jest-validated-action', - description: 'Mock action for testing', - supportsDryRun: true, - handler: fakeActionHandler, - schema: { - input: { - type: 'object', - required: ['foo'], - properties: { - foo: { - type: 'number', - }, - }, - }, - }, - }); + actionRegistry.register( + createTemplateAction({ + id: 'jest-mock-action', + description: 'Mock action for testing', + handler: fakeActionHandler, + }), + ); actionRegistry.register( createTemplateAction({ - id: 'jest-zod-validated-action', + id: 'jest-validated-action', + description: 'Mock action for testing', + supportsDryRun: true, + handler: fakeActionHandler, + schema: { + input: { + type: 'object', + required: ['foo'], + properties: { + foo: { + type: 'number', + }, + }, + }, + }, + }), + ); + + actionRegistry.register( + createTemplateAction({ + id: 'jest-legacy-zod-validated-action', description: 'Mock action for testing', handler: fakeActionHandler, supportsDryRun: true, @@ -142,52 +146,80 @@ describe('NunjucksWorkflowRunner', () => { }) as TemplateAction, ); - actionRegistry.register({ - id: 'output-action', - description: 'Mock action for testing', - handler: async ctx => { - ctx.output('mock', 'backstage'); - ctx.output('shouldRun', true); - }, - }); + actionRegistry.register( + createTemplateAction({ + id: 'jest-zod-validated-action', + description: 'Mock ac', + supportsDryRun: true, + schema: { + input: { + foo: zod => zod.number(), + }, + output: { + test: zod => zod.string(), + }, + }, + handler: fakeActionHandler, + }), + ); - actionRegistry.register({ - id: 'checkpoints-action', - description: 'Mock action with checkpoints', - handler: async ctx => { - const key1 = await ctx.checkpoint({ - key: 'key1', - fn: async () => 'updated', - }); - const key2 = await ctx.checkpoint({ - key: 'key2', - fn: async () => 'updated', - }); - const key3 = await ctx.checkpoint({ - key: 'key3', - fn: async () => 'updated', - }); + actionRegistry.register( + createTemplateAction({ + id: 'output-action', + description: 'Mock action for testing', + handler: async ctx => { + ctx.output('mock', 'backstage'); + ctx.output('shouldRun', true); + }, + }), + ); - const key4 = await ctx.checkpoint({ - key: 'key4', - fn: () => {}, - }); + actionRegistry.register( + createTemplateAction({ + id: 'checkpoints-action', + description: 'Mock action with checkpoints', + schema: { + output: z.object({ + key1: z.string(), + key2: z.string(), + key3: z.string(), + key4: z.string(), + key5: z.string(), + }), + }, + handler: async ctx => { + const key1 = await ctx.checkpoint({ + key: 'key1', + fn: async () => 'updated', + }); + const key2 = await ctx.checkpoint({ + key: 'key2', + fn: async () => 'updated', + }); + const key3 = await ctx.checkpoint({ + key: 'key3', + fn: async () => 'updated', + }); - const key5 = await ctx.checkpoint({ - key: 'key5', - fn: async () => {}, - }); + const key4 = await ctx.checkpoint({ + key: 'key4', + fn: () => {}, + }); - ctx.output('key1', key1); - ctx.output('key2', key2); - ctx.output('key3', key3); + const key5 = await ctx.checkpoint({ + key: 'key5', + fn: async () => {}, + }); - // @ts-expect-error - this is void return - ctx.output('key4', key4); - // @ts-expect-error - this is void return - ctx.output('key5', key5); - }, - }); + ctx.output('key1', key1); + ctx.output('key2', key2); + ctx.output('key3', key3); + + ctx.output('key4', key4); + ctx.output('key5', key5); + }, + }), + ); mockedPermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, @@ -244,6 +276,25 @@ describe('NunjucksWorkflowRunner', () => { ); }); + it('should throw an error if the action has legacy zod schema and the input does not match', async () => { + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-legacy-zod-validated-action', + }, + ], + }); + + await expect(runner.execute(task)).rejects.toThrow( + /Invalid input passed to action jest-legacy-zod-validated-action, instance requires property \"foo\"/, + ); + }); + it('should run the action when the zod validation passes', async () => { const task = createMockTaskWithSpec({ apiVersion: 'scaffolder.backstage.io/v1beta3', @@ -264,6 +315,26 @@ describe('NunjucksWorkflowRunner', () => { expect(fakeActionHandler).toHaveBeenCalledTimes(1); }); + it('should run the action when the zod validation passes with legacy zod', async () => { + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-legacy-zod-validated-action', + input: { foo: 1 }, + }, + ], + }); + + await runner.execute(task); + + expect(fakeActionHandler).toHaveBeenCalledTimes(1); + }); + it('should run the action when the validation passes', async () => { const task = createMockTaskWithSpec({ apiVersion: 'scaffolder.backstage.io/v1beta3', diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts index 41425affc9..da47bfd563 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts @@ -38,13 +38,13 @@ export function generateExampleOutput(schema: Schema): unknown { return Object.fromEntries( Object.entries(schema.properties ?? {}).map(([key, value]) => [ key, - generateExampleOutput(value), + generateExampleOutput(value as Schema), ]), ); } else if (schema.type === 'array') { const [firstSchema] = [schema.items]?.flat(); if (firstSchema) { - return [generateExampleOutput(firstSchema)]; + return [generateExampleOutput(firstSchema as Schema)]; } return []; } else if (schema.type === 'string') { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 35782ea441..1186e7d4f6 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -165,7 +165,7 @@ export interface RouterOptions { database: DatabaseService; catalogClient: CatalogApi; scheduler?: SchedulerService; - actions?: TemplateAction[]; + actions?: TemplateAction[]; /** * @deprecated taskWorkers is deprecated in favor of concurrentTasksLimit option with a single TaskWorker * @defaultValue 1 diff --git a/plugins/scaffolder-node/package.json b/plugins/scaffolder-node/package.json index 770e66cf62..ee7f3695b0 100644 --- a/plugins/scaffolder-node/package.json +++ b/plugins/scaffolder-node/package.json @@ -65,7 +65,7 @@ "fs-extra": "^11.2.0", "globby": "^11.0.0", "isomorphic-git": "^1.23.0", - "jsonschema": "^1.2.6", + "jsonschema": "^1.5.0", "p-limit": "^3.1.0", "tar": "^6.1.12", "winston": "^3.2.1", diff --git a/plugins/scaffolder-node/report-alpha.api.md b/plugins/scaffolder-node/report-alpha.api.md index 8ecc54f031..fa9e022bb4 100644 --- a/plugins/scaffolder-node/report-alpha.api.md +++ b/plugins/scaffolder-node/report-alpha.api.md @@ -122,7 +122,7 @@ export const restoreWorkspace: (opts: { // @alpha export interface ScaffolderActionsExtensionPoint { // (undocumented) - addActions(...actions: TemplateAction[]): void; + addActions(...actions: TemplateAction[]): void; } // @alpha diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index afa611804d..6dabcbf346 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -4,6 +4,7 @@ ```ts import { BackstageCredentials } from '@backstage/backend-plugin-api'; +import { Expand } from '@backstage/types'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Logger } from 'winston'; @@ -24,34 +25,63 @@ import { z } from 'zod'; export type ActionContext< TActionInput extends JsonObject, TActionOutput extends JsonObject = JsonObject, -> = { - logger: Logger; - logStream: Writable; - secrets?: TaskSecrets; - workspacePath: string; - input: TActionInput; - checkpoint(opts: { - key: string; - fn: () => Promise | T; - }): Promise; - output( - name: keyof TActionOutput, - value: TActionOutput[keyof TActionOutput], - ): void; - createTemporaryDirectory(): Promise; - getInitiatorCredentials(): Promise; - task: { - id: string; - }; - templateInfo?: TemplateInfo; - isDryRun?: boolean; - user?: { - entity?: UserEntity; - ref?: string; - }; - signal?: AbortSignal; - each?: JsonObject; -}; + TSchemaType extends 'v1' | 'v2' = 'v1', +> = TSchemaType extends 'v2' + ? { + logger: LoggerService; + secrets?: TaskSecrets; + workspacePath: string; + input: TActionInput; + checkpoint(opts: { + key: string; + fn: () => Promise | T; + }): Promise; + output( + name: keyof TActionOutput, + value: TActionOutput[keyof TActionOutput], + ): void; + createTemporaryDirectory(): Promise; + getInitiatorCredentials(): Promise; + task: { + id: string; + }; + templateInfo?: TemplateInfo; + isDryRun?: boolean; + user?: { + entity?: UserEntity; + ref?: string; + }; + signal?: AbortSignal; + each?: JsonObject; + } + : { + logger: Logger; + logStream: Writable; + secrets?: TaskSecrets; + workspacePath: string; + input: TActionInput; + checkpoint(opts: { + key: string; + fn: () => Promise | T; + }): Promise; + output( + name: keyof TActionOutput, + value: TActionOutput[keyof TActionOutput], + ): void; + createTemporaryDirectory(): Promise; + getInitiatorCredentials(): Promise; + task: { + id: string; + }; + templateInfo?: TemplateInfo; + isDryRun?: boolean; + user?: { + entity?: UserEntity; + ref?: string; + }; + signal?: AbortSignal; + each?: JsonObject; + }; // @public (undocumented) export function addFiles(options: { @@ -150,34 +180,71 @@ export function createBranch(options: { logger?: Logger | undefined; }): Promise; -// @public -export const createTemplateAction: < +// @public @deprecated (undocumented) +export function createTemplateAction< TInputParams extends JsonObject = JsonObject, TOutputParams extends JsonObject = JsonObject, - TInputSchema extends Schema | z.ZodType = {}, - TOutputSchema extends Schema | z.ZodType = {}, - TActionInput extends JsonObject = TInputSchema extends z.ZodType< - any, - any, - infer IReturn - > - ? IReturn - : TInputParams, - TActionOutput extends JsonObject = TOutputSchema extends z.ZodType< - any, - any, - infer IReturn_1 - > - ? IReturn_1 - : TOutputParams, + TInputSchema extends JsonObject = JsonObject, + TOutputSchema extends JsonObject = JsonObject, + TActionInput extends JsonObject = TInputParams, + TActionOutput extends JsonObject = TOutputParams, >( action: TemplateActionOptions< TActionInput, TActionOutput, TInputSchema, - TOutputSchema + TOutputSchema, + 'v1' >, -) => TemplateAction; +): TemplateAction; + +// @public @deprecated (undocumented) +export function createTemplateAction< + TInputParams extends JsonObject = JsonObject, + TOutputParams extends JsonObject = JsonObject, + TInputSchema extends z.ZodType = z.ZodType, + TOutputSchema extends z.ZodType = z.ZodType, + TActionInput extends JsonObject = z.infer, + TActionOutput extends JsonObject = z.infer, +>( + action: TemplateActionOptions< + TActionInput, + TActionOutput, + TInputSchema, + TOutputSchema, + 'v1' + >, +): TemplateAction; + +// @public +export function createTemplateAction< + TInputSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + TOutputSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, +>( + action: TemplateActionOptions< + { + [key in keyof TInputSchema]: z.infer>; + }, + { + [key in keyof TOutputSchema]: z.infer>; + }, + TInputSchema, + TOutputSchema, + 'v2' + >, +): TemplateAction< + FlattenOptionalProperties<{ + [key in keyof TInputSchema]: z.output>; + }>, + FlattenOptionalProperties<{ + [key in keyof TOutputSchema]: z.output>; + }>, + 'v2' +>; // @public export function deserializeDirectoryContents( @@ -443,6 +510,7 @@ export type TaskStatus = export type TemplateAction< TActionInput extends JsonObject = JsonObject, TActionOutput extends JsonObject = JsonObject, + TSchemaType extends 'v1' | 'v2' = 'v1', > = { id: string; description?: string; @@ -455,15 +523,28 @@ export type TemplateAction< input?: Schema; output?: Schema; }; - handler: (ctx: ActionContext) => Promise; + handler: ( + ctx: ActionContext, + ) => Promise; }; // @public (undocumented) export type TemplateActionOptions< TActionInput extends JsonObject = {}, TActionOutput extends JsonObject = {}, - TInputSchema extends Schema | z.ZodType = {}, - TOutputSchema extends Schema | z.ZodType = {}, + TInputSchema extends + | JsonObject + | z.ZodType + | { + [key in string]: (zImpl: typeof z) => z.ZodType; + } = JsonObject, + TOutputSchema extends + | JsonObject + | z.ZodType + | { + [key in string]: (zImpl: typeof z) => z.ZodType; + } = JsonObject, + TSchemaType extends 'v1' | 'v2' = 'v1' | 'v2', > = { id: string; description?: string; @@ -473,7 +554,9 @@ export type TemplateActionOptions< input?: TInputSchema; output?: TOutputSchema; }; - handler: (ctx: ActionContext) => Promise; + handler: ( + ctx: ActionContext, + ) => Promise; }; // @public (undocumented) diff --git a/plugins/scaffolder-node/src/actions/createTemplateAction.test.ts b/plugins/scaffolder-node/src/actions/createTemplateAction.test.ts new file mode 100644 index 0000000000..43d5d2f660 --- /dev/null +++ b/plugins/scaffolder-node/src/actions/createTemplateAction.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createTemplateAction } from './createTemplateAction'; +import { z } from 'zod'; + +describe('createTemplateAction', () => { + it('should allow creating with jsonschema and use the old deprecated types', () => { + const action = createTemplateAction<{ repoUrl: string }, { test: string }>({ + id: 'test', + schema: { + input: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { type: 'string' }, + }, + }, + output: { + type: 'object', + required: ['test'], + properties: { + test: { type: 'string' }, + }, + }, + }, + handler: async ctx => { + // @ts-expect-error - repoUrl is string + const a: number = ctx.input.repoUrl; + + const b: string = ctx.input.repoUrl; + expect(b).toBeDefined(); + + const stream = ctx.logStream; + expect(stream).toBeDefined(); + + ctx.output('test', 'value'); + + // @ts-expect-error - not valid output type + ctx.output('test', 4); + + // @ts-expect-error - not valid output name + ctx.output('test2', 'value'); + }, + }); + + expect(action).toBeDefined(); + }); + + it('should allow creating with zod and use the old deprecated types', () => { + const action = createTemplateAction({ + id: 'test', + schema: { + input: z.object({ + repoUrl: z.string(), + }), + output: z.object({ + test: z.string(), + }), + }, + handler: async ctx => { + // @ts-expect-error - repoUrl is string + const a: number = ctx.input.repoUrl; + + const b: string = ctx.input.repoUrl; + expect(b).toBeDefined(); + + const stream = ctx.logStream; + expect(stream).toBeDefined(); + + ctx.output('test', 'value'); + + // @ts-expect-error - not valid output type + ctx.output('test', 4); + + // @ts-expect-error - not valid output name + ctx.output('test2', 'value'); + }, + }); + + expect(action).toBeDefined(); + }); + + it('should allow creating with new first class zod support', () => { + const action = createTemplateAction({ + id: 'test', + schema: { + input: { + repoUrl: d => d.string(), + }, + output: { + test: d => d.string(), + }, + }, + handler: async ctx => { + // @ts-expect-error - repoUrl is string + const a: number = ctx.input.repoUrl; + + const b: string = ctx.input.repoUrl; + expect(b).toBeDefined(); + + // @ts-expect-error - logStream is not available + const stream = ctx.logStream; + + expect(stream).toBeDefined(); + + ctx.output('test', 'value'); + + // @ts-expect-error - not valid output type + ctx.output('test', 4); + + // @ts-expect-error - not valid output name + ctx.output('test2', 'value'); + }, + }); + + expect(action).toBeDefined(); + }); +}); diff --git a/plugins/scaffolder-node/src/actions/createTemplateAction.ts b/plugins/scaffolder-node/src/actions/createTemplateAction.ts index 4721df7de5..ce38f3b4e1 100644 --- a/plugins/scaffolder-node/src/actions/createTemplateAction.ts +++ b/plugins/scaffolder-node/src/actions/createTemplateAction.ts @@ -16,9 +16,8 @@ import { ActionContext, TemplateAction } from './types'; import { z } from 'zod'; -import { Schema } from 'jsonschema'; -import zodToJsonSchema from 'zod-to-json-schema'; -import { JsonObject } from '@backstage/types'; +import { Expand, JsonObject } from '@backstage/types'; +import { parseSchemas } from './util'; /** @public */ export type TemplateExample = { @@ -30,8 +29,15 @@ export type TemplateExample = { export type TemplateActionOptions< TActionInput extends JsonObject = {}, TActionOutput extends JsonObject = {}, - TInputSchema extends Schema | z.ZodType = {}, - TOutputSchema extends Schema | z.ZodType = {}, + TInputSchema extends + | JsonObject + | z.ZodType + | { [key in string]: (zImpl: typeof z) => z.ZodType } = JsonObject, + TOutputSchema extends + | JsonObject + | z.ZodType + | { [key in string]: (zImpl: typeof z) => z.ZodType } = JsonObject, + TSchemaType extends 'v1' | 'v2' = 'v1' | 'v2', > = { id: string; description?: string; @@ -41,25 +47,112 @@ export type TemplateActionOptions< input?: TInputSchema; output?: TOutputSchema; }; - handler: (ctx: ActionContext) => Promise; + handler: ( + ctx: ActionContext, + ) => Promise; }; +/** + * @ignore + */ +type FlattenOptionalProperties = Expand< + { + [K in keyof T as undefined extends T[K] ? never : K]: T[K]; + } & { + [K in keyof T as undefined extends T[K] ? K : never]?: T[K]; + } +>; + +/** + * @public + * @deprecated migrate to using the new built in zod schema definitions for schemas + */ +export function createTemplateAction< + TInputParams extends JsonObject = JsonObject, + TOutputParams extends JsonObject = JsonObject, + TInputSchema extends JsonObject = JsonObject, + TOutputSchema extends JsonObject = JsonObject, + TActionInput extends JsonObject = TInputParams, + TActionOutput extends JsonObject = TOutputParams, +>( + action: TemplateActionOptions< + TActionInput, + TActionOutput, + TInputSchema, + TOutputSchema, + 'v1' + >, +): TemplateAction; +/** + * @public + * @deprecated migrate to using the new built in zod schema definitions for schemas + */ +export function createTemplateAction< + TInputParams extends JsonObject = JsonObject, + TOutputParams extends JsonObject = JsonObject, + TInputSchema extends z.ZodType = z.ZodType, + TOutputSchema extends z.ZodType = z.ZodType, + TActionInput extends JsonObject = z.infer, + TActionOutput extends JsonObject = z.infer, +>( + action: TemplateActionOptions< + TActionInput, + TActionOutput, + TInputSchema, + TOutputSchema, + 'v1' + >, +): TemplateAction; /** * This function is used to create new template actions to get type safety. * Will convert zod schemas to json schemas for use throughout the system. * @public */ -export const createTemplateAction = < +export function createTemplateAction< + TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, + TOutputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, +>( + action: TemplateActionOptions< + { + [key in keyof TInputSchema]: z.infer>; + }, + { + [key in keyof TOutputSchema]: z.infer>; + }, + TInputSchema, + TOutputSchema, + 'v2' + >, +): TemplateAction< + FlattenOptionalProperties<{ + [key in keyof TInputSchema]: z.output>; + }>, + FlattenOptionalProperties<{ + [key in keyof TOutputSchema]: z.output>; + }>, + 'v2' +>; +export function createTemplateAction< TInputParams extends JsonObject = JsonObject, TOutputParams extends JsonObject = JsonObject, - TInputSchema extends Schema | z.ZodType = {}, - TOutputSchema extends Schema | z.ZodType = {}, + TInputSchema extends + | JsonObject + | z.ZodType + | { [key in string]: (zImpl: typeof z) => z.ZodType } = JsonObject, + TOutputSchema extends + | JsonObject + | z.ZodType + | { [key in string]: (zImpl: typeof z) => z.ZodType } = JsonObject, TActionInput extends JsonObject = TInputSchema extends z.ZodType< any, any, infer IReturn > ? IReturn + : TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } + ? Expand<{ + [key in keyof TInputSchema]: z.infer>; + }> : TInputParams, TActionOutput extends JsonObject = TOutputSchema extends z.ZodType< any, @@ -67,6 +160,10 @@ export const createTemplateAction = < infer IReturn > ? IReturn + : TOutputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } + ? Expand<{ + [key in keyof TOutputSchema]: z.infer>; + }> : TOutputParams, >( action: TemplateActionOptions< @@ -75,23 +172,23 @@ export const createTemplateAction = < TInputSchema, TOutputSchema >, -): TemplateAction => { - const inputSchema = - action.schema?.input && 'safeParseAsync' in action.schema.input - ? zodToJsonSchema(action.schema.input) - : action.schema?.input; - - const outputSchema = - action.schema?.output && 'safeParseAsync' in action.schema.output - ? zodToJsonSchema(action.schema.output) - : action.schema?.output; +): TemplateAction< + TActionInput, + TActionOutput, + TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } + ? 'v2' + : 'v1' +> { + const { inputSchema, outputSchema } = parseSchemas( + action as TemplateActionOptions, + ); return { ...action, schema: { ...action.schema, - input: inputSchema as TInputSchema, - output: outputSchema as TOutputSchema, + input: inputSchema, + output: outputSchema, }, }; -}; +} diff --git a/plugins/scaffolder-node/src/actions/types.ts b/plugins/scaffolder-node/src/actions/types.ts index 171d3d82db..92a2c7464a 100644 --- a/plugins/scaffolder-node/src/actions/types.ts +++ b/plugins/scaffolder-node/src/actions/types.ts @@ -21,8 +21,10 @@ import { TaskSecrets } from '../tasks'; import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; import { UserEntity } from '@backstage/catalog-model'; import { Schema } from 'jsonschema'; -import { BackstageCredentials } from '@backstage/backend-plugin-api'; - +import { + BackstageCredentials, + LoggerService, +} from '@backstage/backend-plugin-api'; /** * ActionContext is passed into scaffolder actions. * @public @@ -30,77 +32,143 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; export type ActionContext< TActionInput extends JsonObject, TActionOutput extends JsonObject = JsonObject, -> = { - // TODO(blam): move this to LoggerService - logger: Logger; - /** @deprecated - use `ctx.logger` instead */ - logStream: Writable; - secrets?: TaskSecrets; - workspacePath: string; - input: TActionInput; - checkpoint(opts: { - key: string; - fn: () => Promise | T; - }): Promise; - output( - name: keyof TActionOutput, - value: TActionOutput[keyof TActionOutput], - ): void; + TSchemaType extends 'v1' | 'v2' = 'v1', +> = TSchemaType extends 'v2' + ? { + logger: LoggerService; + secrets?: TaskSecrets; + workspacePath: string; + input: TActionInput; + checkpoint(opts: { + key: string; + fn: () => Promise | T; + }): Promise; + output( + name: keyof TActionOutput, + value: TActionOutput[keyof TActionOutput], + ): void; + /** + * Creates a temporary directory for use by the action, which is then cleaned up automatically. + */ + createTemporaryDirectory(): Promise; - /** - * Creates a temporary directory for use by the action, which is then cleaned up automatically. - */ - createTemporaryDirectory(): Promise; + /** + * Get the credentials for the current request + */ + getInitiatorCredentials(): Promise; - /** - * Get the credentials for the current request - */ - getInitiatorCredentials(): Promise; + /** + * Task information + */ + task: { + id: string; + }; - /** - * Task information - */ - task: { - id: string; - }; + templateInfo?: TemplateInfo; - templateInfo?: TemplateInfo; + /** + * Whether this action invocation is a dry-run or not. + * This will only ever be true if the actions as marked as supporting dry-runs. + */ + isDryRun?: boolean; - /** - * Whether this action invocation is a dry-run or not. - * This will only ever be true if the actions as marked as supporting dry-runs. - */ - isDryRun?: boolean; + /** + * The user which triggered the action. + */ + user?: { + /** + * The decorated entity from the Catalog + */ + entity?: UserEntity; + /** + * An entity ref for the author of the task + */ + ref?: string; + }; - /** - * The user which triggered the action. - */ - user?: { - /** - * The decorated entity from the Catalog - */ - entity?: UserEntity; - /** - * An entity ref for the author of the task - */ - ref?: string; - }; + /** + * Implement the signal to make your custom step abortable https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal + */ + signal?: AbortSignal; - /** - * Implement the signal to make your custom step abortable https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal - */ - signal?: AbortSignal; + /** + * Optional value of each invocation + */ + each?: JsonObject; + } + : /** @deprecated **/ + { + // TODO(blam): move this to LoggerService + logger: Logger; + /** @deprecated - use `ctx.logger` instead */ + logStream: Writable; + secrets?: TaskSecrets; + workspacePath: string; + input: TActionInput; + checkpoint(opts: { + key: string; + fn: () => Promise | T; + }): Promise; + output( + name: keyof TActionOutput, + value: TActionOutput[keyof TActionOutput], + ): void; - /** - * Optional value of each invocation - */ - each?: JsonObject; -}; + /** + * Creates a temporary directory for use by the action, which is then cleaned up automatically. + */ + createTemporaryDirectory(): Promise; + + /** + * Get the credentials for the current request + */ + getInitiatorCredentials(): Promise; + + /** + * Task information + */ + task: { + id: string; + }; + + templateInfo?: TemplateInfo; + + /** + * Whether this action invocation is a dry-run or not. + * This will only ever be true if the actions as marked as supporting dry-runs. + */ + isDryRun?: boolean; + + /** + * The user which triggered the action. + */ + user?: { + /** + * The decorated entity from the Catalog + */ + entity?: UserEntity; + /** + * An entity ref for the author of the task + */ + ref?: string; + }; + + /** + * Implement the signal to make your custom step abortable https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal + */ + signal?: AbortSignal; + + /** + * Optional value of each invocation + */ + each?: JsonObject; + }; /** @public */ export type TemplateAction< TActionInput extends JsonObject = JsonObject, TActionOutput extends JsonObject = JsonObject, + TSchemaType extends 'v1' | 'v2' = 'v1', > = { id: string; description?: string; @@ -110,5 +178,7 @@ export type TemplateAction< input?: Schema; output?: Schema; }; - handler: (ctx: ActionContext) => Promise; + handler: ( + ctx: ActionContext, + ) => Promise; }; diff --git a/plugins/scaffolder-node/src/actions/util.ts b/plugins/scaffolder-node/src/actions/util.ts index 5e4ce60a1a..f1775bb318 100644 --- a/plugins/scaffolder-node/src/actions/util.ts +++ b/plugins/scaffolder-node/src/actions/util.ts @@ -18,6 +18,10 @@ import { InputError } from '@backstage/errors'; import { isChildPath } from '@backstage/backend-plugin-api'; import { join as joinPath, normalize as normalizePath } from 'path'; import { ScmIntegrationRegistry } from '@backstage/integration'; +import { TemplateActionOptions } from './createTemplateAction'; +import zodToJsonSchema from 'zod-to-json-schema'; +import { z } from 'zod'; +import { Schema } from 'jsonschema'; /** * @public @@ -124,3 +128,60 @@ function checkRequiredParams(repoUrl: URL, ...params: string[]) { } } } + +const isZodSchema = (schema: unknown): schema is z.ZodType => { + return typeof schema === 'object' && !!schema && 'safeParseAsync' in schema; +}; + +const isNativeZodSchema = ( + schema: unknown, +): schema is { [key in string]: (zImpl: typeof z) => z.ZodType } => { + return ( + typeof schema === 'object' && + !!schema && + Object.values(schema).every(v => typeof v === 'function') + ); +}; + +export const parseSchemas = ( + action: TemplateActionOptions, +): { inputSchema?: Schema; outputSchema?: Schema } => { + if (!action.schema) { + return { inputSchema: undefined, outputSchema: undefined }; + } + + if (isZodSchema(action.schema.input)) { + return { + inputSchema: zodToJsonSchema(action.schema.input) as Schema, + outputSchema: isZodSchema(action.schema.output) + ? (zodToJsonSchema(action.schema.output) as Schema) + : undefined, + }; + } + + if (isNativeZodSchema(action.schema.input)) { + const input = z.object( + Object.fromEntries( + Object.entries(action.schema.input).map(([k, v]) => [k, v(z)]), + ), + ); + + return { + inputSchema: zodToJsonSchema(input) as Schema, + outputSchema: isNativeZodSchema(action.schema.output) + ? (zodToJsonSchema( + z.object( + Object.fromEntries( + Object.entries(action.schema.output).map(([k, v]) => [k, v(z)]), + ), + ), + ) as Schema) + : undefined, + }; + } + + return { + inputSchema: action.schema.input, + outputSchema: action.schema.output, + }; +}; diff --git a/plugins/scaffolder-node/src/alpha/index.ts b/plugins/scaffolder-node/src/alpha/index.ts index 1b573d0650..d97aa54557 100644 --- a/plugins/scaffolder-node/src/alpha/index.ts +++ b/plugins/scaffolder-node/src/alpha/index.ts @@ -34,7 +34,7 @@ export * from './globals'; * @alpha */ export interface ScaffolderActionsExtensionPoint { - addActions(...actions: TemplateAction[]): void; + addActions(...actions: TemplateAction[]): void; } /** diff --git a/yarn.lock b/yarn.lock index 2efeae9f20..c793169131 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7720,7 +7720,7 @@ __metadata: globby: ^11.0.0 isbinaryfile: ^5.0.0 isolated-vm: ^5.0.1 - jsonschema: ^1.2.6 + jsonschema: ^1.5.0 knex: ^3.0.0 lodash: ^4.17.21 logform: ^2.3.2 @@ -7799,7 +7799,7 @@ __metadata: fs-extra: ^11.2.0 globby: ^11.0.0 isomorphic-git: ^1.23.0 - jsonschema: ^1.2.6 + jsonschema: ^1.5.0 p-limit: ^3.1.0 tar: ^6.1.12 winston: ^3.2.1 @@ -34355,10 +34355,10 @@ __metadata: languageName: node linkType: hard -"jsonschema@npm:^1.2.6": - version: 1.4.1 - resolution: "jsonschema@npm:1.4.1" - checksum: 1ef02a6cd9bc32241ec86bbf1300bdbc3b5f2d8df6eb795517cf7d1cd9909e7beba1e54fdf73990fd66be98a182bda9add9607296b0cb00b1348212988e424b2 +"jsonschema@npm:^1.2.6, jsonschema@npm:^1.5.0": + version: 1.5.0 + resolution: "jsonschema@npm:1.5.0" + checksum: 170b9c375967bc135f4d029fedc31f5686f2c3bb07e7472cebddbb907b5369bf75a1a50287d6af9c31f61c76fe0b7786e78044c188aaddd329b77d856475e6db languageName: node linkType: hard