/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {parseJsonPreservingLargeIntegers} from '@fluxer/api/src/utils/LosslessJsonParser'; import {initializeFluxerErrorMap} from '@fluxer/api/src/ZodErrorMap'; import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import { InputValidationError, type LocalizedValidationError, } from '@fluxer/errors/src/domains/core/InputValidationError'; import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError'; import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono'; import {getCookie} from 'hono/cookie'; import type {ZodError, ZodTypeAny} from 'zod'; initializeFluxerErrorMap(); function isEmptyObject(obj: object): boolean { return Object.keys(obj).length === 0; } const validationErrorCodeSet = new Set(Object.values(ValidationErrorCodes)); function isValidationErrorCode(value: string): value is ValidationErrorCode { return validationErrorCodeSet.has(value); } function getValidationErrorCode(message: string): ValidationErrorCode { if (isValidationErrorCode(message)) { return message; } return ValidationErrorCodes.INVALID_FORMAT; } interface ZodTooSmallIssue { code: 'too_small'; minimum: number | bigint; type: string; } interface ZodTooBigIssue { code: 'too_big'; maximum: number | bigint; type: string; } function isTooSmallIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooSmallIssue { return issue.code === 'too_small' && 'minimum' in issue && 'type' in issue; } function isTooBigIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooBigIssue { return issue.code === 'too_big' && 'maximum' in issue && 'type' in issue; } interface ZodInvalidTypeIssue { code: 'invalid_type'; expected: string; received: string; } interface ZodCustomIssue { code: 'custom'; params?: Record; } function isInvalidTypeIssue( issue: ZodError['issues'][number], ): issue is ZodError['issues'][number] & ZodInvalidTypeIssue { return issue.code === 'invalid_type' && 'expected' in issue && 'received' in issue; } function isCustomIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodCustomIssue { return issue.code === 'custom'; } function extractVariablesFromIssue(issue: ZodError['issues'][number]): Record | undefined { const path = issue.path; const fieldName = path.length > 0 ? String(path[path.length - 1]) : 'field'; if (isTooSmallIssue(issue)) { return {name: fieldName, min: issue.minimum, minValue: issue.minimum}; } if (isTooBigIssue(issue)) { return {name: fieldName, max: issue.maximum, maxLength: issue.maximum, maxValue: issue.maximum}; } if (isInvalidTypeIssue(issue)) { return {name: fieldName, expected: issue.expected, received: issue.received}; } if (isCustomIssue(issue) && issue.params) { return {name: fieldName, ...issue.params}; } return {name: fieldName}; } function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown { if (typeof obj === 'string' && obj === '') return null; if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, false)); if (obj !== null && typeof obj === 'object') { if (isEmptyObject(obj) && !isRoot) return null; const processed = Object.fromEntries( Object.entries(obj).map(([key, value]) => [key, convertEmptyValuesToNull(value, false)]), ); if (!isRoot && Object.values(processed).every((value) => value === null)) return null; return processed; } return obj; } type HasUndefined = undefined extends T ? true : false; type SafeParseResult = | {success: true; data: T['_output']} | {success: false; error: ZodError}; type Hook< T extends ZodTypeAny, E extends Env, P extends string, Target extends keyof ValidationTargets = keyof ValidationTargets, V extends Input = Input, O = Record, > = ( result: SafeParseResult & {target: Target}, c: Context, ) => Response | undefined | TypedResponse | Promise>; type PreHook = ( value: unknown, c: Context, target: Target, ) => unknown | Promise; type ValidatorOptions< T extends ZodTypeAny, E extends Env, P extends string, Target extends keyof ValidationTargets, V extends Input, > = { pre?: PreHook; post?: Hook; }; export const Validator = < T extends ZodTypeAny, Target extends keyof ValidationTargets, E extends Env, P extends string, In = T['_input'], Out = T['_output'], I extends Input = { in: HasUndefined extends true ? {[K in Target]?: In extends ValidationTargets[K] ? In : {[K2 in keyof In]?: ValidationTargets[K][K2]}} : {[K in Target]: In extends ValidationTargets[K] ? In : {[K2 in keyof In]: ValidationTargets[K][K2]}}; out: {[K in Target]: Out}; }, V extends I = I, >( target: Target, schema: T, hookOrOptions?: Hook | ValidatorOptions, ): MiddlewareHandler => { const options: ValidatorOptions = typeof hookOrOptions === 'function' ? {post: hookOrOptions} : (hookOrOptions ?? {}); return async (c, next): Promise => { let value: unknown; switch (target) { case 'json': try { const raw = await c.req.text(); value = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw); } catch { value = {}; } break; case 'form': { const formData = await c.req.formData(); type FormDataEntry = File | string; type FormValue = FormDataEntry | Array; const form: Record = {}; formData.forEach((formValue, key) => { const existingValue = form[key]; if (key.endsWith('[]')) { const list = Array.isArray(existingValue) ? existingValue : existingValue !== undefined ? [existingValue] : []; list.push(formValue); form[key] = list; } else if (Array.isArray(existingValue)) { existingValue.push(formValue); } else if (existingValue !== undefined) { form[key] = [existingValue, formValue]; } else { form[key] = formValue; } }); value = form; break; } case 'query': value = Object.fromEntries( Object.entries(c.req.queries()).map(([k, v]) => (v.length === 1 ? [k, v[0]] : [k, v])), ); break; case 'param': value = c.req.param(); break; case 'header': value = c.req.header(); break; case 'cookie': value = getCookie(c); break; default: value = {}; } if (options.pre) { value = await options.pre(value, c, target); } const transformedValue = convertEmptyValuesToNull(value); const result = await schema.safeParseAsync(transformedValue); if (options.post) { const hookResult = await options.post({...result, target}, c); if (hookResult) { if (hookResult instanceof Response) return hookResult; if ('response' in hookResult && hookResult.response instanceof Response) return hookResult.response; } } if (!result.success) { const errors: Array = []; const localizedErrors: Array = []; const seen = new Set(); for (const issue of result.error.issues) { const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root'; const code = getValidationErrorCode(issue.message); const key = `${path}|${code}`; if (seen.has(key)) continue; seen.add(key); const variables = extractVariablesFromIssue(issue); errors.push({path, message: code, code}); localizedErrors.push({path, code, variables}); } throw new InputValidationError(errors, localizedErrors); } c.req.addValidatedData(target, result.data as ValidationTargets[Target]); await next(); return; }; };