@auth70/bodyguard
Version:
Fetch API compatible streaming JSON and form data body parser and guard
308 lines (307 loc) • 15.5 kB
JavaScript
import { ERRORS, MAX_DEPTH, MAX_KEYS, MAX_KEY_LENGTH, MAX_SIZE } from "./lib.js";
import { FormDataParser, JSONParser, TextParser, URLParamsParser } from "./parser.js";
export { ERRORS, MAX_DEPTH, MAX_KEYS, MAX_KEY_LENGTH, MAX_SIZE, FormDataParser, JSONParser, TextParser, URLParamsParser };
export class Bodyguard {
config;
/**
* Constructs a Bodyguard instance with the provided configuration or defaults to preset values.
* @param {BodyguardConfig} config - Configuration settings to initialize the Bodyguard instance.
* @param {number} config.maxKeys - Maximum number of keys.
* @param {number} config.maxDepth - Maximum depth of an object or array.
* @param {number} config.maxSize - Maximum size of a Request or Response body in bytes.
* @param {number} config.maxKeyLength - Maximum length of a key in characters.
* @param {boolean} config.castBooleans - Whether to cast boolean values to boolean type.
* @param {boolean} config.castNumbers - Whether to cast numeric values to number type.
* @param {boolean} config.convertPluses - Whether to convert plus signs to spaces in urlencoded form data.
* @param {number} config.maxFiles - Maximum number of files; used only for multipart form data.
* @param {number} config.maxFilenameLength - Maximum length of a filename; used only for multipart form data.
* @param {string[]} config.allowedContentTypes - Allow list for content types; used only for multipart form data.
* @example
* const bodyguard = new Bodyguard({
* maxKeys: 100, // Maximum number of keys.
* maxDepth: 10, // Maximum depth of an object or array.
* maxSize: 1024 * 1024, // Maximum size of a Request or Response body in bytes.
* maxKeyLength: 100, // Maximum length of a key in characters.
* castBooleans: false, // Whether to cast boolean values to boolean type.
* castNumbers: false, // Whether to cast numeric values to number type.
* });
*/
constructor(config) {
this.config = {
maxKeys: config?.maxKeys && typeof config.maxKeys === 'number' && config.maxKeys > 0 ? config.maxKeys : MAX_KEYS,
maxDepth: config?.maxDepth && typeof config.maxDepth === 'number' && config.maxDepth > 0 ? config.maxDepth : MAX_DEPTH,
maxSize: config?.maxSize && typeof config.maxSize === 'number' && config.maxSize > 0 ? config.maxSize : MAX_SIZE,
maxKeyLength: config?.maxKeyLength && typeof config.maxKeyLength === 'number' && config.maxKeyLength > 0 ? config.maxKeyLength : MAX_KEY_LENGTH,
castBooleans: config?.castBooleans !== undefined && typeof config.castBooleans === 'boolean' ? config.castBooleans : false,
castNumbers: config?.castNumbers !== undefined && typeof config.castNumbers === 'boolean' ? config.castNumbers : false,
convertPluses: config?.convertPluses !== undefined && typeof config.convertPluses === 'boolean' ? config.convertPluses : false,
maxFiles: typeof config?.maxFiles === 'number' && config.maxFiles > -1 ? config.maxFiles : Infinity,
maxFilenameLength: typeof config?.maxFilenameLength === 'number' && config.maxFilenameLength > 0 ? config.maxFilenameLength : 255,
allowedContentTypes: config?.allowedContentTypes && Array.isArray(config.allowedContentTypes) ? config.allowedContentTypes : undefined,
};
}
/**
* Attempts to parse a Request or Response body. Returns the parsed object in case of success and
* an error object in case of failure.
* @param {Request | Response} input - Request or Response to parse the body from.
* @param {BodyguardValidator} validator - Optional validator to validate the parsed body against.
* @param {Partial<BodyguardConfig | BodyguardFormConfig>} config - Optional configuration to override the default configuration.
* @returns {Promise<BodyguardResult<E, K>>} - Result of the parsing operation.
*/
async softPat(input, validator, config) {
try {
const res = await this.pat(input, validator, config);
return {
success: true,
value: res
};
}
catch (e) {
return {
success: false,
error: e
};
}
}
/**
* Attempts to parse a Request or Response body. Returns the parsed object in case of success and
* an error object in case of failure.
* @param {Request | Response} input - Request or Response to parse the body from.
* @param {BodyguardValidator} validator - Optional validator to validate the parsed body against.
* @param {Partial<BodyguardConfig | BodyguardFormConfig>} config - Optional configuration to override the default configuration.
* @returns {Promise<K>} - Result of the parsing operation.
* @throws {Error} - If content-type is not present or is invalid, or the body is invalid, it throws an error.
*/
async pat(input, validator, config) {
const contentType = input.headers.get("content-type");
if (!contentType || contentType === '')
throw new Error(ERRORS.NO_CONTENT_TYPE);
if (contentType === "application/x-www-form-urlencoded") {
return await this.form(input, validator, config);
}
else if (contentType.startsWith("multipart/form-data")) {
return await this.form(input, validator, config);
}
else if (contentType === "application/json") {
return await this.json(input, validator, config);
}
else if (contentType === "text/plain") {
return await this.text(input, validator, config);
}
else {
throw new Error(ERRORS.INVALID_CONTENT_TYPE);
}
}
async formInternal(input, config) {
if (!input.body)
throw new Error(ERRORS.BODY_NOT_AVAILABLE);
const instanceConfig = this.constructConfig(config || {});
const contentType = input.headers.get("content-type");
if (!contentType || contentType === '')
throw new Error(ERRORS.NO_CONTENT_TYPE);
const bodyType = contentType === "application/x-www-form-urlencoded" ? "params" : "formdata";
let boundary = "";
if (contentType.includes("boundary")) {
const match = contentType.match(/boundary=(.*)/);
if (!match || !match[1]) {
throw new Error(ERRORS.INVALID_CONTENT_TYPE);
}
boundary = match[1];
}
if (bodyType === "formdata" && !boundary)
throw new Error(ERRORS.INVALID_CONTENT_TYPE);
const parser = bodyType === "params" ? new URLParamsParser(instanceConfig) : new FormDataParser(instanceConfig, boundary);
const ret = await parser.parse(input.body);
return ret;
}
/**
* Attempts to parse a form from a Request or Response. Returns the parsed object in case of success and
* an error object in case of failure.
* @param {Request | Response} input - Request or Response to parse the form from.
* @param {BodyguardValidator} validator - Optional validator to validate the parsed form against.
* @param {Partial<BodyguardFormConfig>} config - Optional configuration to override the default configuration.
* @return {Promise<BodyguardResult<E, K>>} - Result of the parsing operation.
*/
async softForm(input, validator, config) {
try {
const ret = await this.formInternal(input, config);
try {
if (validator) {
return {
success: true,
value: await Promise.resolve(validator(ret))
};
}
return {
success: true,
value: ret
};
}
catch (err) {
return {
success: false,
error: err,
value: ret
};
}
}
catch (e) {
return {
success: false,
error: e
};
}
}
/**
* Parses a form from a Request or Response. Form could be urlencoded or multipart.
* @param {Request | Response} input - Request or Response to parse the form from.
* @param {BodyguardValidator} validator - Optional validator to validate the parsed form against.
* @param {Partial<BodyguardFormConfig>} config - Optional configuration to override the default configuration.
* @param {boolean} soft - Whether to throw an error or return an error object in case of failure.
* @return {Promise<K>} - Parsed form from the Request or Response.
* @throws {Error} - If content-type is not present or is invalid, or the form data is invalid, it throws an error.
*/
async form(input, validator, config, soft) {
const ret = await this.formInternal(input, config);
if (validator) {
return await Promise.resolve(validator(ret));
}
return ret;
}
async jsonInternal(input, config) {
if (!input.body)
throw new Error(ERRORS.BODY_NOT_AVAILABLE);
const instanceConfig = this.constructConfig(config || {});
const parser = new JSONParser(instanceConfig);
const ret = await parser.parse(input.body);
return ret;
}
/**
* Attempts to parse JSON from a Request or Response. Returns the parsed JSON in case of success and
* an error object in case of failure.
* @param {Request | Response} input - Request or Response to parse the JSON from.
* @param {BodyguardValidator} validator - Optional validator to validate the parsed JSON against.
* @param {BodyguardConfig} config - Optional configuration to override the default configuration.
* @return {Promise<BodyguardResult<E, K>>} - Result of the parsing operation.
*/
async softJson(input, validator, config) {
try {
const ret = await this.jsonInternal(input, config);
try {
if (validator) {
return {
success: true,
value: await Promise.resolve(validator(ret))
};
}
return {
success: true,
value: ret
};
}
catch (err) {
return {
success: false,
error: err,
value: ret
};
}
}
catch (e) {
return {
success: false,
error: e
};
}
}
/**
* Parses JSON from a Request or Response.
* @param {Request | Response} input - Request or Response to parse the JSON from.
* @param {BodyguardValidator} validator - Optional validator to validate the parsed JSON against.
* @param {BodyguardConfig} config - Optional configuration to override the default configuration.
* @return {Promise<K>} - Parsed JSON from the Request or Response.
* @throws {Error} - If JSON parsing fails, it throws an error.
*/
async json(input, validator, config) {
const ret = await this.jsonInternal(input, config);
if (validator) {
return await Promise.resolve(validator(ret));
}
return ret;
}
async textInternal(input, config) {
if (!input.body)
throw new Error(ERRORS.BODY_NOT_AVAILABLE);
const instanceConfig = this.constructConfig(config || {});
const parser = new TextParser(instanceConfig);
const ret = await parser.parse(input.body);
return ret;
}
/**
* Attempts to parse text from a Request or Response. Returns the parsed text in case of success and
* an error object in case of failure.
* @param {Request | Response} input - Request or Response to parse the text from.
* @param {BodyguardValidator} validator - Optional validator to validate the parsed text against.
* @param {BodyguardConfig} config - Optional configuration to override the default configuration.
* @returns {Promise<BodyguardResult<K>>} - Result of the parsing operation.
*/
async softText(input, validator, config) {
try {
const ret = await this.textInternal(input, config);
try {
if (validator) {
return {
success: true,
value: await Promise.resolve(validator(ret))
};
}
return {
success: true,
value: ret
};
}
catch (err) {
return {
success: false,
error: err,
value: ret
};
}
}
catch (e) {
return {
success: false,
error: e
};
}
}
/**
* Parses text from a Request or Response.
* @param {Request | Response} input - Request or Response to parse the text from.
* @param {BodyguardValidator} validator - Optional validator to validate the parsed text against.
* @param {BodyguardConfig} config - Optional configuration to override the default configuration.
* @returns {Promise<K>} - Parsed text from the Request or Response.
* @throws {Error} - If text parsing fails, it throws an error.
*/
async text(input, validator, config) {
const ret = await this.textInternal(input, config);
if (validator) {
return await Promise.resolve(validator(ret));
}
return ret;
}
constructConfig(config) {
return {
maxKeys: config?.maxKeys && typeof config.maxKeys === 'number' && config.maxKeys > 0 ? config.maxKeys : this.config.maxKeys,
maxDepth: config?.maxDepth && typeof config.maxDepth === 'number' && config.maxDepth > 0 ? config.maxDepth : this.config.maxDepth,
maxSize: config?.maxSize && typeof config.maxSize === 'number' && config.maxSize > 0 ? config.maxSize : this.config.maxSize,
maxKeyLength: config?.maxKeyLength && typeof config.maxKeyLength === 'number' && config.maxKeyLength > 0 ? config.maxKeyLength : this.config.maxKeyLength,
castBooleans: config?.castBooleans !== undefined && typeof config.castBooleans === 'boolean' ? config.castBooleans : this.config.castBooleans,
castNumbers: config?.castNumbers !== undefined && typeof config.castNumbers === 'boolean' ? config.castNumbers : this.config.castNumbers,
convertPluses: config?.convertPluses !== undefined && typeof config.convertPluses === 'boolean' ? config.convertPluses : false,
maxFiles: typeof config?.maxFiles === 'number' && config.maxFiles > -1 ? config.maxFiles : this.config.maxFiles,
maxFilenameLength: typeof config?.maxFilenameLength === 'number' ? config.maxFilenameLength : this.config.maxFilenameLength,
allowedContentTypes: config?.allowedContentTypes && Array.isArray(config.allowedContentTypes) ? config.allowedContentTypes : this.config.allowedContentTypes,
};
}
}