refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,102 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {createInputValidationError} from '@fluxer/validation/src/validator/ValidatorErrorFactory';
import {extractValidatorRequestValue} from '@fluxer/validation/src/validator/ValidatorRequestValue';
import type {
ValidatorHookOrOptions,
ValidatorInput,
ValidatorOptions,
ValidatorPostHookResult,
} from '@fluxer/validation/src/validator/ValidatorTypes';
import {normalizeValidatorValue} from '@fluxer/validation/src/validator/ValidatorValueNormalizer';
import {initializeFluxerErrorMap} from '@fluxer/validation/src/ZodErrorMap';
import type {Env, Input, MiddlewareHandler, ValidationTargets} from 'hono';
import type {ZodType} from 'zod';
function resolveValidatorOptions<
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets,
V extends Input,
>(hookOrOptions?: ValidatorHookOrOptions<T, E, P, Target, V>): ValidatorOptions<T, E, P, Target, V> {
if (typeof hookOrOptions === 'function') {
return {post: hookOrOptions};
}
return hookOrOptions ?? {};
}
function isResponseWithResponseProperty(value: unknown): value is {response: Response} {
return typeof value === 'object' && value !== null && 'response' in value && value.response instanceof Response;
}
function resolvePostHookResponse<O>(hookResult: ValidatorPostHookResult<O>): Response | undefined {
if (hookResult === undefined) {
return undefined;
}
if (hookResult instanceof Response) {
return hookResult;
}
if (isResponseWithResponseProperty(hookResult)) {
return hookResult.response;
}
return undefined;
}
export function createValidator<
T extends ZodType,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = T['_input'],
Out = T['_output'],
I extends Input = ValidatorInput<T, Target, In, Out>,
V extends I = I,
>(target: Target, schema: T, hookOrOptions?: ValidatorHookOrOptions<T, E, P, Target, V>): MiddlewareHandler<E, P, V> {
initializeFluxerErrorMap();
const options = resolveValidatorOptions(hookOrOptions);
return async (c, next): Promise<Response | undefined> => {
let value = await extractValidatorRequestValue(c, target);
if (options.pre) {
value = await options.pre(value, c, target);
}
const transformedValue = normalizeValidatorValue(value);
const result = await schema.safeParseAsync(transformedValue);
if (options.post) {
const hookResponse = resolvePostHookResponse(await options.post({...result, target}, c));
if (hookResponse !== undefined) {
return hookResponse;
}
}
if (!result.success) {
throw createInputValidationError(result.error.issues);
}
c.req.addValidatedData(target, result.data as ValidationTargets[Target]);
await next();
return;
};
}

View File

@@ -0,0 +1,43 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {resolveZodIssueValidationErrorCode} from '@fluxer/validation/src/error_map/ZodIssueErrorCodeResolver';
import {isValidationErrorCode} from '@fluxer/validation/src/shared/ValidationErrorCodeUtils';
import {z} from 'zod';
let isFluxerErrorMapInitialized = false;
export function fluxerZodErrorMap(
issue: Parameters<z.core.$ZodErrorMap>[0],
): {message: string} | string | undefined | null {
if (typeof issue.message === 'string' && isValidationErrorCode(issue.message)) {
return {message: issue.message};
}
return {message: resolveZodIssueValidationErrorCode(issue)};
}
export function initializeFluxerErrorMap(): void {
if (isFluxerErrorMapInitialized) {
return;
}
z.config({customError: fluxerZodErrorMap});
isFluxerErrorMapInitialized = true;
}

View File

