Skip to content

Commit b3d5469

Browse files
committed
feat: enhance attribute value resolution to prefer request-scoped files for empty data fields
1 parent 981ca0b commit b3d5469

5 files changed

Lines changed: 142 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "kanun",
3-
"version": "1.0.6",
3+
"version": "1.0.7",
44
"description": "Framework-agnostic TypeScript-first validation library.",
55
"type": "module",
66
"files": [

src/BaseValidator.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -692,15 +692,21 @@ export class BaseValidator<D extends GenericObject = GenericObject> {
692692

693693
/**
694694
* Resolve an attribute value from validator data first, then request-scoped file context.
695+
* When the data value is empty (null or empty string), prefer a file from requestFiles
696+
* because framework body-parsers commonly set file input fields to '' or null.
697+
*
698+
* @param attribute
699+
* @returns
695700
*/
696701
private getAttributeValue (attribute: string): any {
697702
const dataValue = deepFind(this.data, attribute)
703+
const fileValue = deepFind(this.getContext().requestFiles ?? {}, attribute)
698704

699-
if (typeof dataValue !== 'undefined') {
700-
return dataValue
705+
if (typeof fileValue !== 'undefined' && (typeof dataValue === 'undefined' || dataValue === null || dataValue === '')) {
706+
return fileValue
701707
}
702708

703-
return deepFind(this.getContext().requestFiles ?? {}, attribute)
709+
return dataValue
704710
}
705711

706712

src/validators/validateAttributes.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,12 +1056,13 @@ class validateAttributes {
10561056

10571057
private getAttributeValue (attribute: string): any {
10581058
const dataValue = deepFind(this.data, attribute)
1059+
const fileValue = deepFind(this.context.requestFiles ?? {}, attribute)
10591060

1060-
if (typeof dataValue !== 'undefined') {
1061-
return dataValue
1061+
if (typeof fileValue !== 'undefined' && (typeof dataValue === 'undefined' || dataValue === null || dataValue === '')) {
1062+
return fileValue
10621063
}
10631064

1064-
return deepFind(this.context.requestFiles ?? {}, attribute)
1065+
return dataValue
10651066
}
10661067

10671068
private getDistinctValues (primaryAttribute: string): any[] {

test.cjs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const { createServer } = require('node:http')
2+
const { useExpressUploadContext, fileValidatorPlugin } = require('./packages/file/dist')
3+
4+
const { Validator, } = require('./dist')
5+
const express = require('express')
6+
const multer = require('multer')
7+
Validator.use(fileValidatorPlugin)
8+
9+
async function listenHttpServer (handler) {
10+
const server = createServer(handler)
11+
12+
await new Promise((resolve, reject) => {
13+
server.once('error', reject)
14+
server.listen(0, '127.0.0.1', () => {
15+
server.off('error', reject)
16+
resolve()
17+
})
18+
})
19+
20+
const address = server.address()
21+
22+
if (!address || typeof address === 'string') {
23+
throw new Error('Could not determine server address.')
24+
}
25+
26+
return {
27+
close: async () => {
28+
await new Promise((resolve, reject) => {
29+
server.close(error => {
30+
if (error) {
31+
reject(error)
32+
return
33+
}
34+
35+
resolve()
36+
})
37+
})
38+
},
39+
url: `http://127.0.0.1:${(address).port}`,
40+
}
41+
}
42+
43+
const test = async () => {
44+
const app = express()
45+
const upload = multer({ storage: multer.memoryStorage() })
46+
47+
const png1x1 = Buffer.from(
48+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlH0b8AAAAASUVORK5CYII=',
49+
'base64',
50+
)
51+
52+
53+
app.use(upload.single('avatar'))
54+
app.use(function (request, _response, next) {
55+
useExpressUploadContext(request)
56+
next()
57+
})
58+
59+
app.post('/middleware-required-global', async function (_request, response) {
60+
const validated = await Validator
61+
.make({}, { avatar: 'required|file|image|mimes:png' })
62+
.validate()
63+
64+
response.json({
65+
hasAvatar: Object.prototype.hasOwnProperty.call(validated, 'avatar'),
66+
})
67+
})
68+
69+
70+
const server = await listenHttpServer(app)
71+
72+
try {
73+
const formData = new FormData()
74+
formData.append('avatar', new File([png1x1], 'avatar.png', { type: 'image/png' }))
75+
76+
const response = await fetch(`${server.url}/middleware-required-global`, {
77+
body: formData,
78+
method: 'POST',
79+
})
80+
81+
console.log(await response.text())
82+
} finally {
83+
await server.close()
84+
}
85+
}
86+
87+
test().catch(console.error)

tests/file-plugin-validator.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const png1x1 = Buffer.from(
3030
'base64',
3131
)
3232

33-
async function listenHttpServer (handler: RequestListener) {
33+
export async function listenHttpServer (handler: RequestListener) {
3434
const server = createServer(handler)
3535

3636
await new Promise<void>((resolve, reject) => {
@@ -201,6 +201,46 @@ describe('File validator plugin', function () {
201201
assert.deepEqual(await validator.validate(), { avatar })
202202
})
203203

204+
it('treats request-scoped files as present when data has empty string for field', async () => {
205+
const avatar = {
206+
buffer: png1x1,
207+
mimetype: 'image/png',
208+
originalname: 'avatar.png',
209+
size: 2048,
210+
}
211+
212+
const validator = Validator.make(
213+
{ avatar: '' } as any,
214+
{
215+
avatar: ['required', 'file', 'image', 'extensions:png'],
216+
},
217+
).withContext({
218+
requestFiles: { avatar },
219+
})
220+
221+
assert.equal(await validator.passes(), true)
222+
})
223+
224+
it('treats request-scoped files as present when data has null for field', async () => {
225+
const avatar = {
226+
buffer: png1x1,
227+
mimetype: 'image/png',
228+
originalname: 'avatar.png',
229+
size: 2048,
230+
}
231+
232+
const validator = Validator.make(
233+
{ avatar: null } as any,
234+
{
235+
avatar: ['required', 'file', 'image', 'extensions:png'],
236+
},
237+
).withContext({
238+
requestFiles: { avatar },
239+
})
240+
241+
assert.equal(await validator.passes(), true)
242+
})
243+
204244
it('still fails the required rule when a request-scoped file is missing', async () => {
205245
const validator = Validator.make(
206246
{},

0 commit comments

Comments
 (0)