UNPKG

@metamask/snaps-utils

Version:
301 lines 12.3 kB
import { union } from "@metamask/snaps-sdk"; import { assign, is, validate, type as superstructType, Struct, StructError, create } from "@metamask/superstruct"; import { assert, isObject } from "@metamask/utils"; import $chalk from "chalk"; const { bold, green, red } = $chalk; import { indent } from "./strings.mjs"; /** * Colorize a value with a color function. This is useful for colorizing values * in error messages. If colorization is disabled, the original value is * returned. * * @param value - The value to colorize. * @param colorFunction - The color function to use. * @param enabled - Whether to colorize the value. * @returns The colorized value, or the original value if colorization is * disabled. */ function color(value, colorFunction, enabled) { if (enabled) { return colorFunction(value); } return value; } /** * Define a struct, and also define the name of the struct as the given name. * * This is useful for improving the error messages returned by `superstruct`. * * @param name - The name of the struct. * @param struct - The struct. * @returns The struct. */ export function named(name, struct) { return new Struct({ ...struct, type: name, }); } export class SnapsStructError extends StructError { constructor(struct, prefix, suffix, failure, failures, colorize = true) { super(failure, failures); this.name = 'SnapsStructError'; this.message = `${prefix}.\n\n${getStructErrorMessage(struct, [...failures()], colorize)}${suffix ? `\n\n${suffix}` : ''}`; } } /** * Converts an array to a generator function that yields the items in the * array. * * @param array - The array. * @returns A generator function. * @yields The items in the array. */ export function* arrayToGenerator(array) { for (const item of array) { yield item; } } /** * Returns a `SnapsStructError` with the given prefix and suffix. * * @param options - The options. * @param options.struct - The struct that caused the error. * @param options.prefix - The prefix to add to the error message. * @param options.suffix - The suffix to add to the error message. Defaults to * an empty string. * @param options.error - The `superstruct` error to wrap. * @param options.colorize - Whether to colorize the value. Defaults to `true`. * @returns The `SnapsStructError`. */ export function getError({ struct, prefix, suffix = '', error, colorize, }) { return new SnapsStructError(struct, prefix, suffix, error, () => arrayToGenerator(error.failures()), colorize); } /** * A wrapper of `superstruct`'s `create` function that throws a * `SnapsStructError` instead of a `StructError`. This is useful for improving * the error messages returned by `superstruct`. * * @param value - The value to validate. * @param struct - The `superstruct` struct to validate the value against. * @param prefix - The prefix to add to the error message. * @param suffix - The suffix to add to the error message. Defaults to an empty * string. * @returns The validated value. */ export function createFromStruct(value, struct, prefix, suffix = '') { try { return create(value, struct); } catch (error) { if (error instanceof StructError) { throw getError({ struct, prefix, suffix, error }); } throw error; } } /** * Get a struct from a failure path. * * @param struct - The struct. * @param path - The failure path. * @returns The struct at the failure path. */ export function getStructFromPath(struct, path) { return path.reduce((result, key) => { if (isObject(struct.schema) && struct.schema[key]) { return struct.schema[key]; } return result; }, struct); } /** * Get the union struct names from a struct. * * @param struct - The struct. * @param colorize - Whether to colorize the value. Defaults to `true`. * @returns The union struct names, or `null` if the struct is not a union * struct. */ export function getUnionStructNames(struct, colorize = true) { if (Array.isArray(struct.schema)) { return struct.schema.map(({ type }) => color(type, green, colorize)); } return null; } /** * Get an error prefix from a `superstruct` failure. This is useful for * formatting the error message returned by `superstruct`. * * @param failure - The `superstruct` failure. * @param colorize - Whether to colorize the value. Defaults to `true`. * @returns The error prefix. */ export function getStructErrorPrefix(failure, colorize = true) { if (failure.type === 'never' || failure.path.length === 0) { return ''; } return `At path: ${color(failure.path.join('.'), bold, colorize)} — `; } /** * Get a string describing the failure. This is similar to the `message` * property of `superstruct`'s `Failure` type, but formats the value in a more * readable way. * * @param struct - The struct that caused the failure. * @param failure - The `superstruct` failure. * @param colorize - Whether to colorize the value. Defaults to `true`. * @returns A string describing the failure. */ export function getStructFailureMessage(struct, failure, colorize = true) { const received = color(JSON.stringify(failure.value), red, colorize); const prefix = getStructErrorPrefix(failure, colorize); if (failure.type === 'union') { const childStruct = getStructFromPath(struct, failure.path); const unionNames = getUnionStructNames(childStruct, colorize); if (unionNames) { return `${prefix}Expected the value to be one of: ${unionNames.join(', ')}, but received: ${received}.`; } return `${prefix}${failure.message}.`; } if (failure.type === 'literal') { // Superstruct's failure does not provide information about which literal // value was expected, so we need to parse the message to get the literal. const message = failure.message .replace(/the literal `(.+)`,/u, `the value to be \`${color('$1', green, colorize)}\`,`) .replace(/, but received: (.+)/u, `, but received: ${color('$1', red, colorize)}`); return `${prefix}${message}.`; } if (failure.type === 'never') { return `Unknown key: ${color(failure.path.join('.'), bold, colorize)}, received: ${received}.`; } if (failure.refinement === 'size') { const message = failure.message .replace(/length between `(\d+)` and `(\d+)`/u, `length between ${color('$1', green, colorize)} and ${color('$2', green, colorize)},`) .replace(/length of `(\d+)`/u, `length of ${color('$1', red, colorize)}`) .replace(/a array/u, 'an array'); return `${prefix}${message}.`; } // Refinements we built ourselves have nice error messages if (failure.refinement !== undefined) { return `${prefix}${failure.message}.`; } return `${prefix}Expected a value of type ${color(failure.type, green, colorize)}, but received: ${received}.`; } /** * Get a string describing the errors. This formats all the errors in a * human-readable way. * * @param struct - The struct that caused the failures. * @param failures - The `superstruct` failures. * @param colorize - Whether to colorize the value. Defaults to `true`. * @returns A string describing the errors. */ export function getStructErrorMessage(struct, failures, colorize = true) { const formattedFailures = failures.map((failure) => indent(`• ${getStructFailureMessage(struct, failure, colorize)}`)); return formattedFailures.join('\n'); } /** * Validate a union struct, and throw readable errors if the value does not * satisfy the struct. This is useful for improving the error messages returned * by `superstruct`. * * @param value - The value to validate. * @param struct - The `superstruct` union struct to validate the value against. * This struct must be a union of object structs, and must have at least one * shared key to validate against. * @param structKey - The key to validate against. This key must be present in * all the object structs in the union struct, and is expected to be a literal * value. * @param coerce - Whether to coerce the value to satisfy the struct. Defaults * to `false`. * @returns The validated value. * @throws If the value does not satisfy the struct. * @example * const struct = union([ * object({ type: literal('a'), value: string() }), * object({ type: literal('b'), value: number() }), * object({ type: literal('c'), value: boolean() }), * // ... * ]); * * // At path: type — Expected the value to be one of: "a", "b", "c", but received: "d". * validateUnion({ type: 'd', value: 'foo' }, struct, 'type'); * * // At path: value — Expected a value of type string, but received: 42. * validateUnion({ type: 'a', value: 42 }, struct, 'value'); */ export function validateUnion(value, struct, structKey, coerce = false) { assert(struct.schema, 'Expected a struct with a schema. Make sure to use `union` from `@metamask/snaps-sdk`.'); assert(struct.schema.length > 0, 'Expected a non-empty array of structs.'); const keyUnion = struct.schema.map((innerStruct) => innerStruct.schema[structKey]); const key = superstructType({ [structKey]: union(keyUnion), }); const [keyError] = validate(value, key, { coerce }); if (keyError) { throw new Error(getStructFailureMessage(key, keyError.failures()[0], false)); } // At this point it's guaranteed that the value is an object, so we can safely // cast it to a Record. const objectValue = value; const objectStructs = struct.schema.filter((innerStruct) => is(objectValue[structKey], innerStruct.schema[structKey])); assert(objectStructs.length > 0, 'Expected a struct to match the value.'); // We need to validate the value against all the object structs that match the // struct key, and return the first validated value. const validationResults = objectStructs.map((objectStruct) => validate(objectValue, objectStruct, { coerce })); const validatedValue = validationResults.find(([error]) => !error); if (validatedValue) { return validatedValue[1]; } assert(validationResults[0][0], 'Expected at least one error.'); // If there is no validated value, we need to find the error with the least // number of failures (with the assumption that it's the most specific error). const validationError = validationResults.reduce((error, [innerError]) => { assert(innerError, 'Expected an error.'); if (innerError.failures().length < error.failures().length) { return innerError; } return error; }, validationResults[0][0]); throw new Error(getStructFailureMessage(struct, validationError.failures()[0], false)); } /** * Create a value with the coercion logic of a union struct, and throw readable * errors if the value does not satisfy the struct. This is useful for improving * the error messages returned by `superstruct`. * * @param value - The value to validate. * @param struct - The `superstruct` union struct to validate the value against. * This struct must be a union of object structs, and must have at least one * shared key to validate against. * @param structKey - The key to validate against. This key must be present in * all the object structs in the union struct, and is expected to be a literal * value. * @returns The validated value. * @throws If the value does not satisfy the struct. * @see validateUnion */ export function createUnion(value, struct, structKey) { return validateUnion(value, struct, structKey, true); } /** * Merge multiple structs into one, using superstruct `assign`. * * Differently from plain `assign`, this function also copies over refinements from each struct. * * @param structs - The `superstruct` structs to merge. * @returns The merged struct. */ export function mergeStructs(...structs) { const mergedStruct = assign(...structs); return new Struct({ ...mergedStruct, *refiner(value, ctx) { for (const struct of structs) { yield* struct.refiner(value, ctx); } }, }); } //# sourceMappingURL=structs.mjs.map