mirror of
https://github.com/Kir-Antipov/mc-publish.git
synced 2024-11-25 09:51:01 -05:00
Refactored logic focused on Action inputs
This commit is contained in:
parent
cd1c9650f3
commit
51993a38bf
2 changed files with 367 additions and 142 deletions
367
src/utils/actions/action-input.ts
Normal file
367
src/utils/actions/action-input.ts
Normal file
|
@ -0,0 +1,367 @@
|
|||
import { runSafely } from "@/utils/async-utils";
|
||||
import { $i, asArray } from "@/utils/collections";
|
||||
import { Converter, toType } from "@/utils/convert";
|
||||
import { getAllEnvironmentVariables, getEnvironmentVariable, setEnvironmentVariable } from "@/utils/environment";
|
||||
import { QueryString } from "@/utils/net";
|
||||
import { ModuleLoader, NODE_MODULE_LOADER, defineNestedProperty, executeImportDirective } from "@/utils/reflection";
|
||||
import { split, stringEquals } from "@/utils/string-utils";
|
||||
import { ActionInputDescriptor, getActionInputDescriptors } from "./action-input-descriptor";
|
||||
import { ActionMetadata } from "./action-metadata";
|
||||
import { ActionParameter, normalizeActionParameterName } from "./action-parameter";
|
||||
import { ActionParameterDescriptorExtractionOptions } from "./action-parameter-descriptor";
|
||||
import { ActionParameterFactoryOptions } from "./action-parameter-factory-options";
|
||||
import { ActionParameterTypeDescriptor } from "./action-parameter-type-descriptor";
|
||||
|
||||
/**
|
||||
* Input parameters allow you to specify data that the action expects to use during runtime.
|
||||
*
|
||||
* GitHub stores input parameters as environment variables.
|
||||
* Input ids with uppercase letters are converted to lowercase during runtime.
|
||||
*/
|
||||
export interface ActionInput extends ActionParameter {
|
||||
/**
|
||||
* A boolean indicating whether the input parameter is required.
|
||||
* If `true`, the action will fail if the parameter is not provided.
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* The default value to use when the input parameter is not specified in the workflow file.
|
||||
* This value is used if `required` is `false`.
|
||||
*/
|
||||
default?: string | number | boolean;
|
||||
|
||||
/**
|
||||
* A message to display if the input parameter is used, indicating that it is deprecated and suggesting alternatives.
|
||||
*/
|
||||
deprecationMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the way input retrieving works.
|
||||
*/
|
||||
export interface InputOptions {
|
||||
/**
|
||||
* Indicates whether the input is required.
|
||||
* If required and not present, will throw.
|
||||
*
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether leading/trailing whitespace will be trimmed for the input.
|
||||
*
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
trimWhitespace?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the way input object retrieving works.
|
||||
*/
|
||||
export interface InputObjectOptions extends InputOptions {
|
||||
/**
|
||||
* The module loader to use for loading modules.
|
||||
*/
|
||||
moduleLoader?: ModuleLoader;
|
||||
|
||||
/**
|
||||
* The converter to use for converting values.
|
||||
*/
|
||||
converter?: Converter;
|
||||
}
|
||||
|
||||
/**
|
||||
* A synthetic string used to represent an undefined input value in the context of GitHub Actions.
|
||||
*
|
||||
* This value is used because inputs with an empty string value and inputs that were not supplied
|
||||
* are indistinguishable in the context of GitHub Actions. Therefore, this synthetic value is used
|
||||
* to represent undefined input values, allowing for a clear distinction between empty and undefined
|
||||
* values.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Yeah, it seems that Microsoft didn't think that 2 already existing values that
|
||||
* represent absence of any object value in slightly different ways quite cut it,
|
||||
* so for their GitHub Actions they invented a brand new one!
|
||||
* Rejoice and greet an, I'm sorry, THE empty string!
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Someone at Microsoft was like:
|
||||
*
|
||||
* - undefined === null == "" // true
|
||||
* - Hm, seems legit
|
||||
*
|
||||
*/
|
||||
export const SYNTHETIC_UNDEFINED = "${undefined}";
|
||||
|
||||
/**
|
||||
* The prefix used to identify GitHub Action inputs in the environment variables.
|
||||
*/
|
||||
const INPUT_PREFIX = "INPUT_";
|
||||
|
||||
/**
|
||||
* Sets the value of a GitHub Action input by setting an environment variable.
|
||||
*
|
||||
* @param name - The name of the input to set.
|
||||
* @param value - The value to set for the input.
|
||||
* @param env - An optional set of the environment variables to update. Defaults to `process.env`.
|
||||
*/
|
||||
export function setActionInput(name: string, value: unknown, env?: Record<string, string>): void {
|
||||
const normalizedName = normalizeActionParameterName(name);
|
||||
const environmentVariableName = INPUT_PREFIX + normalizedName;
|
||||
const stringifiedValue = value === undefined || value === SYNTHETIC_UNDEFINED
|
||||
? undefined
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value);
|
||||
|
||||
setEnvironmentVariable(environmentVariableName, stringifiedValue, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the values of multiple GitHub Action inputs by setting their environment variables.
|
||||
*
|
||||
* @param inputs - An iterable object of pairs, where the first item is the input parameter name, and the second item is the input parameter value.
|
||||
* @param env - An optional set of the environment variables to update. Defaults to `process.env`.
|
||||
*/
|
||||
export function setActionInputs(inputs: Iterable<readonly [string, unknown]>, env?: Record<string, string>): void {
|
||||
for (const [name, value] of inputs) {
|
||||
setActionInput(name, value, env);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of an input.
|
||||
*
|
||||
* @param name - Name of the input to get.
|
||||
* @param options - Options to configure the way input retrieving works.
|
||||
* @param env - An optional set of the environment variables to search within. Defaults to `process.env`.
|
||||
*
|
||||
* @returns The value of the input, or `undefined` if it was not provided.
|
||||
*
|
||||
* @throws An error if the `options.required` flag is set to `true` and the input is not defined.
|
||||
*/
|
||||
export function getActionInput(name: string, options?: InputOptions, env?: Record<string, string>): string | undefined {
|
||||
const normalizedName = normalizeActionParameterName(name);
|
||||
const environmentVariableName = INPUT_PREFIX + normalizedName;
|
||||
const brokenValue = getEnvironmentVariable(environmentVariableName, env);
|
||||
const value = isActionInputDefined(brokenValue) ? brokenValue : undefined;
|
||||
const trimmedValue = (options?.trimWhitespace ?? true) ? value?.trim() : value
|
||||
|
||||
if (options?.required && value === undefined) {
|
||||
throw new Error(`Input required and not supplied: ${name}.`);
|
||||
}
|
||||
|
||||
return trimmedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the values of multiple inputs.
|
||||
*
|
||||
* @param names - Names of the inputs to get.
|
||||
* @param options - Options to configure the way input retrieving works.
|
||||
* @param env - An optional set of the environment variables to search within. Defaults to `process.env`.
|
||||
*
|
||||
* @returns An array of the values of the inputs. The order of the values matches the order of the input names in the `names` parameter.
|
||||
* @throws An error if the `options.required` flag is set to `true` and one of the inputs is not defined.
|
||||
*/
|
||||
export function getActionInputs(names: Iterable<string>, options?: InputOptions, env?: Record<string, string>): string[] {
|
||||
return $i(names).map(name => getActionInput(name, options, env)).toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map containing all inputs provided to the action.
|
||||
*
|
||||
* @param options - Options to configure the way input retrieving works.
|
||||
* @param env - An optional set of the environment variables to search within. Defaults to `process.env`.
|
||||
*
|
||||
* @returns A map of input names and their corresponding values.
|
||||
* @throws An error if the `options.required` flag is set to `true` and one of the inputs is not defined.
|
||||
*/
|
||||
export function getAllActionInputs(options?: InputOptions, env?: Record<string, string>): Map<string, string> {
|
||||
const inputs = new Map<string, string>();
|
||||
const required = options?.required;
|
||||
const trimWhitespace = options?.trimWhitespace ?? true;
|
||||
|
||||
for (const [name, value] of getAllEnvironmentVariables(env)) {
|
||||
if (!name.startsWith(INPUT_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const inputName = name.substring(INPUT_PREFIX.length);
|
||||
const isValueDefined = isActionInputDefined(value);
|
||||
|
||||
if (required && !isValueDefined) {
|
||||
throw new Error(`Input required and not supplied: ${inputName}.`);
|
||||
}
|
||||
|
||||
if (!isValueDefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const inputValue = trimWhitespace ? value.trim() : value;
|
||||
inputs.set(inputName, inputValue);
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the provided value is a defined input value.
|
||||
*
|
||||
* @param value - The value to check.
|
||||
*
|
||||
* @returns `true` if the value is a defined input value; otherwise, `false`.
|
||||
*/
|
||||
function isActionInputDefined(value: unknown): value is string {
|
||||
return typeof value === "string" && value !== SYNTHETIC_UNDEFINED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all action inputs, converts them to the specified types, and returns them as an object.
|
||||
*
|
||||
* @template T - The expected type of the resulting object.
|
||||
*
|
||||
* @param descriptors - An iterable of action input descriptors.
|
||||
* @param options - Options for customizing the input object creation.
|
||||
* @param env - An optional set of the environment variables to search within. Defaults to `process.env`.
|
||||
*
|
||||
* @returns A promise that resolves to an object containing the processed inputs.
|
||||
*/
|
||||
export async function getAllActionInputsAsObject<T = unknown>(descriptors: Iterable<ActionInputDescriptor>, options?: InputObjectOptions, env?: Record<string, string>): Promise<T> {
|
||||
const moduleLoader = options?.moduleLoader || NODE_MODULE_LOADER;
|
||||
const converter = options?.converter || toType;
|
||||
|
||||
const descriptorArray = asArray(descriptors);
|
||||
const inputs = getAllActionInputs(options, env);
|
||||
const inputObject = { } as T;
|
||||
|
||||
for (const [name, value] of inputs) {
|
||||
const descriptor = descriptorArray.find(d => stringEquals(d.name, name, { ignoreCase: true }));
|
||||
const targetDescriptor = descriptor?.redirect ? descriptorArray.find(d => d.name === descriptor.redirect) : descriptor;
|
||||
if (!targetDescriptor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedValue = await parseInput(value, descriptor.type, moduleLoader, converter);
|
||||
if (parsedValue === undefined) {
|
||||
throw new Error(`Cannot convert "${descriptor.name}" to "${descriptor.type.name}".`);
|
||||
}
|
||||
|
||||
defineNestedProperty(inputObject, targetDescriptor.path, { value: parsedValue, writable: true, configurable: true, enumerable: true });
|
||||
}
|
||||
|
||||
return inputObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all action inputs using metadata, converts them to the specified types, and returns them as an object.
|
||||
*
|
||||
* @template T - The expected type of the resulting object.
|
||||
*
|
||||
* @param metadata - The metadata of the action.
|
||||
* @param options - Options for customizing the input object creation and descriptor extraction.
|
||||
* @param env - An optional set of the environment variables to search within. Defaults to `process.env`.
|
||||
*
|
||||
* @returns A promise that resolves to an object containing the processed inputs.
|
||||
*/
|
||||
export async function getAllActionInputsAsObjectUsingMetadata<T = unknown>(metadata: ActionMetadata, options?: InputObjectOptions & ActionParameterDescriptorExtractionOptions, env?: Record<string, string>): Promise<T> {
|
||||
const descriptors = getActionInputDescriptors(metadata, options);
|
||||
return await getAllActionInputsAsObject(descriptors, options, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an input value using the specified type descriptor, module loader, and converter function.
|
||||
*
|
||||
* @param value - The input value to parse.
|
||||
* @param type - The type descriptor for the input.
|
||||
* @param moduleLoader - The module loader to use when loading modules.
|
||||
* @param converter - The converter function to use when converting the input value.
|
||||
*
|
||||
* @returns A promise that resolves to the parsed input value.
|
||||
*/
|
||||
async function parseInput(value: string, type: ActionParameterTypeDescriptor, moduleLoader: ModuleLoader, converter: Converter): Promise<unknown> {
|
||||
const shouldSplit = type.options?.getBoolean(ActionParameterFactoryOptions.SPLIT) ?? type.isArray;
|
||||
const parse = shouldSplit ? parseMultipleInputs : parseSingleInput;
|
||||
|
||||
return await parse(value, type, moduleLoader, converter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses multiple input values using the specified type descriptor, module loader, and converter function.
|
||||
*
|
||||
* @param value - The input value to parse.
|
||||
* @param type - The type descriptor for the input.
|
||||
* @param moduleLoader - The module loader to use when loading modules.
|
||||
* @param converter - The converter function to use when converting the input value.
|
||||
*
|
||||
* @returns A promise that resolves to the parsed input values.
|
||||
*/
|
||||
async function parseMultipleInputs(value: string, type: ActionParameterTypeDescriptor, moduleLoader: ModuleLoader, converter: Converter): Promise<unknown> {
|
||||
const separator = type.options?.getRegExp(ActionParameterFactoryOptions.SEPARATOR) ?? /\r?\n/g;
|
||||
const processSeparately = type.options?.getBoolean(ActionParameterFactoryOptions.PROCESS_SEPARATELY) ?? true;
|
||||
const trimEntries = type.options?.getBoolean(ActionParameterFactoryOptions.TRIM_ENTRIES) ?? true;
|
||||
const removeEmptyEntries = type.options?.getBoolean(ActionParameterFactoryOptions.REMOVE_EMPTY_ENTRIES) ?? true;
|
||||
const flatDepth = type.options?.getNumber(ActionParameterFactoryOptions.FLAT_DEPTH) ?? 1;
|
||||
|
||||
const values = split(value, separator, { trimEntries, removeEmptyEntries });
|
||||
|
||||
if (!processSeparately) {
|
||||
return await parseSingleInput(values, type, moduleLoader, converter);
|
||||
}
|
||||
|
||||
const processedValues = await Promise.all(values.map(v => parseSingleInput(v, type, moduleLoader, converter)));
|
||||
const flattenedValues = processedValues.flat(flatDepth);
|
||||
return flattenedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single input value using the specified type descriptor, module loader, and converter function.
|
||||
*
|
||||
* @param value - The input value to parse.
|
||||
* @param type - The type descriptor for the input.
|
||||
* @param moduleLoader - The module loader to use when loading modules.
|
||||
* @param converter - The converter function to use when converting the input value.
|
||||
*
|
||||
* @returns A promise that resolves to the parsed input value.
|
||||
*/
|
||||
async function parseSingleInput(value: string | string[], type: ActionParameterTypeDescriptor, moduleLoader: ModuleLoader, converter: Converter): Promise<unknown> {
|
||||
// Simple cases like "string", "number", "Date".
|
||||
// Should be handled by the `converter` function.
|
||||
if (!type.factory && !type.module) {
|
||||
return await converter(value, type.name);
|
||||
}
|
||||
|
||||
const typeImport = await executeImportDirective(type, { moduleLoader, required: false });
|
||||
|
||||
// The `factory` function was specified.
|
||||
// Therefore, it should be used to process the input.
|
||||
if (type.factory) {
|
||||
const factoryImport = await executeImportDirective<(v: string | string[], o?: QueryString) => unknown>(type.factory, {
|
||||
moduleLoader,
|
||||
defaultModuleProvider: d => Promise.resolve(d.isDefault ? (typeImport?.value ?? globalThis) : globalThis),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return await factoryImport.value(value, type.options);
|
||||
}
|
||||
|
||||
// The only hope we have is that `converter` function will be able to process the input
|
||||
// using the target type or its module themselves.
|
||||
//
|
||||
// This is usually the case when a type has a dedicated `parse`- or `convert`-like module,
|
||||
// or one those is specified on the module itself.
|
||||
const conversionMethodContainers = [typeImport?.value, typeImport?.module].filter(x => x);
|
||||
for (const target of conversionMethodContainers) {
|
||||
const [convertedValue] = await runSafely(() => converter(value, target));
|
||||
if (convertedValue !== undefined) {
|
||||
return convertedValue;
|
||||
}
|
||||
}
|
||||
|
||||
// None of the above strategies worked.
|
||||
// Let the caller deal with it.
|
||||
return undefined;
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
import process from "process";
|
||||
|
||||
interface EnumLike<T = number> {
|
||||
[i: string | number | symbol]: T | string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface InputObject extends Record<string, string | InputObject> { }
|
||||
|
||||
const undefinedValue = "${undefined}";
|
||||
|
||||
export function getInputAsObject(): Record<string, InputObject> {
|
||||
const inputs = Object.entries(process.env).filter(([key, _]) => key.startsWith("INPUT_"));
|
||||
const input = {};
|
||||
for (const [name, value] of inputs) {
|
||||
const words = name.substring(6).toLowerCase().split(/[\W_]/).filter(x => x);
|
||||
init(input, words, value);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function init(root: InputObject, path: string[], value: string): void {
|
||||
if (value === undefinedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = path.reduce((a, b, i) => a + (i === 0 ? b : (b.substring(0, 1).toUpperCase() + b.substring(1))), "");
|
||||
root[name] = value;
|
||||
if (path.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const innerPath = path[0];
|
||||
const inner = root[innerPath] ? root[innerPath] : (root[innerPath] = {});
|
||||
if (typeof inner === "object") {
|
||||
init(inner, path.slice(1), value);
|
||||
}
|
||||
}
|
||||
|
||||
export function mapStringInput(value: any, defaultValue = ""): string {
|
||||
return mapInput(value, defaultValue, null, "string");
|
||||
}
|
||||
|
||||
export function mapObjectInput(value: any, defaultValue: object = null): object {
|
||||
return mapInput(value, defaultValue, null, "object");
|
||||
}
|
||||
|
||||
export function mapNumberInput(value: any, defaultValue = 0): number {
|
||||
return mapInput(value, defaultValue, {
|
||||
string: x => {
|
||||
const num = +x;
|
||||
return isNaN(num) ? undefined : num;
|
||||
}
|
||||
}, "number");
|
||||
}
|
||||
|
||||
export function mapBooleanInput(value: any, defaultValue = false): boolean {
|
||||
return mapInput(value, defaultValue, {
|
||||
string: x => {
|
||||
const strValue = x.trim().toLowerCase();
|
||||
return (
|
||||
strValue === "true" ? true :
|
||||
strValue === "false" ? false :
|
||||
undefined
|
||||
);
|
||||
}
|
||||
}, "boolean");
|
||||
}
|
||||
|
||||
function findEnumValueByName<T extends EnumLike<U>, U>(enumClass: T, name: string): U | undefined {
|
||||
if (typeof enumClass[+name] === "string") {
|
||||
return <U><unknown>+name;
|
||||
}
|
||||
|
||||
if (enumClass[name] !== undefined) {
|
||||
return <U>enumClass[name];
|
||||
}
|
||||
|
||||
const entries = Object.entries(enumClass);
|
||||
for (const [key, value] of entries) {
|
||||
if (key.localeCompare(name, undefined, { sensitivity: "base" }) === 0) {
|
||||
return <U>value;
|
||||
}
|
||||
}
|
||||
for (const [key, value] of entries) {
|
||||
if (key.trim().replace(/[-_]/g, "").localeCompare(name.trim().replace(/[-_]/g, ""), undefined, { sensitivity: "base" }) === 0) {
|
||||
return <U>value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function mapEnumInput<T>(value: any, enumClass: any, defaultValue?: T): T;
|
||||
|
||||
export function mapEnumInput<T extends EnumLike<U>, U>(value: any, enumClass: T, defaultValue?: U): U;
|
||||
|
||||
export function mapEnumInput<T extends EnumLike<U>, U>(value: any, enumClass: T, defaultValue: U = null): U | null {
|
||||
return mapInput(value, defaultValue, {
|
||||
string: (x: string) => {
|
||||
let result: U = undefined;
|
||||
|
||||
let i = 0;
|
||||
while (i < x.length) {
|
||||
let separatorIndex = x.indexOf("|", i);
|
||||
if (separatorIndex === -1) {
|
||||
separatorIndex = x.length;
|
||||
}
|
||||
|
||||
const currentValue = findEnumValueByName<T, U>(enumClass, x.substring(i, separatorIndex));
|
||||
if (result === undefined || currentValue !== undefined && typeof currentValue !== "number") {
|
||||
result = currentValue;
|
||||
} else {
|
||||
result = <U><unknown>(<number><unknown>result | <number><unknown>currentValue);
|
||||
}
|
||||
|
||||
i = separatorIndex + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}, "number");
|
||||
}
|
||||
|
||||
export function mapInput<T>(value: any, fallbackValue?: T, mappers?: Record<string, (x: any) => T | undefined>, valueType?: string): T {
|
||||
if (value === undefinedValue || value === undefined || value === null) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
valueType ??= typeof fallbackValue;
|
||||
if (typeof value === valueType) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const mapper = mappers?.[typeof value];
|
||||
if (mapper) {
|
||||
const mappedValue = mapper(value);
|
||||
if (typeof mappedValue === valueType) {
|
||||
return mappedValue;
|
||||
}
|
||||
}
|
||||
return fallbackValue;
|
||||
}
|
Loading…
Reference in a new issue