refactor progress
This commit is contained in:
26
packages/validation/package.json
Normal file
26
packages/validation/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@fluxer/validation",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/errors": "workspace:*",
|
||||
"hono": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
102
packages/validation/src/Validator.tsx
Normal file
102
packages/validation/src/Validator.tsx
Normal 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;
|
||||
};
|
||||
}
|
||||
43
packages/validation/src/ZodErrorMap.tsx
Normal file
43
packages/validation/src/ZodErrorMap.tsx
Normal 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;
|
||||
}
|
||||
130
packages/validation/src/__tests__/ValidationErrorCodes.test.tsx
Normal file
130
packages/validation/src/__tests__/ValidationErrorCodes.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
697
packages/validation/src/__tests__/Validator.test.tsx
Normal file
697
packages/validation/src/__tests__/Validator.test.tsx
Normal 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'});
|
||||
});
|
||||
});
|
||||
});
|
||||
359
packages/validation/src/__tests__/ZodErrorMap.test.tsx
Normal file
359
packages/validation/src/__tests__/ZodErrorMap.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
34
packages/validation/src/shared/ValidationErrorCodeUtils.tsx
Normal file
34
packages/validation/src/shared/ValidationErrorCodeUtils.tsx
Normal 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;
|
||||
}
|
||||
52
packages/validation/src/validator/ValidatorErrorFactory.tsx
Normal file
52
packages/validation/src/validator/ValidatorErrorFactory.tsx
Normal 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);
|
||||
}
|
||||
109
packages/validation/src/validator/ValidatorIssueVariables.tsx
Normal file
109
packages/validation/src/validator/ValidatorIssueVariables.tsx
Normal 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};
|
||||
}
|
||||
92
packages/validation/src/validator/ValidatorRequestValue.tsx
Normal file
92
packages/validation/src/validator/ValidatorRequestValue.tsx
Normal 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 {};
|
||||
}
|
||||
}
|
||||
79
packages/validation/src/validator/ValidatorTypes.tsx
Normal file
79
packages/validation/src/validator/ValidatorTypes.tsx
Normal 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};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
5
packages/validation/tsconfig.json
Normal file
5
packages/validation/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
44
packages/validation/vitest.config.ts
Normal file
44
packages/validation/vitest.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user