Skip to content

Commit 8f346df

Browse files
authored
feat!: show detailed validation errors in console (#6551)
BREAKING: `ValidationError` now requires the `global` or `collection` slug, as well as an `errors` property. The actual errors are no longer at the top-level.
1 parent 559c064 commit 8f346df

File tree

15 files changed

+101
-44
lines changed

15 files changed

+101
-44
lines changed

packages/db-mongodb/src/create.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Create, Document, PayloadRequestWithData } from 'payload'
22

33
import type { MongooseAdapter } from './index.js'
44

5-
import handleError from './utilities/handleError.js'
5+
import { handleError } from './utilities/handleError.js'
66
import { withSession } from './withSession.js'
77

88
export const create: Create = async function create(
@@ -15,7 +15,7 @@ export const create: Create = async function create(
1515
try {
1616
;[doc] = await Model.create([data], options)
1717
} catch (error) {
18-
handleError(error, req)
18+
handleError({ collection, error, req })
1919
}
2020

2121
// doc.toJSON does not do stuff like converting ObjectIds to string, or date strings to date objects. That's why we use JSON.parse/stringify here

packages/db-mongodb/src/updateOne.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { PayloadRequestWithData, UpdateOne } from 'payload'
22

33
import type { MongooseAdapter } from './index.js'
44

5-
import handleError from './utilities/handleError.js'
5+
import { handleError } from './utilities/handleError.js'
66
import sanitizeInternalFields from './utilities/sanitizeInternalFields.js'
77
import { withSession } from './withSession.js'
88

@@ -29,7 +29,7 @@ export const updateOne: UpdateOne = async function updateOne(
2929
try {
3030
result = await Model.findOneAndUpdate(query, data, options)
3131
} catch (error) {
32-
handleError(error, req)
32+
handleError({ collection, error, req })
3333
}
3434

3535
result = JSON.parse(JSON.stringify(result))

packages/db-mongodb/src/utilities/handleError.ts

+21-9
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
import httpStatus from 'http-status'
22
import { APIError, ValidationError } from 'payload'
33

4-
const handleError = (error, req) => {
4+
export const handleError = ({
5+
collection,
6+
error,
7+
global,
8+
req,
9+
}: {
10+
collection?: string
11+
error
12+
global?: string
13+
req
14+
}) => {
515
// Handle uniqueness error from MongoDB
616
if (error.code === 11000 && error.keyValue) {
717
throw new ValidationError(
8-
[
9-
{
10-
field: Object.keys(error.keyValue)[0],
11-
message: req.t('error:valueMustBeUnique'),
12-
},
13-
],
18+
{
19+
collection,
20+
errors: [
21+
{
22+
field: Object.keys(error.keyValue)[0],
23+
message: req.t('error:valueMustBeUnique'),
24+
},
25+
],
26+
global,
27+
},
1428
req.t,
1529
)
1630
} else if (error.code === 11000) {
@@ -19,5 +33,3 @@ const handleError = (error, req) => {
1933
throw error
2034
}
2135
}
22-
23-
export default handleError

packages/db-postgres/src/upsertRow/index.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,14 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
313313
} catch (error) {
314314
throw error.code === '23505'
315315
? new ValidationError(
316-
[
317-
{
318-
field: adapter.fieldConstraints[tableName][error.constraint],
319-
message: req.t('error:valueMustBeUnique'),
320-
},
321-
],
316+
{
317+
errors: [
318+
{
319+
field: adapter.fieldConstraints[tableName][error.constraint],
320+
message: req.t('error:valueMustBeUnique'),
321+
},
322+
],
323+
},
322324
req.t,
323325
)
324326
: error

packages/payload/src/auth/operations/login.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,16 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
8383
const { email: unsanitizedEmail, password } = data
8484

8585
if (typeof unsanitizedEmail !== 'string' || unsanitizedEmail.trim() === '') {
86-
throw new ValidationError([{ field: 'email', message: req.i18n.t('validation:required') }])
86+
throw new ValidationError({
87+
collection: collectionConfig.slug,
88+
errors: [{ field: 'email', message: req.i18n.t('validation:required') }],
89+
})
8790
}
8891
if (typeof password !== 'string' || password.trim() === '') {
89-
throw new ValidationError([{ field: 'password', message: req.i18n.t('validation:required') }])
92+
throw new ValidationError({
93+
collection: collectionConfig.slug,
94+
errors: [{ field: 'password', message: req.i18n.t('validation:required') }],
95+
})
9096
}
9197

9298
const email = unsanitizedEmail ? unsanitizedEmail.toLowerCase().trim() : null

packages/payload/src/auth/operations/resetPassword.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ export const resetPasswordOperation = async (args: Arguments): Promise<Result> =
6767
if (!user) throw new APIError('Token is either invalid or has expired.', httpStatus.FORBIDDEN)
6868

6969
// TODO: replace this method
70-
const { hash, salt } = await generatePasswordSaltHash({ password: data.password })
70+
const { hash, salt } = await generatePasswordSaltHash({
71+
collection: collectionConfig,
72+
password: data.password,
73+
})
7174

7275
user.salt = salt
7376
user.hash = hash

packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import crypto from 'crypto'
22

3+
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
4+
35
import { ValidationError } from '../../../errors/index.js'
46

57
const defaultPasswordValidator = (password: string): string | true => {
@@ -24,16 +26,21 @@ function pbkdf2Promisified(password: string, salt: string): Promise<Buffer> {
2426
}
2527

2628
type Args = {
29+
collection: SanitizedCollectionConfig
2730
password: string
2831
}
2932

3033
export const generatePasswordSaltHash = async ({
34+
collection,
3135
password,
3236
}: Args): Promise<{ hash: string; salt: string }> => {
3337
const validationResult = defaultPasswordValidator(password)
3438

3539
if (typeof validationResult === 'string') {
36-
throw new ValidationError([{ field: 'password', message: validationResult }])
40+
throw new ValidationError({
41+
collection: collection?.slug,
42+
errors: [{ field: 'password', message: validationResult }],
43+
})
3744
}
3845

3946
const saltBuffer = await randomBytes()

packages/payload/src/auth/strategies/local/register.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@ export const registerLocalStrategy = async ({
3434
})
3535

3636
if (existingUser.docs.length > 0) {
37-
throw new ValidationError([
38-
{ field: 'email', message: req.t('error:userEmailAlreadyRegistered') },
39-
])
37+
throw new ValidationError({
38+
collection: collection.slug,
39+
errors: [{ field: 'email', message: req.t('error:userEmailAlreadyRegistered') }],
40+
})
4041
}
4142

42-
const { hash, salt } = await generatePasswordSaltHash({ password })
43+
const { hash, salt } = await generatePasswordSaltHash({ collection, password })
4344

4445
const sanitizedDoc = { ...doc }
4546
if (sanitizedDoc.password) delete sanitizedDoc.password

packages/payload/src/collections/operations/updateByID.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,10 @@ export const updateByIDOperation = async <TSlug extends CollectionSlug>(
260260
const dataToUpdate: Record<string, unknown> = { ...result }
261261

262262
if (shouldSavePassword && typeof password === 'string') {
263-
const { hash, salt } = await generatePasswordSaltHash({ password })
263+
const { hash, salt } = await generatePasswordSaltHash({
264+
collection: collectionConfig,
265+
password,
266+
})
264267
dataToUpdate.salt = salt
265268
dataToUpdate.hash = hash
266269
delete dataToUpdate.password

packages/payload/src/errors/APIError.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ class ExtendableError<TData extends object = { [key: string]: unknown }> extends
1111
status: number
1212

1313
constructor(message: string, status: number, data: TData, isPublic: boolean) {
14-
super(message)
14+
super(message, {
15+
// show data in cause
16+
cause: data,
17+
})
1518
this.name = this.constructor.name
1619
this.message = message
1720
this.status = status

packages/payload/src/errors/ValidationError.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,25 @@ import httpStatus from 'http-status'
55

66
import { APIError } from './APIError.js'
77

8-
export class ValidationError extends APIError<{ field: string; message: string }[]> {
9-
constructor(results: { field: string; message: string }[], t?: TFunction) {
8+
export class ValidationError extends APIError<{
9+
collection?: string
10+
errors: { field: string; message: string }[]
11+
global?: string
12+
}> {
13+
constructor(
14+
results: { collection?: string; errors: { field: string; message: string }[]; global?: string },
15+
t?: TFunction,
16+
) {
1017
const message = t
11-
? t('error:followingFieldsInvalid', { count: results.length })
12-
: results.length === 1
18+
? t('error:followingFieldsInvalid', { count: results.errors.length })
19+
: results.errors.length === 1
1320
? en.translations.error.followingFieldsInvalid_one
1421
: en.translations.error.followingFieldsInvalid_other
1522

16-
super(`${message} ${results.map((f) => f.field).join(', ')}`, httpStatus.BAD_REQUEST, results)
23+
super(
24+
`${message} ${results.errors.map((f) => f.field).join(', ')}`,
25+
httpStatus.BAD_REQUEST,
26+
results,
27+
)
1728
}
1829
}

packages/payload/src/fields/hooks/beforeChange/index.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ export const beforeChange = async <T extends Record<string, unknown>>({
6969
})
7070

7171
if (errors.length > 0) {
72-
throw new ValidationError(errors, req.t)
72+
throw new ValidationError(
73+
{
74+
collection: collection?.slug,
75+
errors,
76+
global: global?.slug,
77+
},
78+
req.t,
79+
)
7380
}
7481

7582
await mergeLocaleActions.reduce(async (priorAction, action) => {

packages/payload/src/utilities/logger.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const pinoPretty = (pinoPrettyImport.default ||
77

88
export type PayloadLogger = pinoImport.default.Logger
99

10-
const prettyOptions = {
10+
const prettyOptions: pinoPrettyImport.PrettyOptions = {
1111
colorize: true,
1212
ignore: 'pid,hostname',
1313
translateTime: 'SYS:HH:MM:ss',

packages/ui/src/forms/Form/index.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,8 @@ export const Form: React.FC<FormProps> = (props) => {
339339
newNonFieldErrs.push(err)
340340
}
341341

342-
if (Array.isArray(err?.data)) {
343-
err.data.forEach((dataError) => {
342+
if (Array.isArray(err?.data?.errors)) {
343+
err.data?.errors.forEach((dataError) => {
344344
if (dataError?.field) {
345345
newFieldErrs.push(dataError)
346346
} else {

test/collections-graphql/int.spec.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -1185,24 +1185,26 @@ describe('collections-graphql', () => {
11851185
expect(errors[0].message).toEqual('The following field is invalid: password')
11861186
expect(errors[0].path[0]).toEqual('test2')
11871187
expect(errors[0].extensions.name).toEqual('ValidationError')
1188-
expect(errors[0].extensions.data[0].message).toEqual('No password was given')
1189-
expect(errors[0].extensions.data[0].field).toEqual('password')
1188+
expect(errors[0].extensions.data.errors[0].message).toEqual('No password was given')
1189+
expect(errors[0].extensions.data.errors[0].field).toEqual('password')
11901190

11911191
expect(Array.isArray(errors[1].locations)).toEqual(true)
11921192
expect(errors[1].message).toEqual('The following field is invalid: email')
11931193
expect(errors[1].path[0]).toEqual('test3')
11941194
expect(errors[1].extensions.name).toEqual('ValidationError')
1195-
expect(errors[1].extensions.data[0].message).toEqual(
1195+
expect(errors[1].extensions.data.errors[0].message).toEqual(
11961196
'A user with the given email is already registered.',
11971197
)
1198-
expect(errors[1].extensions.data[0].field).toEqual('email')
1198+
expect(errors[1].extensions.data.errors[0].field).toEqual('email')
11991199

12001200
expect(Array.isArray(errors[2].locations)).toEqual(true)
12011201
expect(errors[2].message).toEqual('The following field is invalid: email')
12021202
expect(errors[2].path[0]).toEqual('test4')
12031203
expect(errors[2].extensions.name).toEqual('ValidationError')
1204-
expect(errors[2].extensions.data[0].message).toEqual('Please enter a valid email address.')
1205-
expect(errors[2].extensions.data[0].field).toEqual('email')
1204+
expect(errors[2].extensions.data.errors[0].message).toEqual(
1205+
'Please enter a valid email address.',
1206+
)
1207+
expect(errors[2].extensions.data.errors[0].field).toEqual('email')
12061208
})
12071209

12081210
it('should return the minimum allowed information about internal errors', async () => {

0 commit comments

Comments
 (0)