winterspec
Version:
Write Winter-CG compatible routes with filesystem routing and tons of features
226 lines (225 loc) • 9.48 kB
JavaScript
import { z, ZodError, ZodFirstPartyTypeKind } from "zod";
import { BadRequestError, InputParsingError, InputValidationError, InvalidContentTypeError, InvalidQueryParamsError, } from "./http-exceptions.js";
const getZodObjectSchemaFromZodEffectSchema = (isZodEffect, schema) => {
if (!isZodEffect) {
return schema;
}
let currentSchema = schema;
while (currentSchema instanceof z.ZodEffects) {
currentSchema = currentSchema._def.schema;
}
return currentSchema;
};
/**
* This function is used to get the correct schema from a ZodEffect | ZodDefault | ZodOptional schema.
* TODO: this function should handle all special cases of ZodSchema and not just ZodEffect | ZodDefault | ZodOptional
*/
const getZodDefFromZodSchemaHelpers = (schema) => {
const special_zod_types = [
ZodFirstPartyTypeKind.ZodOptional,
ZodFirstPartyTypeKind.ZodDefault,
ZodFirstPartyTypeKind.ZodEffects,
];
while (special_zod_types.includes(schema._def.typeName)) {
if (schema._def.typeName === ZodFirstPartyTypeKind.ZodOptional ||
schema._def.typeName === ZodFirstPartyTypeKind.ZodDefault) {
schema = schema._def.innerType;
continue;
}
if (schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects) {
schema = schema._def.schema;
continue;
}
}
return schema._def;
};
const tryGetZodSchemaAsObject = (schema) => {
const isZodEffect = schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects;
const safe_schema = getZodObjectSchemaFromZodEffectSchema(isZodEffect, schema);
const isZodObject = safe_schema._def.typeName === ZodFirstPartyTypeKind.ZodObject;
if (!isZodObject) {
return undefined;
}
return safe_schema;
};
const isZodSchemaArray = (schema) => {
const def = getZodDefFromZodSchemaHelpers(schema);
return def.typeName === ZodFirstPartyTypeKind.ZodArray;
};
const isZodSchemaBoolean = (schema) => {
const def = getZodDefFromZodSchemaHelpers(schema);
return def.typeName === ZodFirstPartyTypeKind.ZodBoolean;
};
const parseQueryParams = (schema, input, supportedArrayFormats) => {
const parsed_input = Object.assign({}, input);
const obj_schema = tryGetZodSchemaAsObject(schema);
if (obj_schema) {
for (const [key, value] of Object.entries(obj_schema.shape)) {
if (isZodSchemaArray(value)) {
const array_input = input[key];
if (typeof array_input === "string" &&
supportedArrayFormats.includes("comma")) {
parsed_input[key] = array_input.split(",");
}
const bracket_syntax_array_input = input[`${key}[]`];
if (typeof bracket_syntax_array_input === "string" &&
supportedArrayFormats.includes("brackets")) {
const pre_split_array = bracket_syntax_array_input;
parsed_input[key] = pre_split_array.split(",");
}
if (Array.isArray(bracket_syntax_array_input) &&
supportedArrayFormats.includes("brackets")) {
parsed_input[key] = bracket_syntax_array_input;
}
continue;
}
if (isZodSchemaBoolean(value)) {
const boolean_input = input[key];
if (typeof boolean_input === "string") {
parsed_input[key] = boolean_input === "true";
}
}
}
}
return schema.parse(parsed_input);
};
const validateQueryParams = (inputUrl, schema, supportedArrayFormats) => {
const url = new URL(inputUrl, "http://dummy.com");
const seenKeys = new Set();
const obj_schema = tryGetZodSchemaAsObject(schema);
if (!obj_schema) {
return;
}
for (const key of url.searchParams.keys()) {
for (const [schemaKey, value] of Object.entries(obj_schema.shape)) {
if (isZodSchemaArray(value)) {
if (key === `${schemaKey}[]` &&
!supportedArrayFormats.includes("brackets")) {
throw new InvalidQueryParamsError(`Bracket syntax not supported for query param "${schemaKey}"`);
}
}
}
const key_schema = obj_schema.shape[key];
if (key_schema) {
if (isZodSchemaArray(key_schema)) {
if (seenKeys.has(key) && !supportedArrayFormats.includes("repeat")) {
throw new InvalidQueryParamsError(`Repeated parameters not supported for duplicate query param "${key}"`);
}
}
}
seenKeys.add(key);
}
};
export const withInputValidation = (input) => async (req, ctx, next) => {
const { supportedArrayFormats } = input;
if ((input.formData && input.jsonBody) ||
(input.formData && input.commonParams)) {
throw new Error("Cannot use formData with jsonBody or commonParams");
}
if ((req.method === "POST" || req.method === "PATCH") &&
(input.jsonBody || input.commonParams) &&
!req.headers.get("content-type")?.includes("application/json") &&
!input.jsonBody?.isOptional() &&
!input.commonParams?.isOptional()) {
throw new InvalidContentTypeError(`${req.method} requests must have Content-Type header with "application/json"`);
}
if (input.urlEncodedFormData &&
!input.urlEncodedFormData.isOptional() &&
req.method !== "GET" &&
!req.headers
.get("content-type")
?.includes("application/x-www-form-urlencoded")) {
throw new InvalidContentTypeError(`Must have Content-Type header with "application/x-www-form-urlencoded"`);
}
if (input.formData &&
!input.formData.isOptional() &&
(req.method === "POST" || req.method === "PATCH") &&
!req.headers.get("content-type")?.includes("multipart/form-data")) {
throw new InvalidContentTypeError(`${req.method} requests must have Content-Type header with "multipart/form-data"`);
}
// TODO eventually we should support multipart/form-data
const originalParams = Object.fromEntries(new URL(req.url).searchParams.entries());
let jsonBody;
if ((input.jsonBody || input.commonParams) &&
req.headers.get("content-type")?.includes("application/json")) {
try {
jsonBody = await req.clone().json();
}
catch (e) {
if (!input.jsonBody?.isOptional() &&
!input.commonParams?.isOptional()) {
throw new InputParsingError("Error while parsing JSON body");
}
}
}
let multiPartFormData = undefined;
if (input.formData) {
try {
multiPartFormData = await req.clone().formData();
multiPartFormData = Object.fromEntries(multiPartFormData.entries());
}
catch (e) {
if (!input.formData?.isOptional()) {
throw new InputParsingError("Error while parsing form data");
}
}
}
let urlEncodedFormData = undefined;
if (input.urlEncodedFormData &&
req.headers
.get("content-type")
?.includes("application/x-www-form-urlencoded")) {
try {
const params = new URLSearchParams(await req.clone().text());
urlEncodedFormData = Object.fromEntries(params.entries());
}
catch (e) {
if (!input.urlEncodedFormData?.isOptional()) {
throw new InputParsingError("Error while parsing url encoded form data");
}
}
}
try {
const originalCombinedParams = {
...originalParams,
...(typeof jsonBody === "object" ? jsonBody : {}),
};
const willValidateRequestBody = !["GET", "DELETE", "HEAD"].includes(req.method);
if (Boolean(input.formData) && willValidateRequestBody) {
req.multiPartFormData = input.formData?.parse(multiPartFormData);
}
if (Boolean(input.jsonBody) && willValidateRequestBody) {
req.jsonBody = input.jsonBody?.parse(jsonBody);
}
if (Boolean(input.urlEncodedFormData) && willValidateRequestBody) {
req.urlEncodedFormData =
input.urlEncodedFormData?.parse(urlEncodedFormData);
}
if (Boolean(input.routeParams) && "routeParams" in req) {
req.routeParams = input.routeParams?.parse(req.routeParams);
}
if (input.queryParams) {
if (!req.url) {
throw new Error("req.url is undefined");
}
validateQueryParams(req.url, input.queryParams, supportedArrayFormats);
req.query = parseQueryParams(input.queryParams, originalParams, supportedArrayFormats);
}
if (input.commonParams) {
/**
* as commonParams includes query params, we can use the parseQueryParams function
*/
req.commonParams = parseQueryParams(input.commonParams, originalCombinedParams, supportedArrayFormats);
}
}
catch (error) {
if (error instanceof BadRequestError) {
throw error;
}
if (error instanceof ZodError) {
throw new InputValidationError(error);
}
throw new InputParsingError("Error while parsing input");
}
return next(req, ctx);
};