feat(js-client)!: Segregation of responsibility between js-client packages [fixes DXJ-525] (#378)

Schema validation in js-client
This commit is contained in:
Akim
2023-11-19 09:04:10 +07:00
committed by GitHub
parent 638da47bc2
commit f4a550dd22
80 changed files with 2998 additions and 11303 deletions

View File

@ -14,74 +14,144 @@
* limitations under the License.
*/
import assert from "assert";
import { Buffer } from "buffer";
import { JSONValue } from "@fluencelabs/interfaces";
import bs58 from "bs58";
import { sha256 } from "multiformats/hashes/sha2";
import { z } from "zod";
import {
CallServiceData,
CallServiceResult,
CallServiceResultType,
GenericCallServiceHandler,
ResultCodes,
} from "../jsServiceHost/interfaces.js";
import { getErrorMessage, isString, jsonify } from "../util/utils.js";
import { getErrorMessage, jsonify } from "../util/utils.js";
const success = (
// TODO: Remove unknown after adding validation to builtin inputs
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
result: CallServiceResultType | unknown,
): CallServiceResult => {
const success = (result: CallServiceResultType): CallServiceResult => {
return {
// TODO: Remove type assertion after adding validation to builtin inputs
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
result: result as CallServiceResultType,
result,
retCode: ResultCodes.success,
};
};
const error = (
// TODO: Remove unknown after adding validation to builtin inputs
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
error: CallServiceResultType | unknown,
): CallServiceResult => {
const error = (error: CallServiceResultType): CallServiceResult => {
return {
// TODO: Remove type assertion after adding validation to builtin inputs
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
result: error as CallServiceResultType,
result: error,
retCode: ResultCodes.error,
};
};
const chunk = <T>(arr: T[]): T[][] => {
const res: T[][] = [];
const chunkSize = 2;
for (let i = 0; i < arr.length; i += chunkSize) {
const chunk = arr.slice(i, i + chunkSize);
res.push(chunk);
}
return res;
};
const errorNotImpl = (methodName: string) => {
return error(
`The JS implementation of Peer does not support "${methodName}"`,
);
};
const makeJsonImpl = (args: [Record<string, JSONValue>, ...JSONValue[]]) => {
const [obj, ...kvs] = args;
const parseWithSchema = <T extends z.ZodTypeAny>(
schema: T,
req: CallServiceData,
): [z.infer<T>, null] | [null, string] => {
const result = schema.safeParse(req.args, {
errorMap: (issue, ctx) => {
if (
issue.code === z.ZodIssueCode.invalid_type &&
issue.path.length === 1 &&
typeof issue.path[0] === "number"
) {
const [arg] = issue.path;
return {
message: `Argument ${arg} expected to be of type ${issue.expected}, Got ${issue.received}`,
};
}
const toMerge: Record<string, JSONValue> = {};
if (issue.code === z.ZodIssueCode.too_big) {
return {
message: `Expected ${
issue.maximum
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
} argument(s). Got ${ctx.data.length}`,
};
}
for (let i = 0; i < kvs.length / 2; i++) {
const k = kvs[i * 2];
if (issue.code === z.ZodIssueCode.too_small) {
return {
message: `Expected ${
issue.minimum
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
} argument(s). Got ${ctx.data.length}`,
};
}
if (!isString(k)) {
return error(`Argument ${i * 2 + 1} is expected to be string`);
}
if (issue.code === z.ZodIssueCode.invalid_union) {
return {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
message: `Expected argument(s). Got ${ctx.data.length}`,
};
}
const v = kvs[i * 2 + 1];
toMerge[k] = v;
return { message: ctx.defaultError };
},
});
if (result.success) {
return [result.data, null];
} else {
return [null, result.error.errors[0].message];
}
const res = { ...obj, ...toMerge };
return success(res);
};
// TODO: These assert made for silencing more stricter ts rules. Will be fixed in DXJ-493
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
type Literal = z.infer<typeof literalSchema>;
type Json = Literal | { [key: string]: Json } | Json[];
const jsonSchema: z.ZodType<Json> = z.lazy(() => {
return z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]);
});
const jsonImplSchema = z
.tuple([z.record(jsonSchema)])
.rest(z.tuple([z.string(), jsonSchema]));
const makeJsonImpl = (args: z.infer<typeof jsonImplSchema>) => {
const [obj, ...kvs] = args;
return success({ ...obj, ...Object.fromEntries(kvs) });
};
type withSchema = <T extends z.ZodTypeAny>(
arg: T,
) => (
arg1: (value: z.infer<T>) => CallServiceResult | Promise<CallServiceResult>,
) => (req: CallServiceData) => CallServiceResult | Promise<CallServiceResult>;
const withSchema: withSchema = <T extends z.ZodTypeAny>(schema: T) => {
return (bound) => {
return (req) => {
const [value, message] = parseWithSchema(schema, req);
if (message != null) {
return error(message);
}
return bound(value);
};
};
};
export const builtInServices: Record<
string,
Record<string, GenericCallServiceHandler>
@ -116,29 +186,16 @@ export const builtInServices: Record<
return errorNotImpl("peer.get_contact");
},
timeout: (req) => {
if (req.args.length !== 2) {
return error(
"timeout accepts exactly two arguments: timeout duration in ms and a message string",
);
}
const durationMs = req.args[0];
const message = req.args[1];
if (typeof durationMs !== "number" || typeof message !== "string") {
return error(
"timeout accepts exactly two arguments: timeout duration in ms and a message string",
);
}
return new Promise((resolve) => {
setTimeout(() => {
const res = success(message);
resolve(res);
}, durationMs);
});
},
timeout: withSchema(z.tuple([z.number(), z.string()]))(
([durationMs, msg]) => {
return new Promise((resolve) => {
setTimeout(() => {
const res = success(msg);
resolve(res);
}, durationMs);
});
},
),
},
kad: {
@ -246,120 +303,48 @@ export const builtInServices: Record<
return success(req.args);
},
array_length: (req) => {
if (req.args.length !== 1) {
return error(
"array_length accepts exactly one argument, found: " +
req.args.length,
);
} else {
assert(Array.isArray(req.args[0]));
return success(req.args[0].length);
}
},
array_length: withSchema(z.tuple([z.array(z.unknown())]))(([arr]) => {
return success(arr.length);
}),
identity: (req) => {
if (req.args.length > 1) {
return error(
`identity accepts up to 1 arguments, received ${req.args.length} arguments`,
);
} else {
return success(req.args.length === 0 ? {} : req.args[0]);
}
},
identity: withSchema(z.array(jsonSchema).max(1))((args) => {
return success(args.length === 0 ? {} : args[0]);
}),
concat: (req) => {
const incorrectArgIndices = req.args //
.map((x, i): [boolean, number] => {
return [Array.isArray(x), i];
})
.filter(([isArray]) => {
return !isArray;
})
.map(([, index]) => {
return index;
});
if (incorrectArgIndices.length > 0) {
const str = incorrectArgIndices.join(", ");
return error(
`All arguments of 'concat' must be arrays: arguments ${str} are not`,
);
} else {
// TODO: remove after adding validation
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return success([].concat(...(req.args as never[][])));
}
},
string_to_b58: (req) => {
if (req.args.length !== 1) {
return error("string_to_b58 accepts only one string argument");
} else {
const [input] = req.args;
// TODO: remove after adding validation
assert(typeof input === "string");
return success(bs58.encode(new TextEncoder().encode(input)));
}
},
string_from_b58: (req) => {
if (req.args.length !== 1) {
return error("string_from_b58 accepts only one string argument");
} else {
const [input] = req.args;
// TODO: remove after adding validation
assert(typeof input === "string");
return success(new TextDecoder().decode(bs58.decode(input)));
}
},
bytes_to_b58: (req) => {
if (req.args.length !== 1 || !Array.isArray(req.args[0])) {
return error(
"bytes_to_b58 accepts only single argument: array of numbers",
);
} else {
// TODO: remove after adding validation
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const argumentArray = req.args[0] as number[];
return success(bs58.encode(new Uint8Array(argumentArray)));
}
},
bytes_from_b58: (req) => {
if (req.args.length !== 1) {
return error("bytes_from_b58 accepts only one string argument");
} else {
const [input] = req.args;
// TODO: remove after adding validation
assert(typeof input === "string");
return success(Array.from(bs58.decode(input)));
}
},
sha256_string: async (req) => {
if (req.args.length !== 1) {
return error(
`sha256_string accepts 1 argument, found: ${req.args.length}`,
);
} else {
const [input] = req.args;
// TODO: remove after adding validation
assert(typeof input === "string");
const inBuffer = Buffer.from(input);
const multihash = await sha256.digest(inBuffer);
return success(bs58.encode(multihash.bytes));
}
},
concat_strings: (req) => {
// TODO: remove after adding validation
concat: withSchema(z.array(z.array(z.unknown())))((args) => {
// Schema is used with unknown type to prevent useless runtime check
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const res = "".concat(...(req.args as string[]));
return success(res);
},
const arr = args as never[][];
return success(arr.flat());
}),
string_to_b58: withSchema(z.tuple([z.string()]))(([input]) => {
return success(bs58.encode(new TextEncoder().encode(input)));
}),
string_from_b58: withSchema(z.tuple([z.string()]))(([input]) => {
return success(new TextDecoder().decode(bs58.decode(input)));
}),
bytes_to_b58: withSchema(z.tuple([z.array(z.number())]))(([input]) => {
return success(bs58.encode(new Uint8Array(input)));
}),
bytes_from_b58: withSchema(z.tuple([z.string()]))(([input]) => {
return success(Array.from(bs58.decode(input)));
}),
sha256_string: withSchema(z.tuple([z.string()]))(async ([input]) => {
const inBuffer = Buffer.from(input);
const multihash = await sha256.digest(inBuffer);
return success(bs58.encode(multihash.bytes));
}),
concat_strings: withSchema(z.array(z.string()))((args) => {
return success(args.join(""));
}),
},
debug: {
@ -379,365 +364,187 @@ export const builtInServices: Record<
},
math: {
add: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
add: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x + y);
},
}),
sub: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
sub: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x - y);
},
}),
mul: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
mul: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x * y);
},
}),
fmul: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
fmul: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(Math.floor(x * y));
},
}),
div: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
div: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(Math.floor(x / y));
},
}),
rem: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
rem: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x % y);
},
}),
pow: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
pow: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(Math.pow(x, y));
},
}),
log: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
log: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(Math.log(y) / Math.log(x));
},
}),
},
cmp: {
gt: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
gt: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x > y);
},
}),
gte: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
gte: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x >= y);
},
}),
lt: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
lt: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x < y);
},
}),
lte: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
lte: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x <= y);
},
}),
cmp: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [x, y] = req.args;
// TODO: Remove after adding validation
assert(typeof x === "number" && typeof y === "number");
cmp: withSchema(z.tuple([z.number(), z.number()]))(([x, y]) => {
return success(x === y ? 0 : x > y ? 1 : -1);
},
}),
},
array: {
sum: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 1)) != null) {
return err;
}
// TODO: Remove after adding validation
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const [xs] = req.args as [number[]];
sum: withSchema(z.tuple([z.array(z.number())]))(([xs]) => {
return success(
xs.reduce((agg, cur) => {
return agg + cur;
}, 0),
);
},
}),
dedup: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 1)) != null) {
return err;
}
const [xs] = req.args;
// TODO: Remove after adding validation
assert(Array.isArray(xs));
dedup: withSchema(z.tuple([z.array(z.any())]))(([xs]) => {
const set = new Set(xs);
return success(Array.from(set));
},
}),
intersect: (req) => {
let err;
intersect: withSchema(z.tuple([z.array(z.any()), z.array(z.any())]))(
([xs, ys]) => {
const intersection = xs.filter((x) => {
return ys.includes(x);
});
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
return success(intersection);
},
),
const [xs, ys] = req.args;
// TODO: Remove after adding validation
assert(Array.isArray(xs) && Array.isArray(ys));
diff: withSchema(z.tuple([z.array(z.any()), z.array(z.any())]))(
([xs, ys]) => {
const diff = xs.filter((x) => {
return !ys.includes(x);
});
const intersection = xs.filter((x) => {
return ys.includes(x);
});
return success(diff);
},
),
return success(intersection);
},
sdiff: withSchema(z.tuple([z.array(z.any()), z.array(z.any())]))(
([xs, ys]) => {
const sdiff = [
xs.filter((y) => {
return !ys.includes(y);
}),
ys.filter((x) => {
return !xs.includes(x);
}),
].flat();
diff: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [xs, ys] = req.args;
// TODO: Remove after adding validation
assert(Array.isArray(xs) && Array.isArray(ys));
const diff = xs.filter((x) => {
return !ys.includes(x);
});
return success(diff);
},
sdiff: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 2)) != null) {
return err;
}
const [xs, ys] = req.args;
// TODO: Remove after adding validation
assert(Array.isArray(xs) && Array.isArray(ys));
const sdiff = [
// force new line
...xs.filter((y) => {
return !ys.includes(y);
}),
...ys.filter((x) => {
return !xs.includes(x);
}),
];
return success(sdiff);
},
return success(sdiff);
},
),
},
json: {
obj: (req) => {
let err;
obj: withSchema(
z
.array(z.unknown())
.refine(
(arr) => {
return arr.length % 2 === 0;
},
(arr) => {
return {
message: "Expected even number of argument(s). Got " + arr.length,
};
},
)
.transform((args) => {
return chunk(args);
})
.pipe(z.array(z.tuple([z.string(), jsonSchema]))),
)((args) => {
return makeJsonImpl([{}, ...args]);
}),
if ((err = checkForArgumentsCountEven(req)) != null) {
return err;
}
put: withSchema(
z
.tuple([z.record(jsonSchema), z.string(), jsonSchema])
.transform(
([obj, name, value]): [{ [key: string]: Json }, [string, Json]] => {
return [obj, [name, value]];
},
),
)(makeJsonImpl),
// TODO: remove after adding validation
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return makeJsonImpl([{}, ...req.args] as [
Record<string, JSONValue>,
...JSONValue[],
]);
},
puts: withSchema(
z
.array(z.unknown())
.refine(
(arr) => {
return arr.length >= 3;
},
(value) => {
return {
message: `Expected more than 3 argument(s). Got ${value.length}`,
};
},
)
.refine(
(arr) => {
return arr.length % 2 === 1;
},
{
message: "Argument count must be odd.",
},
)
.transform((args) => {
return [args[0], ...chunk(args.slice(1))];
})
.pipe(jsonImplSchema),
)(makeJsonImpl),
put: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 3)) != null) {
return err;
}
if ((err = checkForArgumentType(req, 0, "object")) != null) {
return err;
}
return makeJsonImpl(
// TODO: remove after adding validation
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
req.args as [Record<string, JSONValue>, ...JSONValue[]],
);
},
puts: (req) => {
let err;
if ((err = checkForArgumentsCountOdd(req)) != null) {
return err;
}
if ((err = checkForArgumentsCountMoreThan(req, 3)) != null) {
return err;
}
if ((err = checkForArgumentType(req, 0, "object")) != null) {
return err;
}
return makeJsonImpl(
// TODO: remove after adding validation
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
req.args as [Record<string, JSONValue>, ...JSONValue[]],
);
},
stringify: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 1)) != null) {
return err;
}
if ((err = checkForArgumentType(req, 0, "object")) != null) {
return err;
}
const [json] = req.args;
const res = JSON.stringify(json);
return success(res);
},
parse: (req) => {
let err;
if ((err = checkForArgumentsCount(req, 1)) != null) {
return err;
}
if ((err = checkForArgumentType(req, 0, "string")) != null) {
return err;
}
const [raw] = req.args;
stringify: withSchema(z.tuple([z.record(z.string(), jsonSchema)]))(
([json]) => {
const res = JSON.stringify(json);
return success(res);
},
),
parse: withSchema(z.tuple([z.string()]))(([raw]) => {
try {
// TODO: Remove after adding validation
assert(typeof raw === "string");
const json = JSON.parse(raw);
// Parsing any argument here yields JSONValue
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const json = JSON.parse(raw) as JSONValue;
return success(json);
} catch (err: unknown) {
return error(getErrorMessage(err));
}
},
}),
},
"run-console": {
@ -749,59 +556,3 @@ export const builtInServices: Record<
},
},
} as const;
const checkForArgumentsCount = (
req: { args: Array<unknown> },
count: number,
) => {
if (req.args.length !== count) {
return error(`Expected ${count} argument(s). Got ${req.args.length}`);
}
return null;
};
const checkForArgumentsCountMoreThan = (
req: { args: Array<unknown> },
count: number,
) => {
if (req.args.length < count) {
return error(
`Expected more than ${count} argument(s). Got ${req.args.length}`,
);
}
return null;
};
const checkForArgumentsCountEven = (req: { args: Array<unknown> }) => {
if (req.args.length % 2 === 1) {
return error(`Expected even number of argument(s). Got ${req.args.length}`);
}
return null;
};
const checkForArgumentsCountOdd = (req: { args: Array<unknown> }) => {
if (req.args.length % 2 === 0) {
return error(`Expected odd number of argument(s). Got ${req.args.length}`);
}
return null;
};
const checkForArgumentType = (
req: { args: Array<unknown> },
index: number,
type: string,
) => {
const actual = typeof req.args[index];
if (actual !== type) {
return error(
`Argument ${index} expected to be of type ${type}, Got ${actual}`,
);
}
return null;
};