From 1a588465e93fd26bdf7c76289f6922d2b896360c Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 3 Oct 2024 14:30:19 -0500 Subject: [PATCH] feat(scaffolder): add first class citizen support for zod Signed-off-by: Paul Schultz backport better typescript support Signed-off-by: Paul Schultz wip Signed-off-by: Paul Schultz wip Signed-off-by: Paul Schultz complete refactor Signed-off-by: Paul Schultz --- .changeset/curvy-numbers-grin.md | 7 + .changeset/dull-olives-itch.md | 7 + packages/types/report.api.md | 5 + packages/types/src/utility.ts | 38 ++++ .../src/actions/createTemplateAction.ts | 192 +++++++++++++++--- plugins/scaffolder-node/src/actions/index.ts | 24 ++- plugins/scaffolder-node/src/actions/types.ts | 171 ++++++++++++++-- 7 files changed, 391 insertions(+), 53 deletions(-) create mode 100644 .changeset/curvy-numbers-grin.md create mode 100644 .changeset/dull-olives-itch.md create mode 100644 packages/types/src/utility.ts diff --git a/.changeset/curvy-numbers-grin.md b/.changeset/curvy-numbers-grin.md new file mode 100644 index 0000000000..1f786335c0 --- /dev/null +++ b/.changeset/curvy-numbers-grin.md @@ -0,0 +1,7 @@ +--- +'@backstage/types': minor +--- + +feat: feat(scaffolder): add first class citizen support for zod + +Added `Prettify` utility type to enhance readability of hover overlays (type hints) for object types. This type simplifies complex object intersections, making them more legible in editor tooltips. diff --git a/.changeset/dull-olives-itch.md b/.changeset/dull-olives-itch.md new file mode 100644 index 0000000000..b886906849 --- /dev/null +++ b/.changeset/dull-olives-itch.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-scaffolder-node': minor +--- + +feat(scaffolder): add first class citizen support for zod + +This change introduces a new way to define template actions using Zod schemas for type safety. The existing `createTemplateAction` function is now renamed to `oldCreateTemplateAction` to maintain backwards compatibility. A new `createTemplateAction` function is introduced which acts as an overload, supporting both the old style (using JSON Schema or string schemas) via `oldCreateTemplateAction` and the new style (using Zod schemas) via `newCreateTemplateAction`. This new function, `newCreateTemplateAction`, provides direct support for Zod, simplifying action definition and enhancing type checking. diff --git a/packages/types/report.api.md b/packages/types/report.api.md index b6bfae0862..130a6ae504 100644 --- a/packages/types/report.api.md +++ b/packages/types/report.api.md @@ -79,6 +79,11 @@ export type Observer = { complete?(): void; }; +// @public +export type Prettify> = { + [K in keyof T]: T[K]; +} & {}; + // @public export type Subscription = { unsubscribe(): void; diff --git a/packages/types/src/utility.ts b/packages/types/src/utility.ts new file mode 100644 index 0000000000..ebcfedc360 --- /dev/null +++ b/packages/types/src/utility.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2024 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. + */ + +/** + * A utility type that enhances the readability of hover overlays (type hints) for object types. + * + * @example Prettifying a basic object merge + * + * ### Basic object type + * ```ts + * type Example = { item1: string } & { item2: number } + * // ?^ { item1: string } & { item2: number } + * ``` + * + * ### Usage + * ```ts + * type Example = Prettify<{ item1: string } & { item2: number }> + * // ?^ { item1: string, item2: number } + * ``` + * + * @public + */ +export type Prettify> = { + [K in keyof T]: T[K]; +} & {}; diff --git a/plugins/scaffolder-node/src/actions/createTemplateAction.ts b/plugins/scaffolder-node/src/actions/createTemplateAction.ts index 4721df7de5..a083b2ab88 100644 --- a/plugins/scaffolder-node/src/actions/createTemplateAction.ts +++ b/plugins/scaffolder-node/src/actions/createTemplateAction.ts @@ -14,24 +14,42 @@ * limitations under the License. */ -import { ActionContext, TemplateAction } from './types'; +import type { JsonObject, Prettify } from '@backstage/types'; +import type { Schema } from 'jsonschema'; import { z } from 'zod'; -import { Schema } from 'jsonschema'; import zodToJsonSchema from 'zod-to-json-schema'; -import { JsonObject } from '@backstage/types'; +import type { + InferActionType, + NewActionContext, + NewTemplateAction, + OldActionContext, + OldTemplateAction, + TemplateExample, +} from './types'; -/** @public */ -export type TemplateExample = { - description: string; - example: string; -}; - -/** @public */ -export type TemplateActionOptions< - TActionInput extends JsonObject = {}, - TActionOutput extends JsonObject = {}, - TInputSchema extends Schema | z.ZodType = {}, - TOutputSchema extends Schema | z.ZodType = {}, +/** + * @deprecated migrate to {@link NewTemplateActionOptions} + * @public + */ +export type OldTemplateActionOptions< + TInputParams extends JsonObject = JsonObject, + TOutputParams extends JsonObject = JsonObject, + TInputSchema extends Schema | z.ZodType = Schema, + TOutputSchema extends Schema | z.ZodType = Schema, + TActionInput extends JsonObject = TInputSchema extends z.ZodType< + any, + any, + infer IReturn + > + ? IReturn + : TInputParams, + TActionOutput extends JsonObject = TOutputSchema extends z.ZodType< + any, + any, + infer IReturn + > + ? IReturn + : TOutputParams, > = { id: string; description?: string; @@ -41,15 +59,69 @@ export type TemplateActionOptions< input?: TInputSchema; output?: TOutputSchema; }; - handler: (ctx: ActionContext) => Promise; + handler: ( + ctx: OldActionContext, + ) => Promise; }; +/** + * @public + */ +export type NewTemplateActionOptions< + TInputParams extends Record< + PropertyKey, + (zod: typeof z) => z.ZodType + > = Record z.ZodType>, + TOutputParams extends Record< + PropertyKey, + (zod: typeof z) => z.ZodType + > = Record z.ZodType>, +> = { + id: string; + description?: string; + examples?: TemplateExample[]; + supportsDryRun?: boolean; + schema: { + input: TInputParams; + output: TOutputParams; + }; + handler: ( + ctx: NewActionContext< + InferActionType, + InferActionType + >, + ) => Promise; +}; + +/** + * @public + */ +export type TemplateActionOptions< + TInputParams extends Record z.ZodType>, + TOutputParams extends Record z.ZodType>, +> = + | OldTemplateActionOptions + | NewTemplateActionOptions; + +function isZod(schema?: Schema | z.ZodType): schema is z.ZodType { + return !!(schema && 'safeParseAsync' in schema); +} + +function transformZodRecordToObject( + record: Record z.ZodType>, +): z.ZodObject> { + return z.object( + Object.fromEntries(Object.entries(record).map(([k, v]) => [k, v(z)])), + ); +} + /** * 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. + * @deprecated migrate to {@link newCreateTemplateAction} * @public */ -export const createTemplateAction = < +export function oldCreateTemplateAction< TInputParams extends JsonObject = JsonObject, TOutputParams extends JsonObject = JsonObject, TInputSchema extends Schema | z.ZodType = {}, @@ -69,29 +141,91 @@ export const createTemplateAction = < ? IReturn : TOutputParams, >( - action: TemplateActionOptions< - TActionInput, - TActionOutput, + action: OldTemplateActionOptions< + TInputParams, + TOutputParams, TInputSchema, - TOutputSchema + TOutputSchema, + TActionInput, + TActionOutput >, -): TemplateAction => { +): OldTemplateAction { const inputSchema = - action.schema?.input && 'safeParseAsync' in action.schema.input - ? zodToJsonSchema(action.schema.input) + action.schema && action.schema.input && isZod(action.schema.input) + ? (zodToJsonSchema(action.schema.input) as Schema) : action.schema?.input; const outputSchema = - action.schema?.output && 'safeParseAsync' in action.schema.output - ? zodToJsonSchema(action.schema.output) + action.schema && action.schema.output && isZod(action.schema.output) + ? (zodToJsonSchema(action.schema.output) as Schema) : action.schema?.output; return { ...action, schema: { ...action.schema, - input: inputSchema as TInputSchema, - output: outputSchema as TOutputSchema, + input: inputSchema, + output: outputSchema, }, }; -}; +} + +/** + * 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 function newCreateTemplateAction< + TInputParams extends Record z.ZodType>, + TOutputParams extends Record z.ZodType>, +>( + action: NewTemplateActionOptions, +): NewTemplateAction< + InferActionType, + InferActionType +> { + const input = transformZodRecordToObject(action.schema.input); + const output = transformZodRecordToObject(action.schema.output); + + return { + ...action, + schema: { + ...action.schema, + input: zodToJsonSchema(input) as Schema, + output: zodToJsonSchema(output) as Schema, + }, + }; +} + +function isOldAction( + action: OldTemplateActionOptions | NewTemplateActionOptions, +): action is OldTemplateActionOptions { + return ( + isZod(action.schema?.input) || + typeof action.schema?.input === 'string' || + isZod(action.schema?.output) || + typeof action.schema?.output === 'string' + ); +} + +/** + * 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 function createTemplateAction< + TInputParams extends JsonObject = JsonObject, + TOutputParams extends JsonObject = JsonObject, + TAction extends + | OldTemplateActionOptions + | NewTemplateActionOptions = OldTemplateActionOptions, + TReturn = TAction extends OldTemplateActionOptions + ? Prettify> + : Prettify, +>(action: TAction): TReturn { + if (isOldAction(action)) { + return oldCreateTemplateAction(action) as TReturn; + } + + return newCreateTemplateAction(action) as TReturn; +} diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts index c1c8551c53..9ac9d9a5a4 100644 --- a/plugins/scaffolder-node/src/actions/index.ts +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -16,21 +16,31 @@ export { createTemplateAction, + type NewTemplateActionOptions, + type OldTemplateActionOptions, type TemplateActionOptions, - type TemplateExample, } from './createTemplateAction'; export { executeShellCommand, type ExecuteShellCommandOptions, } from './executeShellCommand'; export { fetchContents, fetchFile } from './fetch'; -export { type ActionContext, type TemplateAction } from './types'; export { - initRepoAndPush, - commitAndPushRepo, - commitAndPushBranch, addFiles, - createBranch, cloneRepo, + commitAndPushBranch, + commitAndPushRepo, + createBranch, + initRepoAndPush, } from './gitHelpers'; -export { parseRepoUrl, getRepoSourceDirectory } from './util'; +export type { + ActionContext, + InferActionType, + NewActionContext, + NewTemplateAction, + OldActionContext, + OldTemplateAction, + TemplateAction, + TemplateExample, +} from './types'; +export { getRepoSourceDirectory, parseRepoUrl } from './util'; diff --git a/plugins/scaffolder-node/src/actions/types.ts b/plugins/scaffolder-node/src/actions/types.ts index 171d3d82db..e554baf2f7 100644 --- a/plugins/scaffolder-node/src/actions/types.ts +++ b/plugins/scaffolder-node/src/actions/types.ts @@ -14,21 +14,45 @@ * limitations under the License. */ -import { Logger } from 'winston'; -import { Writable } from 'stream'; -import { JsonObject, JsonValue } from '@backstage/types'; -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 type { + BackstageCredentials, + LoggerService, +} from '@backstage/backend-plugin-api'; +import type { UserEntity } from '@backstage/catalog-model'; +import type { TemplateInfo } from '@backstage/plugin-scaffolder-common'; +import type { JsonObject, JsonValue, Prettify } from '@backstage/types'; +import type { Schema } from 'jsonschema'; +import type { Writable } from 'stream'; +import type { Logger } from 'winston'; +import { z } from 'zod'; +import type { TaskSecrets } from '../tasks'; /** - * ActionContext is passed into scaffolder actions. * @public */ -export type ActionContext< - TActionInput extends JsonObject, +export type TemplateExample = { + description: string; + example: string; +}; + +/** @public */ +export type InferActionType< + T extends Record z.ZodType>, +> = Prettify<{ + [K in keyof T]: T[K] extends ( + zod: typeof z, + ) => z.ZodType + ? Extract + : never; +}>; + +/** + * OldActionContext is passed into scaffolder actions. + * @deprecated migrate to {@link NewActionContext} + * @public + */ +export type OldActionContext< + TActionInput extends JsonObject = JsonObject, TActionOutput extends JsonObject = JsonObject, > = { // TODO(blam): move this to LoggerService @@ -43,8 +67,10 @@ export type ActionContext< fn: () => Promise | T; }): Promise; output( - name: keyof TActionOutput, - value: TActionOutput[keyof TActionOutput], + ...params: { + /* This maps the key to the value for type checking */ + [K in keyof TActionOutput]: [name: K, value: TActionOutput[K]]; + }[keyof TActionOutput] ): void; /** @@ -97,18 +123,129 @@ export type ActionContext< each?: JsonObject; }; -/** @public */ -export type TemplateAction< +/** + * NewActionContext is passed into scaffolder actions. + * @public + */ +export type NewActionContext< + TActionInput extends JsonObject = JsonObject, + TActionOutput extends JsonObject = JsonObject, +> = { + logger: LoggerService; + secrets?: TaskSecrets; + workspacePath: string; + input: TActionInput; + checkpoint( + key: string, + fn: () => Promise, + ): Promise; + output( + ...params: { + /* This maps the key to the value for type checking */ + [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; + + /** + * Get the credentials for the current request + */ + getInitiatorCredentials(): Promise; + + 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 ActionContext< + TActionInput extends JsonObject = JsonObject, + TActionOutput extends JsonObject = JsonObject, +> = + | OldActionContext + | NewActionContext; + +/** + * @deprecated migrate to {@link NewTemplateAction} + * @public + */ +export type OldTemplateAction< TActionInput extends JsonObject = JsonObject, TActionOutput extends JsonObject = JsonObject, > = { id: string; description?: string; - examples?: { description: string; example: string }[]; + examples?: TemplateExample[]; supportsDryRun?: boolean; schema?: { input?: Schema; output?: Schema; }; - handler: (ctx: ActionContext) => Promise; + handler: ( + ctx: OldActionContext, + ) => Promise; }; + +/** + * @public + */ +export type NewTemplateAction< + TActionInput extends JsonObject = JsonObject, + TActionOutput extends JsonObject = JsonObject, +> = { + id: string; + description?: string; + examples?: TemplateExample[]; + supportsDryRun?: boolean; + schema: { + input: Schema; + output: Schema; + }; + handler: ( + ctx: NewActionContext, + ) => Promise; +}; + +/** + * @public + */ +export type TemplateAction< + TActionInput extends JsonObject = JsonObject, + TActionOutput extends JsonObject = JsonObject, +> = + | OldTemplateAction + | NewTemplateAction;