@@ -0,0 +1,130 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {type ValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {describe, expect, it} from 'vitest';
describe('ValidationErrorCodes', () => {
describe('structure', () => {
it('should be a constant object', () => {
expect(typeof ValidationErrorCodes).toBe('object');
});
it('should have string values matching their keys', () => {
for (const [key, value] of Object.entries(ValidationErrorCodes)) {
expect(key).toBe(value);
expect(typeof value).toBe('string');
}
});
it('should have all unique values', () => {
const values = Object.values(ValidationErrorCodes);
const uniqueValues = new Set(values);
expect(uniqueValues.size).toBe(values.length);
});
});
describe('common error codes', () => {
it('should include format-related error codes', () => {
expect(ValidationErrorCodes.INVALID_FORMAT).toBe('INVALID_FORMAT');
expect(ValidationErrorCodes.INVALID_EMAIL_ADDRESS).toBe('INVALID_EMAIL_ADDRESS');
expect(ValidationErrorCodes.INVALID_SNOWFLAKE).toBe('INVALID_SNOWFLAKE');
expect(ValidationErrorCodes.INVALID_URL_FORMAT).toBe('INVALID_URL_FORMAT');
});
it('should include length-related error codes', () => {
expect(ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH).toBe('CONTENT_EXCEEDS_MAX_LENGTH');
expect(ValidationErrorCodes.STRING_LENGTH_INVALID).toBe('STRING_LENGTH_INVALID');
expect(ValidationErrorCodes.USERNAME_LENGTH_INVALID).toBe('USERNAME_LENGTH_INVALID');
expect(ValidationErrorCodes.PASSWORD_LENGTH_INVALID).toBe('PASSWORD_LENGTH_INVALID');
});
it('should include user-related error codes', () => {
expect(ValidationErrorCodes.USERNAME_INVALID_CHARACTERS).toBe('USERNAME_INVALID_CHARACTERS');
expect(ValidationErrorCodes.EMAIL_ALREADY_IN_USE).toBe('EMAIL_ALREADY_IN_USE');
expect(ValidationErrorCodes.INVALID_PASSWORD).toBe('INVALID_PASSWORD');
});
it('should include date-related error codes', () => {
expect(ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT).toBe('INVALID_DATE_OF_BIRTH_FORMAT');
expect(ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE).toBe('SCHEDULED_TIME_MUST_BE_FUTURE');
});
it('should include value range error codes', () => {
expect(ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE).toBe('VALUE_MUST_BE_INTEGER_IN_RANGE');
expect(ValidationErrorCodes.VALUE_TOO_SMALL).toBe('VALUE_TOO_SMALL');
});
});
describe('type safety', () => {
it('should allow using error codes as ValidationErrorCode type', () => {
const code: ValidationErrorCode = ValidationErrorCodes.INVALID_FORMAT;
expect(code).toBe('INVALID_FORMAT');
});
it('should work with Object.values for iteration', () => {
const allCodes = Object.values(ValidationErrorCodes);
expect(allCodes.length).toBeGreaterThan(0);
expect(allCodes).toContain('INVALID_FORMAT');
expect(allCodes).toContain('INVALID_EMAIL_ADDRESS');
});
it('should work with Object.keys for iteration', () => {
const allKeys = Object.keys(ValidationErrorCodes);
expect(allKeys.length).toBeGreaterThan(0);
expect(allKeys).toContain('INVALID_FORMAT');
expect(allKeys).toContain('INVALID_EMAIL_ADDRESS');
});
});
describe('specific error code categories', () => {
it('should have attachment-related error codes', () => {
expect(ValidationErrorCodes.ATTACHMENT_FIELDS_REQUIRED).toBe('ATTACHMENT_FIELDS_REQUIRED');
expect(ValidationErrorCodes.ATTACHMENT_MUST_BE_IMAGE).toBe('ATTACHMENT_MUST_BE_IMAGE');
expect(ValidationErrorCodes.DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED).toBe('DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED');
});
it('should have channel-related error codes', () => {
expect(ValidationErrorCodes.CHANNEL_NOT_FOUND).toBe('CHANNEL_NOT_FOUND');
expect(ValidationErrorCodes.CHANNEL_MUST_BE_VOICE).toBe('CHANNEL_MUST_BE_VOICE');
expect(ValidationErrorCodes.INVALID_CHANNEL_ID).toBe('INVALID_CHANNEL_ID');
});
it('should have guild-related error codes', () => {
expect(ValidationErrorCodes.GUILD_BANNER_REQUIRES_FEATURE).toBe('GUILD_BANNER_REQUIRES_FEATURE');
expect(ValidationErrorCodes.CANNOT_LEAVE_GUILD_AS_OWNER).toBe('CANNOT_LEAVE_GUILD_AS_OWNER');
});
it('should have permission-related error codes', () => {
expect(ValidationErrorCodes.PREMIUM_REQUIRED_FOR_CUSTOM_EMOJI).toBe('PREMIUM_REQUIRED_FOR_CUSTOM_EMOJI');
expect(ValidationErrorCodes.BANNERS_REQUIRE_PREMIUM).toBe('BANNERS_REQUIRE_PREMIUM');
});
it('should have token and authentication error codes', () => {
expect(ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN).toBe('INVALID_OR_EXPIRED_RESET_TOKEN');
expect(ValidationErrorCodes.INVALID_OR_EXPIRED_VERIFICATION_TOKEN).toBe('INVALID_OR_EXPIRED_VERIFICATION_TOKEN');
expect(ValidationErrorCodes.INVALID_MFA_CODE).toBe('INVALID_MFA_CODE');
});
it('should have rate limit error codes', () => {
expect(ValidationErrorCodes.AVATAR_CHANGED_TOO_MANY_TIMES).toBe('AVATAR_CHANGED_TOO_MANY_TIMES');
expect(ValidationErrorCodes.USERNAME_CHANGED_TOO_MANY_TIMES).toBe('USERNAME_CHANGED_TOO_MANY_TIMES');
});
});
});

View File

@@ -0,0 +1,697 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {createValidator} from '@fluxer/validation/src/Validator';
import {Hono} from 'hono';
import {describe, expect, it} from 'vitest';
import {z} from 'zod';
function createTestApp() {
return new Hono();
}
describe('Validator middleware', () => {
describe('JSON body validation', () => {
it('should validate valid JSON body', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
age: z.number(),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John', age: 30}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'John', age: 30});
});
it('should throw InputValidationError for invalid JSON body', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
age: z.number(),
});
let thrownError: Error | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
thrownError = err;
if (err instanceof InputValidationError) {
return new Response(JSON.stringify({error: 'validation_error'}), {status: 400});
}
return new Response('Internal error', {status: 500});
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 123, age: 'not a number'}),
});
expect(response.status).toBe(400);
expect(thrownError).toBeInstanceOf(InputValidationError);
});
it('should handle empty JSON body as empty object', async () => {
const app = createTestApp();
const schema = z.object({}).optional();
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json({received: data});
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '',
});
expect(response.status).toBe(200);
});
});
describe('Query parameter validation', () => {
it('should validate valid query parameters', async () => {
const app = createTestApp();
const schema = z.object({
page: z.string(),
limit: z.string(),
});
app.get('/test', createValidator('query', schema), (c) => {
const data = c.req.valid('query');
return c.json(data);
});
const response = await app.request('/test?page=1&limit=10');
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({page: '1', limit: '10'});
});
it('should throw InputValidationError for missing required query parameters', async () => {
const app = createTestApp();
const schema = z.object({
page: z.string(),
limit: z.string(),
});
let thrownError: Error | null = null;
app.get('/test', createValidator('query', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
thrownError = err;
return new Response('Error', {status: 400});
});
const response = await app.request('/test?page=1');
expect(response.status).toBe(400);
expect(thrownError).toBeInstanceOf(InputValidationError);
});
it('should handle multiple values for same query parameter', async () => {
const app = createTestApp();
const schema = z.object({
tags: z.array(z.string()),
});
app.get('/test', createValidator('query', schema), (c) => {
const data = c.req.valid('query');
return c.json(data);
});
const response = await app.request('/test?tags=a&tags=b&tags=c');
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({tags: ['a', 'b', 'c']});
});
});
describe('Path parameter validation', () => {
it('should validate valid path parameters', async () => {
const app = createTestApp();
const schema = z.object({
id: z.string(),
});
app.get('/users/:id', createValidator('param', schema), (c) => {
const data = c.req.valid('param');
return c.json(data);
});
const response = await app.request('/users/123');
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({id: '123'});
});
it('should throw InputValidationError for invalid path parameters', async () => {
const app = createTestApp();
const schema = z.object({
id: z.string().uuid(),
});
let thrownError: Error | null = null;
app.get('/users/:id', createValidator('param', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
thrownError = err;
return new Response('Error', {status: 400});
});
const response = await app.request('/users/not-a-uuid');
expect(response.status).toBe(400);
expect(thrownError).toBeInstanceOf(InputValidationError);
});
});
describe('Header validation', () => {
it('should validate valid headers', async () => {
const app = createTestApp();
const schema = z.object({
'x-api-key': z.string(),
});
app.get('/test', createValidator('header', schema), (c) => {
const data = c.req.valid('header');
return c.json({key: data['x-api-key']});
});
const response = await app.request('/test', {
headers: {'X-Api-Key': 'secret-key'},
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({key: 'secret-key'});
});
});
describe('Empty string to null conversion', () => {
it('should convert empty strings to null', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
bio: z.string().nullable(),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John', bio: ''}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'John', bio: null});
});
it('should convert nested empty strings to null', async () => {
const app = createTestApp();
const schema = z.object({
user: z.object({
name: z.string(),
description: z.string().nullable(),
}),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user: {name: 'John', description: ''}}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({user: {name: 'John', description: null}});
});
it('should convert empty arrays elements to null', async () => {
const app = createTestApp();
const schema = z.object({
items: z.array(z.string().nullable()),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: ['a', '', 'b']}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({items: ['a', null, 'b']});
});
});
describe('Hook options', () => {
it('should call pre hook before validation', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
app.post(
'/test',
createValidator('json', schema, {
pre: (value) => {
const obj = value as Record<string, unknown>;
return {...obj, name: String(obj.name).toUpperCase()};
},
}),
(c) => {
const data = c.req.valid('json');
return c.json(data);
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'john'}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'JOHN'});
});
it('should call post hook with validation result', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
let postHookCalled = false;
let postHookSuccess = false;
app.post(
'/test',
createValidator('json', schema, {
post: (result) => {
postHookCalled = true;
postHookSuccess = result.success;
return undefined;
},
}),
(c) => {
const data = c.req.valid('json');
return c.json(data);
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John'}),
});
expect(response.status).toBe(200);
expect(postHookCalled).toBe(true);
expect(postHookSuccess).toBe(true);
});
it('should allow post hook to return custom response', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
app.post(
'/test',
createValidator('json', schema, {
post: () => {
return new Response('Custom response', {status: 202});
},
}),
(c) => {
return c.json({reached: true});
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John'}),
});
expect(response.status).toBe(202);
const body = await response.text();
expect(body).toBe('Custom response');
});
it('should support hook as function shorthand for post hook', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
let hookCalled = false;
app.post(
'/test',
createValidator('json', schema, (result) => {
hookCalled = true;
expect(result.success).toBe(true);
return undefined;
}),
(c) => {
const data = c.req.valid('json');
return c.json(data);
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John'}),
});
expect(response.status).toBe(200);
expect(hookCalled).toBe(true);
});
});
describe('Error details', () => {
it('should include path in validation errors', async () => {
const app = createTestApp();
const schema = z.object({
user: z.object({
email: z.email(),
}),
});
let capturedError: InputValidationError | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
if (err instanceof InputValidationError) {
capturedError = err;
}
return new Response('Error', {status: 400});
});
await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user: {email: 'invalid'}}),
});
expect(capturedError).toBeInstanceOf(InputValidationError);
const localizedErrors = capturedError!.getLocalizedErrors();
expect(localizedErrors).not.toBeNull();
expect(localizedErrors![0].path).toBe('user.email');
expect(localizedErrors![0].code).toBe(ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
});
it('should include error code for too_small errors', async () => {
const app = createTestApp();
const schema = z.object({
items: z.array(z.string()).min(3),
});
let capturedError: InputValidationError | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
if (err instanceof InputValidationError) {
capturedError = err;
}
return new Response('Error', {status: 400});
});
await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: ['a']}),
});
expect(capturedError).toBeInstanceOf(InputValidationError);
const localizedErrors = capturedError!.getLocalizedErrors();
expect(localizedErrors).not.toBeNull();
expect(localizedErrors![0].path).toBe('items');
expect(localizedErrors![0].code).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('should include error code for too_big errors', async () => {
const app = createTestApp();
const schema = z.object({
count: z.number().max(10),
});
let capturedError: InputValidationError | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
if (err instanceof InputValidationError) {
capturedError = err;
}
return new Response('Error', {status: 400});
});
await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({count: 100}),
});
expect(capturedError).toBeInstanceOf(InputValidationError);
const localizedErrors = capturedError!.getLocalizedErrors();
expect(localizedErrors).not.toBeNull();
expect(localizedErrors![0].path).toBe('count');
expect(localizedErrors![0].code).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('should use root as path for top-level errors', async () => {
const app = createTestApp();
const schema = z.string();
let capturedError: InputValidationError | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
if (err instanceof InputValidationError) {
capturedError = err;
}
return new Response('Error', {status: 400});
});
await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({not: 'a string'}),
});
expect(capturedError).toBeInstanceOf(InputValidationError);
const localizedErrors = capturedError!.getLocalizedErrors();
expect(localizedErrors).not.toBeNull();
expect(localizedErrors![0].path).toBe('root');
});
});
describe('Form data validation', () => {
it('should validate form data', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
email: z.email(),
});
app.post('/test', createValidator('form', schema), (c) => {
const data = c.req.valid('form');
return c.json(data);
});
const formData = new FormData();
formData.append('name', 'John');
formData.append('email', 'john@example.com');
const response = await app.request('/test', {
method: 'POST',
body: formData,
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'John', email: 'john@example.com'});
});
it('should handle array fields in form data', async () => {
const app = createTestApp();
const schema = z.object({
'tags[]': z.array(z.string()),
});
app.post('/test', createValidator('form', schema), (c) => {
const data = c.req.valid('form');
return c.json(data);
});
const formData = new FormData();
formData.append('tags[]', 'a');
formData.append('tags[]', 'b');
formData.append('tags[]', 'c');
const response = await app.request('/test', {
method: 'POST',
body: formData,
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({'tags[]': ['a', 'b', 'c']});
});
it('should handle duplicate non-array fields as arrays', async () => {
const app = createTestApp();
const schema = z.object({
name: z.union([z.string(), z.array(z.string())]),
});
app.post('/test', createValidator('form', schema), (c) => {
const data = c.req.valid('form');
return c.json(data);
});
const formData = new FormData();
formData.append('name', 'John');
formData.append('name', 'Jane');
const response = await app.request('/test', {
method: 'POST',
body: formData,
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: ['John', 'Jane']});
});
});
describe('Async validation', () => {
it('should handle async schema validation', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string().refine(async (val) => val.length > 2, {
message: ValidationErrorCodes.STRING_LENGTH_INVALID,
}),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John'}),
});
expect(response.status).toBe(200);
});
it('should handle async pre hook', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
app.post(
'/test',
createValidator('json', schema, {
pre: async (value) => {
await new Promise((resolve) => setTimeout(resolve, 10));
const obj = value as Record<string, unknown>;
return {...obj, name: String(obj.name).toUpperCase()};
},
}),
(c) => {
const data = c.req.valid('json');
return c.json(data);
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'john'}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'JOHN'});
});
});
});

