Created
March 19, 2022 20:05
-
-
Save Phaqui/e1af34a892bf4fea79d79e251b7f0f89 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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