From cf8b627f073d17b1df154507e0ced15f32e0d78e Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 5 Feb 2025 09:15:59 +0100 Subject: [PATCH] chore: refactoring things to work a little cleaner Signed-off-by: blam Signed-off-by: blam --- .../confluence/confluenceToMarkdown.ts | 1 - .../actions/builtin/catalog/fetch.ts | 81 +++---- .../scaffolder-backend/src/service/router.ts | 2 +- .../src/actions/mockActionContext.ts | 2 +- .../src/actions/createTemplateAction.test.ts | 125 +++++++++++ .../src/actions/createTemplateAction.ts | 111 ++++++++-- plugins/scaffolder-node/src/actions/types.ts | 198 ++++++++++++------ plugins/scaffolder-node/src/actions/util.ts | 52 +++++ 8 files changed, 451 insertions(+), 121 deletions(-) create mode 100644 plugins/scaffolder-node/src/actions/createTemplateAction.test.ts diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.ts index aa9f94988a..3d08259e98 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.ts @@ -66,7 +66,6 @@ export const createConfluenceToMarkdownAction = (options: { 'Paste your Confluence url. Ensure it follows this format: https://{confluence+base+url}/display/{spacekey}/{page+title} or https://{confluence+base+url}/spaces/{spacekey}/pages/1234567/{page+title} for Confluence Cloud', items: { type: 'string', - default: 'Confluence URL', }, }, repoUrl: { 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/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-test-utils/src/actions/mockActionContext.ts b/plugins/scaffolder-node-test-utils/src/actions/mockActionContext.ts index eb03ac0a0f..f042416e36 100644 --- a/plugins/scaffolder-node-test-utils/src/actions/mockActionContext.ts +++ b/plugins/scaffolder-node-test-utils/src/actions/mockActionContext.ts @@ -35,7 +35,7 @@ export const createMockActionContext = < TActionOutput extends JsonObject = JsonObject, >( options?: Partial>, -): ActionContext => { +): ActionContext => { const credentials = mockCredentials.user(); const defaultContext = { logger: loggerToWinstonLogger(mockServices.logger.mock()), 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..7dcb3f491c --- /dev/null +++ b/plugins/scaffolder-node/src/actions/createTemplateAction.test.ts @@ -0,0 +1,125 @@ +/* + * 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'); + }, + }); + }); + + 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'); + }, + }); + }); + + 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'); + }, + }); + }); +}); diff --git a/plugins/scaffolder-node/src/actions/createTemplateAction.ts b/plugins/scaffolder-node/src/actions/createTemplateAction.ts index 4721df7de5..02cac0a4b5 100644 --- a/plugins/scaffolder-node/src/actions/createTemplateAction.ts +++ b/plugins/scaffolder-node/src/actions/createTemplateAction.ts @@ -17,8 +17,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 +30,14 @@ export type TemplateExample = { export type TemplateActionOptions< TActionInput extends JsonObject = {}, TActionOutput extends JsonObject = {}, - TInputSchema extends Schema | z.ZodType = {}, - TOutputSchema extends Schema | z.ZodType = {}, + TInputSchema extends + | Schema + | z.ZodType + | { [key in string]: (zImpl: typeof z) => z.ZodType } = Schema, + TOutputSchema extends + | Schema + | z.ZodType + | { [key in string]: (zImpl: typeof z) => z.ZodType } = Schema, > = { id: string; description?: string; @@ -41,7 +47,9 @@ export type TemplateActionOptions< input?: TInputSchema; output?: TOutputSchema; }; - handler: (ctx: ActionContext) => Promise; + handler: ( + ctx: ActionContext, + ) => Promise; }; /** @@ -49,17 +57,86 @@ export type TemplateActionOptions< * 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 + >, +): TemplateAction< + Expand<{ + [key in keyof TInputSchema]: z.output>; + }>, + Expand<{ + [key in keyof TOutputSchema]: z.output>; + }>, + TInputSchema +>; +/** + * @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 Schema | z.ZodType = {}, - TOutputSchema extends Schema | z.ZodType = {}, + TInputSchema extends Schema = Schema, + TOutputSchema extends Schema = Schema, + TActionInput extends JsonObject = TInputParams, + TActionOutput extends JsonObject = TOutputParams, +>( + action: TemplateActionOptions< + TActionInput, + TActionOutput, + TInputSchema, + TOutputSchema + >, +): TemplateAction; +/** + * @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 + >, +): TemplateAction; +export function createTemplateAction< + TInputParams extends JsonObject = JsonObject, + TOutputParams extends JsonObject = JsonObject, + TInputSchema extends + | Schema + | z.ZodType + | { [key in string]: (zImpl: typeof z) => z.ZodType } = {}, + TOutputSchema extends + | Schema + | z.ZodType + | { [key in string]: (zImpl: typeof z) => z.ZodType } = {}, 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 +144,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,16 +156,8 @@ 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 { + const { inputSchema, outputSchema } = parseSchemas(action); return { ...action, @@ -94,4 +167,4 @@ export const createTemplateAction = < output: outputSchema as TOutputSchema, }, }; -}; +} diff --git a/plugins/scaffolder-node/src/actions/types.ts b/plugins/scaffolder-node/src/actions/types.ts index 171d3d82db..d89e61e9e7 100644 --- a/plugins/scaffolder-node/src/actions/types.ts +++ b/plugins/scaffolder-node/src/actions/types.ts @@ -21,8 +21,11 @@ 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'; +import { z } from 'zod'; /** * ActionContext is passed into scaffolder actions. * @public @@ -30,77 +33,148 @@ 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; + TInputSchema extends + | { [key in string]: (zImpl: typeof z) => z.ZodType } + | Schema = Schema, +> = TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } + ? { + logger: LoggerService; + secrets?: TaskSecrets; + workspacePath: string; + input: TActionInput; + checkpoint( + key: string, + fn: () => Promise, + ): Promise; + output( + ...params: { + [K in keyof TActionOutput]: [name: K, value: TActionOutput[K]]; + }[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, + TInputSchema extends + | { [key in string]: (zImpl: typeof z) => z.ZodType } + | Schema = Schema, > = { id: string; description?: string; @@ -110,5 +184,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..038d199d3b 100644 --- a/plugins/scaffolder-node/src/actions/util.ts +++ b/plugins/scaffolder-node/src/actions/util.ts @@ -18,6 +18,9 @@ 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'; /** * @public @@ -124,3 +127,52 @@ 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) => { + if (!action.schema) { + return { inputSchema: undefined, outputSchema: undefined }; + } + + if (isZodSchema(action.schema.input) && isZodSchema(action.schema.output)) { + return { + inputSchema: zodToJsonSchema(action.schema.input), + outputSchema: zodToJsonSchema(action.schema.output), + }; + } + + if ( + isNativeZodSchema(action.schema.input) && + isNativeZodSchema(action.schema.output) + ) { + const input = z.object( + Object.fromEntries( + Object.entries(action.schema.input).map(([k, v]) => [k, v(z)]), + ), + ); + const output = z.object( + Object.fromEntries( + Object.entries(action.schema.output).map(([k, v]) => [k, v(z)]), + ), + ); + return { + inputSchema: zodToJsonSchema(input), + outputSchema: zodToJsonSchema(output), + }; + } + + throw new Error('Invalid schema provided'); +};