Skip to content

Commit 98e4e81

Browse files
KTrain5169stebeusautofix-ci[bot]
authored
feat(standard-validator): Add flattenErrors utility to @hono/standard-validator (#2050)
* fear(standard-validator): Add sortErrors utility Co-authored-by: stebeus <189894010+stebeus@users.noreply.github.com> * feat(standard-validator): add test cases for sortErrors * chore(standard-validator): add arktype test for sortErrors & format directory * chore(standard-validator): changeset add * refactor(standard-validator): make user ArkType schema idiomatic It's verbose to do a one-to-one translation of a Zod/Valibot schema to ArkType, on which hints that ArkType is used differently from other validators. * feat(standard-validator): reject invalid keys for the user ArkType schema * test(standard-validator): fix `sortErrors` assertions * rename `sortErrors` to `flattenErrors` * typeof key !== 'undefined' * Forgot to update changeset * ci: apply automated fixes --------- Co-authored-by: stebeus <189894010+stebeus@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 6ba1731 commit 98e4e81

6 files changed

Lines changed: 145 additions & 2 deletions

File tree

.changeset/tricky-socks-rule.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hono/standard-validator': minor
3+
---
4+
5+
Add a new flattenErrors utility that allows errors to be sorted by form and field errors, with field errors also being sorted by path.

packages/standard-validator/__schemas__/arktype.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ const headerSchema = type({
3030
'user-agent': 'string',
3131
})
3232

33+
const userSchema = type({
34+
username: type('string.alphanumeric <= 10'),
35+
password: type('string >= 4').pipe((value) => value.trim()),
36+
'+': 'reject',
37+
})
38+
3339
export {
3440
headerSchema,
3541
idJSONSchema,
@@ -38,4 +44,5 @@ export {
3844
queryNameSchema,
3945
queryPaginationSchema,
4046
querySortSchema,
47+
userSchema,
4148
}

packages/standard-validator/__schemas__/valibot.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
import { object, string, number, optional, pipe, unknown, transform, picklist } from 'valibot'
1+
import {
2+
object,
3+
string,
4+
number,
5+
optional,
6+
pipe,
7+
unknown,
8+
transform,
9+
picklist,
10+
strictObject,
11+
maxLength,
12+
minLength,
13+
regex,
14+
trim,
15+
} from 'valibot'
216

317
const personJSONSchema = object({
418
name: string(),
@@ -32,6 +46,15 @@ const headerSchema = object({
3246
'user-agent': string(),
3347
})
3448

49+
const userSchema = strictObject({
50+
username: pipe(
51+
string(),
52+
maxLength(10, 'Username cannot be longer than 10 characters'),
53+
regex(/^[\p{L}\p{N}_]+$/u, 'Username must contain only alphanumeric characters')
54+
),
55+
password: pipe(string(), trim(), minLength(4, 'Password must be at least 4 characters long')),
56+
})
57+
3558
export {
3659
headerSchema,
3760
idJSONSchema,
@@ -40,4 +63,5 @@ export {
4063
queryNameSchema,
4164
queryPaginationSchema,
4265
querySortSchema,
66+
userSchema,
4367
}

packages/standard-validator/__schemas__/zod.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ const headerSchema = z.object({
3232
'user-agent': z.string(),
3333
})
3434

35+
const userSchema = z.strictObject({
36+
username: z
37+
.string()
38+
.max(10, 'Username cannot be longer than 10 characters')
39+
.regex(/^[\p{L}\p{N}_]+$/u, 'Username must contain only alphanumeric characters'),
40+
password: z.string().trim().min(4, 'Password must be at least 4 characters long'),
41+
})
42+
3543
export {
3644
headerSchema,
3745
idJSONSchema,
@@ -40,4 +48,5 @@ export {
4048
queryNameSchema,
4149
queryPaginationSchema,
4250
querySortSchema,
51+
userSchema,
4352
}

packages/standard-validator/src/index.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { vi } from 'vitest'
88
import * as arktypeSchemas from '../__schemas__/arktype'
99
import * as valibotSchemas from '../__schemas__/valibot'
1010
import * as zodSchemas from '../__schemas__/zod'
11-
import { sValidator } from '.'
11+
import { sValidator, flattenErrors } from '.'
1212

1313
type MergeDiscriminatedUnion<U> =
1414
UnionToIntersection<U> extends infer O ? { [K in keyof O]: O[K] } : never
@@ -489,3 +489,72 @@ describe('Standard Schema Validation', () => {
489489
})
490490
})
491491
})
492+
493+
describe('sortErrors', () => {
494+
const testData = {
495+
username: 'Super John Doe',
496+
password: '123',
497+
role: 'admin',
498+
}
499+
500+
it('sorts Zod validation errors by path', async () => {
501+
// Arrange
502+
const { issues = [] } = await zodSchemas.userSchema['~standard'].validate(testData)
503+
504+
// Act
505+
const sortedErrors = flattenErrors(issues)
506+
507+
// Assert
508+
expect(sortedErrors).toStrictEqual({
509+
formErrors: ['Unrecognized key: "role"'],
510+
fieldErrors: {
511+
username: [
512+
'Username cannot be longer than 10 characters',
513+
'Username must contain only alphanumeric characters',
514+
],
515+
password: ['Password must be at least 4 characters long'],
516+
},
517+
})
518+
})
519+
520+
it('sorts Valibot validation errors by path', async () => {
521+
// Arrange
522+
const { issues = [] } = await valibotSchemas.userSchema['~standard'].validate(testData)
523+
524+
// Act
525+
const sortedErrors = flattenErrors(issues)
526+
527+
// Assert
528+
expect(sortedErrors).toStrictEqual({
529+
formErrors: [],
530+
fieldErrors: {
531+
username: [
532+
'Username cannot be longer than 10 characters',
533+
'Username must contain only alphanumeric characters',
534+
],
535+
password: ['Password must be at least 4 characters long'],
536+
role: ['Invalid key: Expected never but received "role"'],
537+
},
538+
})
539+
})
540+
541+
it('sorts ArkType validation errors by path', async () => {
542+
// Arrange
543+
const { issues = [] } = await arktypeSchemas.userSchema['~standard'].validate(testData)
544+
545+
// Act
546+
const sortedErrors = flattenErrors(issues)
547+
548+
// Assert
549+
expect(sortedErrors).toStrictEqual({
550+
formErrors: [],
551+
fieldErrors: {
552+
username: [
553+
expect.stringMatching(/username.*must be.*only letters and digits.*at most length 10/s),
554+
],
555+
password: ['password must be at least length 4 (was 3)'],
556+
role: ['role must be removed'],
557+
},
558+
})
559+
})
560+
})

packages/standard-validator/src/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,32 @@ const sValidator = <
162162

163163
export type { Hook }
164164
export { sValidator }
165+
166+
interface FlattenedErrorObject {
167+
formErrors: string[]
168+
fieldErrors: Record<string, string[]>
169+
}
170+
171+
/**
172+
* Sorts validation errors by their paths.
173+
* @param issues An array of {@link StandardSchemaV1.Issue validation issues}.
174+
* @returns An object with sorted form and field errors.
175+
*/
176+
export const flattenErrors = (issues: readonly StandardSchemaV1.Issue[]): FlattenedErrorObject => {
177+
const formErrors: string[] = []
178+
const fieldErrors: Record<PropertyKey, string[]> = {}
179+
180+
for (const { path = [], message } of issues) {
181+
const [issuePath] = path
182+
const key = typeof issuePath === 'object' ? issuePath.key : issuePath
183+
184+
if (typeof key !== 'undefined' && !fieldErrors[key]) {
185+
fieldErrors[key] = []
186+
}
187+
188+
const errors = typeof key !== 'undefined' ? fieldErrors[key] : formErrors
189+
errors?.push(message)
190+
}
191+
192+
return { formErrors, fieldErrors }
193+
}

0 commit comments

Comments
 (0)