Skip to content

Instantly share code, notes, and snippets.

@Phaqui
Created March 19, 2022 20:05
Show Gist options
  • Save Phaqui/e1af34a892bf4fea79d79e251b7f0f89 to your computer and use it in GitHub Desktop.
Save Phaqui/e1af34a892bf4fea79d79e251b7f0f89 to your computer and use it in GitHub Desktop.
type Optional<T> = T | null;
type CheckFunction<T> = (val: T) => boolean;
type TransformFunction<T> = (val: T) => T;
interface Typedef<T = JSONType> {
type: T,
presence?: "required" | "optional",
check?: CheckFunction<T>,
default?: T,
transform?: TransformFunction<T>,
};
interface Typedefs {
[index: string]: Typedef
}
type BodyType<T> = {
[key in keyof T]: any;
}
type CheckRequestBodyResult<T> = Promise<
[
Optional<{ [key in keyof T]: any }>
,
Optional<Response>
]
>;
export async function check_request_body<T = Typedefs>(
typedefs: T,
request: Request,
): CheckRequestBodyResult<T> {
let body: BodyType<T>;
try {
body = await request.json();
} catch(err) {
return [null, unprocessable("JSON could not be decoded: " + err)];
}
const seen_keys = new Set<string>();
const unexpected_keys = new Set<string>();
// Find possible keys, keys that have a default value, and required keys
const possible_keys: Set<keyof Typedefs> = new Set();
const default_keys: Set<keyof Typedefs> = new Set();
const required_keys: Set<keyof Typedefs> = new Set();
const optional_keys: Set<keyof Typedefs> = new Set();
for (const [key_name, typedef] of Object.entries(typedefs)) {
possible_keys.add(key_name);
if ("default" in typedef) {
default_keys.add(key_name);
// if it has a default, it must be optional
optional_keys.add(key_name);
}
if (!typedef.presence || typedef.presence === "required") {
required_keys.add(key_name);
}
if (typedef.presence === "optional") {
// ^... but just because it's optional, doesn't mean it has
// a default
optional_keys.add(key_name);
}
}
const errors = [];
for (const [key, val] of Object.entries(body)) {
if (!possible_keys.has(key)) {
unexpected_keys.add(key);
continue;
}
seen_keys.add(key);
// check the type of this val against the thing in typedefs
const got = determine_json_type(val);
const expected = typedefs[key].type;
if (got !== expected) {
errors.push({ type: "TypeError", expected, got, key });
// no need to report other erros about this key, when
// the type isn't even correct!
continue;
}
// run the check-function, if there is one
if (typeof typedefs[key].check === "function") {
const valid = typedefs[key].check(val);
if (!valid) {
errors.push({ type: "ValidationError", key });
}
}
}
// Are we missing any keys, that we should have seen?
const missing_keys = set_difference(possible_keys, seen_keys);
for (const missing_key of missing_keys) {
// key is missing, but maybe has a default value?
if (default_keys.has(missing_key)) {
body[missing_key] = typedefs[missing_key].default;
seen_keys.add(missing_key);
} else if (optional_keys.has(missing_key)) {
// no default for missing key, but it's optional
continue;
} else {
errors.push({ type: "MissingKeyError", key: missing_key });
}
}
// transform all values that have transformations defined
for (const key of seen_keys) {
if (typeof typedefs[key].transform === "function") {
body[key] = typedefs[key].transform(body[key]);
}
}
for (const unexpected_key of unexpected_keys) {
errors.push({ type: "UnexpectedKey", key: unexpected_key });
}
if (errors.length > 0) {
return [null, unprocessable(errors)];
}
return [body, null];
}
function determine_json_type(val: unknown): JSONType | "integer" {
if (val === null) return "null";
const t = typeof val;
if (t === "boolean") return "boolean";
if (t === "string") return "string";
if (t === "number") {
// if I understand correctly (iiuc),
// when javascript parses JSON numbers, they will
// implicitly convert them to IEEE 754 double precision,
// and as such, all I can really test for, is wheter I can trust
// that a given number was accurately parsed as an integer with
// the Number.isSafeInteger() method. Number.isInteger() is also
// possible, but it will not tell me if the integer in question
// has been rounded after being read from the input, so I cannot
// be sure that that is the actual integer I read.
return Number.isSafeInteger(val) ? "integer" : "number";
}
return Array.isArray(val) ? "array" : "object";
}
// example usage in another file
const POST_BODY = {
"email": {
type: "string",
check: valid_email_address,
transform: (email: string) => email.toLowerCase(),
},
"name": {
presence: "optional",
type: "string",
check: (val: string) => val.length > 2 && val.length < 50,
},
"privileges": {
presence: "optional",
type: "integer",
check: (val: number) => val == 0 || val == 1,
default: 0,
}
};
routes.post("/users", with_auth, require_admin, async (request: Request) => {
const [new_user, error] = await check_request_body(POST_BODY, request);
// `new_user` object is now validated according to POST_BODY
// if errors, then error will be set accordingly
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment