UNPKG

winterspec

Version:

Write Winter-CG compatible routes with filesystem routing and tons of features

226 lines (225 loc) 9.48 kB
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); };