feat(scaffolder): add first class citizen support for zod

Signed-off-by: Paul Schultz <pschultz@pobox.com>

backport better typescript support

Signed-off-by: Paul Schultz <pschultz@pobox.com>

wip

Signed-off-by: Paul Schultz <pschultz@pobox.com>

wip

Signed-off-by: Paul Schultz <pschultz@pobox.com>

complete refactor

Signed-off-by: Paul Schultz <pschultz@pobox.com>
This commit is contained in:
Paul Schultz
2024-10-03 14:30:19 -05:00
committed by benjdlambert
parent 6bc81c78e7
commit 1a588465e9
7 changed files with 391 additions and 53 deletions
+7
View File
@@ -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.
+7
View File
@@ -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.
+5
View File
@@ -79,6 +79,11 @@ export type Observer<T> = {
complete?(): void;
};
// @public
export type Prettify<T extends Record<PropertyKey, unknown>> = {
[K in keyof T]: T[K];
} & {};
// @public
export type Subscription = {
unsubscribe(): void;
+38
View File
@@ -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<T extends Record<PropertyKey, unknown>> = {
[K in keyof T]: T[K];
} & {};
@@ -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<TActionInput, TActionOutput>) => Promise<void>;
handler: (
ctx: OldActionContext<TActionInput, TActionOutput>,
) => Promise<void>;
};
/**
* @public
*/
export type NewTemplateActionOptions<
TInputParams extends Record<
PropertyKey,
(zod: typeof z) => z.ZodType
> = Record<PropertyKey, (zod: typeof z) => z.ZodType>,
TOutputParams extends Record<
PropertyKey,
(zod: typeof z) => z.ZodType
> = Record<PropertyKey, (zod: typeof z) => z.ZodType>,
> = {
id: string;
description?: string;
examples?: TemplateExample[];
supportsDryRun?: boolean;
schema: {
input: TInputParams;
output: TOutputParams;
};
handler: (
ctx: NewActionContext<
InferActionType<TInputParams>,
InferActionType<TOutputParams>
>,
) => Promise<void>;
};
/**
* @public
*/
export type TemplateActionOptions<
TInputParams extends Record<PropertyKey, (zod: typeof z) => z.ZodType>,
TOutputParams extends Record<PropertyKey, (zod: typeof z) => z.ZodType>,
> =
| OldTemplateActionOptions
| NewTemplateActionOptions<TInputParams, TOutputParams>;
function isZod(schema?: Schema | z.ZodType): schema is z.ZodType {
return !!(schema && 'safeParseAsync' in schema);
}
function transformZodRecordToObject(
record: Record<PropertyKey, (zod: typeof z) => z.ZodType>,
): z.ZodObject<Record<PropertyKey, z.ZodType>> {
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<TActionInput, TActionOutput> => {
): OldTemplateAction<TActionInput, TActionOutput> {
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<PropertyKey, (zod: typeof z) => z.ZodType>,
TOutputParams extends Record<PropertyKey, (zod: typeof z) => z.ZodType>,
>(
action: NewTemplateActionOptions<TInputParams, TOutputParams>,
): NewTemplateAction<
InferActionType<TInputParams>,
InferActionType<TOutputParams>
> {
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<OldTemplateAction<TInputParams, TOutputParams>>
: Prettify<NewTemplateAction>,
>(action: TAction): TReturn {
if (isOldAction(action)) {
return oldCreateTemplateAction(action) as TReturn;
}
return newCreateTemplateAction(action) as TReturn;
}
+17 -7
View File
@@ -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';
+154 -17
View File
@@ -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<PropertyKey, (zod: typeof z) => z.ZodType>,
> = Prettify<{
[K in keyof T]: T[K] extends (
zod: typeof z,
) => z.ZodType<any, any, infer IReturn>
? Extract<IReturn, JsonValue | undefined>
: 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> | T;
}): Promise<T>;
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<U extends JsonValue>(
key: string,
fn: () => Promise<U>,
): Promise<U>;
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<string>;
/**
* Get the credentials for the current request
*/
getInitiatorCredentials(): Promise<BackstageCredentials>;
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<TActionInput, TActionOutput>
| NewActionContext<TActionInput, TActionOutput>;
/**
* @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<TActionInput, TActionOutput>) => Promise<void>;
handler: (
ctx: OldActionContext<TActionInput, TActionOutput>,
) => Promise<void>;
};
/**
* @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<TActionInput, TActionOutput>,
) => Promise<void>;
};
/**
* @public
*/
export type TemplateAction<
TActionInput extends JsonObject = JsonObject,
TActionOutput extends JsonObject = JsonObject,
> =
| OldTemplateAction<TActionInput, TActionOutput>
| NewTemplateAction<TActionInput, TActionOutput>;