View File

@@ -0,0 +1,359 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {initializeFluxerErrorMap} from '@fluxer/validation/src/ZodErrorMap';
import {beforeAll, describe, expect, it} from 'vitest';
import {z} from 'zod';
describe('fluxerZodErrorMap', () => {
beforeAll(() => {
initializeFluxerErrorMap();
});
describe('invalid_type errors', () => {
it('should return INVALID_FORMAT for number type mismatch', () => {
const schema = z.number();
const result = schema.safeParse('not a number');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT for string type mismatch', () => {
const schema = z.string();
const result = schema.safeParse(123);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT for object type mismatch', () => {
const schema = z.object({name: z.string()});
const result = schema.safeParse('not an object');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT for array type mismatch', () => {
const schema = z.array(z.string());
const result = schema.safeParse('not an array');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
});
describe('unrecognized_keys errors', () => {
it('should return INVALID_FORMAT for unrecognized keys in strict mode', () => {
const schema = z.object({name: z.string()}).strict();
const result = schema.safeParse({name: 'test', extra: 'field'});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
});
describe('invalid_union errors', () => {
it('should return INVALID_FORMAT for union type mismatch', () => {
const schema = z.union([z.string(), z.number()]);
const result = schema.safeParse({});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
});
describe('invalid_value errors', () => {
it('should return INVALID_FORMAT for enum mismatch', () => {
const schema = z.enum(['a', 'b', 'c']);
const result = schema.safeParse('d');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT for literal mismatch', () => {
const schema = z.literal('expected');
const result = schema.safeParse('actual');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
});
describe('too_small errors', () => {
it('should return INVALID_FORMAT for string too short', () => {
const schema = z.string().min(5);
const result = schema.safeParse('abc');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT for number too small', () => {
const schema = z.number().min(10);
const result = schema.safeParse(5);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT for array too short', () => {
const schema = z.array(z.string()).min(3);
const result = schema.safeParse(['a']);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_DATE_OF_BIRTH_FORMAT for date too early', () => {
const minDate = new Date('2000-01-01');
const schema = z.date().min(minDate);
const result = schema.safeParse(new Date('1990-01-01'));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT);
}
});
});
describe('too_big errors', () => {
it('should return CONTENT_EXCEEDS_MAX_LENGTH for string too long', () => {
const schema = z.string().max(5);
const result = schema.safeParse('toolongstring');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH);
}
});
it('should return INVALID_FORMAT for number too large', () => {
const schema = z.number().max(10);
const result = schema.safeParse(100);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT for array too long', () => {
const schema = z.array(z.string()).max(2);
const result = schema.safeParse(['a', 'b', 'c', 'd']);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return SCHEDULED_TIME_MUST_BE_FUTURE for date too late', () => {
const maxDate = new Date('2000-01-01');
const schema = z.date().max(maxDate);
const result = schema.safeParse(new Date('2025-01-01'));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE);
}
});
});
describe('invalid_format errors', () => {
it('should return INVALID_EMAIL_ADDRESS for invalid email', () => {
const schema = z.email();
const result = schema.safeParse('not-an-email');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
});
it('should return INVALID_SNOWFLAKE for invalid uuid', () => {
const schema = z.string().uuid();
const result = schema.safeParse('not-a-uuid');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_SNOWFLAKE);
}
});
it('should return INVALID_FORMAT for invalid url', () => {
const schema = z.url();
const result = schema.safeParse('not-a-url');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
});
describe('not_multiple_of errors', () => {
it('should return INVALID_FORMAT for number not multiple of', () => {
const schema = z.number().multipleOf(5);
const result = schema.safeParse(7);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
});
describe('custom errors', () => {
it('should use error_code from params when provided', () => {
const schema = z.string().refine(() => false, {
params: {error_code: ValidationErrorCodes.USERNAME_LENGTH_INVALID},
});
const result = schema.safeParse('test');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_LENGTH_INVALID);
}
});
it('should return INVALID_FORMAT when error_code is not a valid ValidationErrorCode', () => {
const schema = z.string().refine(() => false, {
params: {error_code: 'SOME_INVALID_CODE'},
});
const result = schema.safeParse('test');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT when params has no error_code', () => {
const schema = z.string().refine(() => false, {
params: {other: 'value'},
});
const result = schema.safeParse('test');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should return INVALID_FORMAT when error_code is not a string', () => {
const schema = z.string().refine(() => false, {
params: {error_code: 123},
});
const result = schema.safeParse('test');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
});
describe('validation error code as message', () => {
it('should preserve validation error code when used as message directly', () => {
const schema = z.string().refine(() => false, {
message: ValidationErrorCodes.PASSWORD_LENGTH_INVALID,
});
const result = schema.safeParse('test');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.PASSWORD_LENGTH_INVALID);
}
});
});
describe('complex schema validation', () => {
it('should handle nested object validation', () => {
const schema = z.object({
user: z.object({
email: z.email(),
age: z.number().min(18),
}),
});
const result = schema.safeParse({
user: {
email: 'invalid',
age: 10,
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.length).toBe(2);
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
expect(result.error.issues[1].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
it('should handle array element validation', () => {
const schema = z.array(z.email());
const result = schema.safeParse(['valid@email.com', 'invalid', 'also@valid.com']);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
}
});
});
});
describe('initializeFluxerErrorMap', () => {
it('should set custom error map globally', () => {
initializeFluxerErrorMap();
const schema = z.string().min(10);
const result = schema.safeParse('short');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_FORMAT);
}
});
});

View File

@@ -0,0 +1,95 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {isValidationErrorCode} from '@fluxer/validation/src/shared/ValidationErrorCodeUtils';
import type {z} from 'zod';
type ZodIssue = Parameters<z.core.$ZodErrorMap>[0];
function getIssueOrigin(issue: ZodIssue): string | undefined {
if ('origin' in issue && typeof issue.origin === 'string') {
return issue.origin;
}
return undefined;
}
function getIssueFormat(issue: ZodIssue): string | undefined {
if ('format' in issue && typeof issue.format === 'string') {
return issue.format;
}
return undefined;
}
function getIssueParams(issue: ZodIssue): Record<string, unknown> | undefined {
if ('params' in issue && issue.params !== null && typeof issue.params === 'object') {
return issue.params as Record<string, unknown>;
}
return undefined;
}
function getCustomErrorCode(issue: ZodIssue): ValidationErrorCode | undefined {
const customErrorCode = getIssueParams(issue)?.['error_code'];
if (typeof customErrorCode === 'string' && isValidationErrorCode(customErrorCode)) {
return customErrorCode;
}
return undefined;
}
export function resolveZodIssueValidationErrorCode(issue: ZodIssue): ValidationErrorCode {
switch (issue.code) {
case 'too_small':
return getIssueOrigin(issue) === 'date'
? ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT
: ValidationErrorCodes.INVALID_FORMAT;
case 'too_big': {
const origin = getIssueOrigin(issue);
if (origin === 'string') {
return ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
}
if (origin === 'date') {
return ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE;
}
return ValidationErrorCodes.INVALID_FORMAT;
}
case 'invalid_format': {
const format = getIssueFormat(issue);
if (format === 'email') {
return ValidationErrorCodes.INVALID_EMAIL_ADDRESS;
}
if (format === 'uuid') {
return ValidationErrorCodes.INVALID_SNOWFLAKE;
}
return ValidationErrorCodes.INVALID_FORMAT;
}
case 'custom':
return getCustomErrorCode(issue) ?? ValidationErrorCodes.INVALID_FORMAT;
case 'invalid_type':
case 'unrecognized_keys':
case 'invalid_union':
case 'invalid_value':
case 'not_multiple_of':
case 'invalid_key':
case 'invalid_element':
return ValidationErrorCodes.INVALID_FORMAT;
default:
return ValidationErrorCodes.INVALID_FORMAT;
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
export function isValidationErrorCode(value: string): value is ValidationErrorCode {
return validationErrorCodeSet.has(value);
}
export function resolveValidationErrorCode(value: string | undefined): ValidationErrorCode {
if (value !== undefined && isValidationErrorCode(value)) {
return value;
}
return ValidationErrorCodes.INVALID_FORMAT;
}

View File

@@ -0,0 +1,52 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {
InputValidationError,
type LocalizedValidationError,
} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError';
import {resolveValidationErrorCode} from '@fluxer/validation/src/shared/ValidationErrorCodeUtils';
import {extractValidatorIssueVariables} from '@fluxer/validation/src/validator/ValidatorIssueVariables';
import type {ZodError} from 'zod';
type ZodValidationIssue = ZodError['issues'][number];
function getIssuePath(issue: ZodValidationIssue): string {
if (issue.path.length === 0) {
return 'root';
}
return issue.path.join('.');
}
export function createInputValidationError(issues: Array<ZodValidationIssue>): InputValidationError {
const errors: Array<ValidationError> = [];
const localizedErrors: Array<LocalizedValidationError> = [];
for (const issue of issues) {
const path = getIssuePath(issue);
const code = resolveValidationErrorCode(issue.message);
const variables = extractValidatorIssueVariables(issue);
errors.push({path, message: code, code});
localizedErrors.push({path, code, variables});
}
return new InputValidationError(errors, localizedErrors);
}

View File

@@ -0,0 +1,109 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {ZodError} from 'zod';
interface ZodTooSmallIssue {
code: 'too_small';
minimum: number | bigint;
type: string;
}
interface ZodTooBigIssue {
code: 'too_big';
maximum: number | bigint;
type: string;
}
interface ZodInvalidTypeIssue {
code: 'invalid_type';
expected: string;
received: string;
}
interface ZodCustomIssue {
code: 'custom';
params?: Record<string, unknown>;
}
type ZodValidationIssue = ZodError['issues'][number];
function getIssueFieldName(issue: ZodValidationIssue): string {
if (issue.path.length === 0) {
return 'field';
}
return String(issue.path[issue.path.length - 1]);
}
function isTooSmallIssue(issue: ZodValidationIssue): issue is ZodValidationIssue & ZodTooSmallIssue {
return issue.code === 'too_small' && 'minimum' in issue && 'type' in issue;
}
function isTooBigIssue(issue: ZodValidationIssue): issue is ZodValidationIssue & ZodTooBigIssue {
return issue.code === 'too_big' && 'maximum' in issue && 'type' in issue;
}
function isInvalidTypeIssue(issue: ZodValidationIssue): issue is ZodValidationIssue & ZodInvalidTypeIssue {
return issue.code === 'invalid_type' && 'expected' in issue && 'received' in issue;
}
function isCustomIssue(issue: ZodValidationIssue): issue is ZodValidationIssue & ZodCustomIssue {
return issue.code === 'custom';
}
export function extractValidatorIssueVariables(issue: ZodValidationIssue): Record<string, unknown> | undefined {
const fieldName = getIssueFieldName(issue);
if (isTooSmallIssue(issue)) {
return {
name: fieldName,
min: issue.minimum,
minValue: issue.minimum,
minimum: issue.minimum,
type: issue.type,
};
}
if (isTooBigIssue(issue)) {
return {
name: fieldName,
max: issue.maximum,
maxValue: issue.maximum,
maximum: issue.maximum,
type: issue.type,
};
}
if (isInvalidTypeIssue(issue)) {
return {
name: fieldName,
expected: issue.expected,
received: issue.received,
};
}
if (isCustomIssue(issue) && issue.params !== undefined && issue.params !== null) {
return {
name: fieldName,
...issue.params,
};
}
return {name: fieldName};
}

View File

@@ -0,0 +1,92 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {Context, Env, Input, ValidationTargets} from 'hono';
import {getCookie} from 'hono/cookie';
type FormDataEntryValue = File | string;
type FormValue = FormDataEntryValue | Array<FormDataEntryValue>;
async function extractJsonValue<E extends Env, P extends string, V extends Input>(
c: Context<E, P, V>,
): Promise<unknown> {
try {
return await c.req.json<unknown>();
} catch {
return {};
}
}
async function extractFormValue<E extends Env, P extends string, V extends Input>(
c: Context<E, P, V>,
): Promise<unknown> {
const formData = await c.req.formData();
const form: Record<string, FormValue> = {};
formData.forEach((value, key) => {
const existingValue = form[key];
if (key.endsWith('[]')) {
const list = Array.isArray(existingValue) ? existingValue : existingValue !== undefined ? [existingValue] : [];
list.push(value);
form[key] = list;
return;
}
if (Array.isArray(existingValue)) {
existingValue.push(value);
return;
}
if (existingValue !== undefined) {
form[key] = [existingValue, value];
return;
}
form[key] = value;
});
return form;
}
function extractQueryValue<E extends Env, P extends string, V extends Input>(c: Context<E, P, V>): unknown {
return Object.fromEntries(
Object.entries(c.req.queries()).map(([key, values]) => (values.length === 1 ? [key, values[0]] : [key, values])),
);
}
export async function extractValidatorRequestValue<
E extends Env,
P extends string,
V extends Input,
Target extends keyof ValidationTargets,
>(c: Context<E, P, V>, target: Target): Promise<unknown> {
switch (target) {
case 'json':
return extractJsonValue(c);
case 'form':
return extractFormValue(c);
case 'query':
return extractQueryValue(c);
case 'param':
return c.req.param();
case 'header':
return c.req.header();
case 'cookie':
return getCookie(c);
default:
return {};
}
}

View File

@@ -0,0 +1,79 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {Context, Env, Input, TypedResponse, ValidationTargets} from 'hono';
import type {ZodError, ZodType} from 'zod';
type HasUndefined<T> = undefined extends T ? true : false;
export type ValidatorSafeParseResult<T extends ZodType> =
| {success: true; data: T['_output']}
| {success: false; error: ZodError<T['_input']>};
export type ValidatorPostHookResult<O = Record<string, unknown>> = Response | undefined | TypedResponse<O>;
export type ValidatorPostHook<
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets = keyof ValidationTargets,
V extends Input = Input,
O = Record<string, unknown>,
> = (
result: ValidatorSafeParseResult<T> & {target: Target},
c: Context<E, P, V>,
) => ValidatorPostHookResult<O> | Promise<ValidatorPostHookResult<O>>;
export type ValidatorPreHook<
E extends Env,
P extends string,
Target extends keyof ValidationTargets,
V extends Input,
> = (value: unknown, c: Context<E, P, V>, target: Target) => unknown | Promise<unknown>;
export interface ValidatorOptions<
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets,
V extends Input,
> {
pre?: ValidatorPreHook<E, P, Target, V>;
post?: ValidatorPostHook<T, E, P, Target, V>;
}
export type ValidatorHookOrOptions<
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets,
V extends Input,
> = ValidatorPostHook<T, E, P, Target, V> | ValidatorOptions<T, E, P, Target, V>;
export type ValidatorInput<
T extends ZodType,
Target extends keyof ValidationTargets,
In = T['_input'],
Out = T['_output'],
> = {
in: HasUndefined<In> 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};
};

View File

@@ -0,0 +1,50 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
function isEmptyObject(value: object): boolean {
return Object.keys(value).length === 0;
}
export function normalizeValidatorValue(value: unknown, isRoot = true): unknown {
if (typeof value === 'string' && value === '') {
return null;
}
if (Array.isArray(value)) {
return value.map((item) => normalizeValidatorValue(item, false));
}
if (value !== null && typeof value === 'object') {
if (!isRoot && isEmptyObject(value)) {
return null;
}
const processedValue = Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, normalizeValidatorValue(nestedValue, false)]),
);
if (!isRoot && Object.values(processedValue).every((nestedValue) => nestedValue === null)) {
return null;
}
return processedValue;
}
return value;
}