@metamask/snaps-utils
Version:
A collection of utilities for MetaMask Snaps
301 lines • 12.3 kB
JavaScript
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