diff --git a/.changeset/tricky-socks-rule.md b/.changeset/tricky-socks-rule.md new file mode 100644 index 000000000..2d562399d --- /dev/null +++ b/.changeset/tricky-socks-rule.md @@ -0,0 +1,5 @@ +--- +'@hono/standard-validator': minor +--- + +Add a new flattenErrors utility that allows errors to be sorted by form and field errors, with field errors also being sorted by path. diff --git a/packages/standard-validator/__schemas__/arktype.ts b/packages/standard-validator/__schemas__/arktype.ts index 31cda54f9..cb616cd6a 100644 --- a/packages/standard-validator/__schemas__/arktype.ts +++ b/packages/standard-validator/__schemas__/arktype.ts @@ -30,6 +30,12 @@ const headerSchema = type({ 'user-agent': 'string', }) +const userSchema = type({ + username: type('string.alphanumeric <= 10'), + password: type('string >= 4').pipe((value) => value.trim()), + '+': 'reject', +}) + export { headerSchema, idJSONSchema, @@ -38,4 +44,5 @@ export { queryNameSchema, queryPaginationSchema, querySortSchema, + userSchema, } diff --git a/packages/standard-validator/__schemas__/valibot.ts b/packages/standard-validator/__schemas__/valibot.ts index bad8bcdd4..0eb650cab 100644 --- a/packages/standard-validator/__schemas__/valibot.ts +++ b/packages/standard-validator/__schemas__/valibot.ts @@ -1,4 +1,18 @@ -import { object, string, number, optional, pipe, unknown, transform, picklist } from 'valibot' +import { + object, + string, + number, + optional, + pipe, + unknown, + transform, + picklist, + strictObject, + maxLength, + minLength, + regex, + trim, +} from 'valibot' const personJSONSchema = object({ name: string(), @@ -32,6 +46,15 @@ const headerSchema = object({ 'user-agent': string(), }) +const userSchema = strictObject({ + username: pipe( + string(), + maxLength(10, 'Username cannot be longer than 10 characters'), + regex(/^[\p{L}\p{N}_]+$/u, 'Username must contain only alphanumeric characters') + ), + password: pipe(string(), trim(), minLength(4, 'Password must be at least 4 characters long')), +}) + export { headerSchema, idJSONSchema, @@ -40,4 +63,5 @@ export { queryNameSchema, queryPaginationSchema, querySortSchema, + userSchema, } diff --git a/packages/standard-validator/__schemas__/zod.ts b/packages/standard-validator/__schemas__/zod.ts index 8099f4149..b13b6c17a 100644 --- a/packages/standard-validator/__schemas__/zod.ts +++ b/packages/standard-validator/__schemas__/zod.ts @@ -32,6 +32,14 @@ const headerSchema = z.object({ 'user-agent': z.string(), }) +const userSchema = z.strictObject({ + username: z + .string() + .max(10, 'Username cannot be longer than 10 characters') + .regex(/^[\p{L}\p{N}_]+$/u, 'Username must contain only alphanumeric characters'), + password: z.string().trim().min(4, 'Password must be at least 4 characters long'), +}) + export { headerSchema, idJSONSchema, @@ -40,4 +48,5 @@ export { queryNameSchema, queryPaginationSchema, querySortSchema, + userSchema, } diff --git a/packages/standard-validator/src/index.test.ts b/packages/standard-validator/src/index.test.ts index d4cc2501d..3dfae7908 100644 --- a/packages/standard-validator/src/index.test.ts +++ b/packages/standard-validator/src/index.test.ts @@ -8,7 +8,7 @@ import { vi } from 'vitest' import * as arktypeSchemas from '../__schemas__/arktype' import * as valibotSchemas from '../__schemas__/valibot' import * as zodSchemas from '../__schemas__/zod' -import { sValidator } from '.' +import { sValidator, flattenErrors } from '.' type MergeDiscriminatedUnion = UnionToIntersection extends infer O ? { [K in keyof O]: O[K] } : never @@ -489,3 +489,72 @@ describe('Standard Schema Validation', () => { }) }) }) + +describe('sortErrors', () => { + const testData = { + username: 'Super John Doe', + password: '123', + role: 'admin', + } + + it('sorts Zod validation errors by path', async () => { + // Arrange + const { issues = [] } = await zodSchemas.userSchema['~standard'].validate(testData) + + // Act + const sortedErrors = flattenErrors(issues) + + // Assert + expect(sortedErrors).toStrictEqual({ + formErrors: ['Unrecognized key: "role"'], + fieldErrors: { + username: [ + 'Username cannot be longer than 10 characters', + 'Username must contain only alphanumeric characters', + ], + password: ['Password must be at least 4 characters long'], + }, + }) + }) + + it('sorts Valibot validation errors by path', async () => { + // Arrange + const { issues = [] } = await valibotSchemas.userSchema['~standard'].validate(testData) + + // Act + const sortedErrors = flattenErrors(issues) + + // Assert + expect(sortedErrors).toStrictEqual({ + formErrors: [], + fieldErrors: { + username: [ + 'Username cannot be longer than 10 characters', + 'Username must contain only alphanumeric characters', + ], + password: ['Password must be at least 4 characters long'], + role: ['Invalid key: Expected never but received "role"'], + }, + }) + }) + + it('sorts ArkType validation errors by path', async () => { + // Arrange + const { issues = [] } = await arktypeSchemas.userSchema['~standard'].validate(testData) + + // Act + const sortedErrors = flattenErrors(issues) + + // Assert + expect(sortedErrors).toStrictEqual({ + formErrors: [], + fieldErrors: { + username: [ + expect.stringMatching(/username.*must be.*only letters and digits.*at most length 10/s), + ], + password: ['password must be at least length 4 (was 3)'], + role: ['role must be removed'], + }, + }) + }) +}) diff --git a/packages/standard-validator/src/index.ts b/packages/standard-validator/src/index.ts index 37116a3bd..f16d7df43 100644 --- a/packages/standard-validator/src/index.ts +++ b/packages/standard-validator/src/index.ts @@ -162,3 +162,32 @@ const sValidator = < export type { Hook } export { sValidator } + +interface FlattenedErrorObject { + formErrors: string[] + fieldErrors: Record +} + +/** + * Sorts validation errors by their paths. + * @param issues An array of {@link StandardSchemaV1.Issue validation issues}. + * @returns An object with sorted form and field errors. + */ +export const flattenErrors = (issues: readonly StandardSchemaV1.Issue[]): FlattenedErrorObject => { + const formErrors: string[] = [] + const fieldErrors: Record = {} + + for (const { path = [], message } of issues) { + const [issuePath] = path + const key = typeof issuePath === 'object' ? issuePath.key : issuePath + + if (typeof key !== 'undefined' && !fieldErrors[key]) { + fieldErrors[key] = [] + } + + const errors = typeof key !== 'undefined' ? fieldErrors[key] : formErrors + errors?.push(message) + } + + return { formErrors, fieldErrors } +}