Skip to content

Commit 6842fdb

Browse files
authored
feat: add File schema that returns a platform native File instance
1 parent 6086716 commit 6842fdb

File tree

9 files changed

+657
-0
lines changed

9 files changed

+657
-0
lines changed

index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export { VineObject } from './src/schema/object/main.js'
2727
export { VineLiteral } from './src/schema/literal/main.js'
2828
export { VineBoolean } from './src/schema/boolean/main.js'
2929
export { VineAccepted } from './src/schema/accepted/main.js'
30+
export { VineFile } from './src/schema/file/main.js'
3031
export { BaseLiteralType } from './src/schema/base/literal.js'
3132
export { BaseType, BaseModifiersType } from './src/schema/base/main.js'
3233
export { SimpleErrorReporter } from './src/reporters/simple_error_reporter.js'

src/defaults.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ export const messages = {
9797

9898
'date.weekend': 'The {{ field }} field is not a weekend',
9999
'date.weekday': 'The {{ field }} field is not a weekday',
100+
101+
'file': 'The {{ field }} field must be a valid file',
102+
'file.minSize': 'The {{ field }} field must be at least {{ min }} bytes in size',
103+
'file.maxSize': 'The {{ field }} field must not exceed {{ max }} bytes in size',
104+
'file.mimeTypes':
105+
'The {{ field }} field must be one of the following mime types: {{ mimeTypes }}',
100106
}
101107

102108
/**

src/schema/builder.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { VineAccepted } from './accepted/main.js'
2626
import { group } from './object/group_builder.js'
2727
import { VineNativeEnum } from './enum/native_enum.js'
2828
import { VineUnionOfTypes } from './union_of_types/main.js'
29+
import { VineFile } from './file/main.js'
2930
import { ITYPE, OTYPE, COTYPE, IS_OF_TYPE, UNIQUE_NAME } from '../symbols.js'
3031
import type {
3132
UndefinedOptional,
@@ -185,4 +186,9 @@ export class SchemaBuilder extends Macroable {
185186
schemasInUse.clear()
186187
return new VineUnionOfTypes(schemas)
187188
}
189+
190+
/** Define a file field */
191+
file() {
192+
return new VineFile()
193+
}
188194
}

src/schema/file/main.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* vinejs
3+
*
4+
* (c) VineJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { SUBTYPE } from '../../symbols.js'
11+
import { FieldOptions, Validation } from '../../types.js'
12+
import { BaseLiteralType } from '../base/literal.js'
13+
import { isFileRule, maxSizeRule, mimeTypesRule, minSizeRule } from './rules.js'
14+
15+
export class VineFile extends BaseLiteralType<File, File, File> {
16+
[SUBTYPE] = 'file'
17+
18+
constructor(options?: FieldOptions, validations?: Validation<any>[]) {
19+
super(options, validations || [isFileRule()])
20+
}
21+
22+
clone() {
23+
return new VineFile(this.cloneOptions(), this.cloneValidations()) as this
24+
}
25+
26+
minSize(size: number) {
27+
return this.use(minSizeRule({ min: size }))
28+
}
29+
30+
maxSize(size: number) {
31+
return this.use(maxSizeRule({ max: size }))
32+
}
33+
34+
mimeTypes(types: string[]) {
35+
return this.use(mimeTypesRule({ mimeTypes: types }))
36+
}
37+
}

src/schema/file/rules.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { messages } from '../../defaults.js'
2+
import { createRule } from '../../vine/create_rule.js'
3+
4+
export const isFileRule = createRule((value, _, field) => {
5+
if (!(value instanceof File)) field.report(messages.file, 'file', field)
6+
})
7+
8+
export const minSizeRule = createRule<{ min: number }>((value, options, field) => {
9+
if (!(value instanceof File) || !field.isValid) return
10+
if (value.size < options.min)
11+
field.report(messages['file.minSize'], 'file.minSize', field, options)
12+
})
13+
14+
export const maxSizeRule = createRule<{ max: number }>((value, options, field) => {
15+
if (!(value instanceof File) || !field.isValid) return
16+
if (value.size > options.max)
17+
field.report(messages['file.maxSize'], 'file.maxSize', field, options)
18+
})
19+
20+
export const mimeTypesRule = createRule<{ mimeTypes: string[] }>((value, options, field) => {
21+
if (!(value instanceof File) || !field.isValid) return
22+
const mimeType = value.type
23+
if (!options.mimeTypes.includes(mimeType))
24+
field.report(messages['file.mimeTypes'], 'file.mimeTypes', field, options)
25+
})

tests/integration/schema/file.spec.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* @vinejs/vine
3+
*
4+
* (c) VineJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { test } from '@japa/runner'
11+
import vine from '../../../index.js'
12+
13+
test.group('File', () => {
14+
test('fail when value is not a file', async ({ assert }) => {
15+
const schema = vine.object({
16+
file: vine.file(),
17+
})
18+
19+
const data = { file: 'not-a-file' }
20+
await assert.validationErrors(vine.validate({ schema, data }), [
21+
{
22+
field: 'file',
23+
message: 'The file field must be a valid file',
24+
rule: 'file',
25+
},
26+
])
27+
})
28+
29+
test('fail when file size exceeds the limit', async ({ assert }) => {
30+
const schema = vine.object({
31+
file: vine.file().maxSize(2 * 1024 * 1024),
32+
})
33+
34+
const data = { file: new File(['a'.repeat(3 * 1024 * 1024)], 'file.txt') } // Simulating a file of 3MB
35+
await assert.validationErrors(vine.validate({ schema, data }), [
36+
{
37+
field: 'file',
38+
meta: {
39+
max: 2 * 1024 * 1024,
40+
},
41+
message: `The file field must not exceed ${2 * 1024 * 1024} bytes in size`,
42+
rule: 'file.maxSize',
43+
},
44+
])
45+
})
46+
47+
test('pass when value is a valid file within size limit', async ({ assert }) => {
48+
const schema = vine.object({
49+
file: vine.file().maxSize(2 * 1024 * 1024),
50+
})
51+
52+
const data = { file: new File(['a'.repeat(1 * 1024 * 1024)], 'file.text') } // Simulating a file of 1MB
53+
await assert.validationOutput(vine.validate({ schema, data }), data)
54+
})
55+
56+
test('fail when file size is below the minimum limit', async ({ assert }) => {
57+
const schema = vine.object({
58+
file: vine.file().minSize(1 * 1024 * 1024),
59+
})
60+
61+
const data = { file: new File(['a'.repeat(512 * 1024)], 'file.txt') } // Simulating a file of 512KB
62+
await assert.validationErrors(vine.validate({ schema, data }), [
63+
{
64+
field: 'file',
65+
meta: {
66+
min: 1 * 1024 * 1024,
67+
},
68+
message: `The file field must be at least ${1 * 1024 * 1024} bytes in size`,
69+
rule: 'file.minSize',
70+
},
71+
])
72+
})
73+
74+
test('fail when value is not a valid MIME type', async ({ assert }) => {
75+
const schema = vine.object({
76+
file: vine.file().mimeTypes(['text/plain']),
77+
})
78+
79+
const data = {
80+
file: new File([''], 'file.txt', { type: 'image/png' }),
81+
}
82+
await assert.validationErrors(vine.validate({ schema, data }), [
83+
{
84+
field: 'file',
85+
message: 'The file field must be one of the following mime types: text/plain',
86+
meta: {
87+
mimeTypes: ['text/plain'],
88+
},
89+
rule: 'file.mimeTypes',
90+
},
91+
])
92+
})
93+
94+
test('pass when value is a valid MIME type', async ({ assert }) => {
95+
const schema = vine.object({
96+
file: vine.file().mimeTypes(['text/plain']),
97+
})
98+
99+
const data = {
100+
file: new File([''], 'file.txt', { type: 'text/plain' }),
101+
}
102+
await assert.validationOutput(vine.validate({ schema, data }), data)
103+
})
104+
})

tests/integration/validator.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import vine, {
1919
VineString,
2020
VineLiteral,
2121
VineBoolean,
22+
VineFile,
2223
} from '../../index.js'
2324
import { Infer } from '../../src/types.js'
2425
import { ValidationError } from '../../src/errors/validation_error.js'
@@ -190,6 +191,14 @@ test.group('Validator | extend schema classes', () => {
190191
assert.isTrue((vine.enum(['guest', 'moderator', 'admin']) as any).hasMultipleOptions())
191192
})
192193

194+
test('extend VineFile class', ({ assert }) => {
195+
VineFile.macro('isImage' as any, function (this: VineFile) {
196+
return true
197+
})
198+
199+
assert.isTrue((vine.file() as any).isImage())
200+
})
201+
193202
test('extend Vine class', ({ assert }) => {
194203
Vine.macro('money' as any, function (this: Vine) {
195204
return true

tests/unit/rules/file.spec.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* @vinejs/vine
3+
*
4+
* (c) VineJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
import { test } from '@japa/runner'
10+
import { validator } from '../../../factories/main.js'
11+
import {
12+
isFileRule,
13+
maxSizeRule,
14+
minSizeRule,
15+
mimeTypesRule,
16+
} from '../../../src/schema/file/rules.js'
17+
18+
test.group('File | isFileRule', () => {
19+
test('should pass when value is a valid file', () => {
20+
const rule = isFileRule()
21+
const file = new File([''], 'file.txt')
22+
const validated = validator.execute(rule, file)
23+
validated.assertSucceeded()
24+
})
25+
26+
test('should fail when value is not a file', () => {
27+
const rule = isFileRule()
28+
const validated = validator.execute(rule, 123)
29+
validated.assertError('The dummy field must be a valid file')
30+
})
31+
})
32+
33+
test.group('File | minSizeRule', () => {
34+
test('should pass when file size is greater than or equal to the minimum size', () => {
35+
const rule = minSizeRule({ min: 1024 }) // 1 KB
36+
const file = new File(['a'.repeat(1030)], 'file.txt') // 1 KB file
37+
const validated = validator.execute(rule, file)
38+
validated.assertSucceeded()
39+
})
40+
41+
test('does not run when its not valid', () => {
42+
const rule = minSizeRule({ min: 1024 }) // 1 KB
43+
const file = new File(['a'.repeat(1030)], 'file.txt') // 1 KB file
44+
const validated = validator.withContext({ isValid: false }).execute(rule, file)
45+
validated.assertSucceeded()
46+
})
47+
48+
test('does not run when its not a file', () => {
49+
const rule = minSizeRule({ min: 1024 }) // 1 KB
50+
const file = 'string'
51+
const validated = validator.execute(rule, file)
52+
validated.assertSucceeded()
53+
})
54+
55+
test('should fail when file size is less than the minimum size', () => {
56+
const rule = minSizeRule({ min: 1024 }) // 1 KB
57+
const file = new File(['a'.repeat(512)], 'file.txt') // 512 bytes file
58+
const validated = validator.execute(rule, file)
59+
validated.assertError('The dummy field must be at least 1024 bytes in size')
60+
})
61+
})
62+
63+
test.group('File | maxSizeRule', () => {
64+
test('should pass when file size is less than or equal to the maximum size', () => {
65+
const rule = maxSizeRule({ max: 2048 }) // 2 KB
66+
const file = new File(['a'.repeat(2030)], 'file.txt') // 2 KB file
67+
const validated = validator.execute(rule, file)
68+
validated.assertSucceeded()
69+
})
70+
71+
test('should fail when file size is greater than the maximum size', () => {
72+
const rule = maxSizeRule({ max: 2048 }) // 2 KB
73+
const file = new File(['a'.repeat(3072)], 'file.txt') // 3 KB file
74+
const validated = validator.execute(rule, file)
75+
validated.assertError('The dummy field must not exceed 2048 bytes in size')
76+
})
77+
78+
test('should not run when is not valid', () => {
79+
const rule = maxSizeRule({ max: 2048 }) // 2 KB
80+
const file = new File(['a'.repeat(1030)], 'file.txt') // 1 KB file
81+
const validated = validator.withContext({ isValid: false }).execute(rule, file)
82+
validated.assertSucceeded()
83+
})
84+
85+
test('should not run when is not a file', () => {
86+
const rule = maxSizeRule({ max: 2048 }) // 2 KB
87+
const file = 'string'
88+
const validated = validator.execute(rule, file)
89+
validated.assertSucceeded()
90+
})
91+
})
92+
93+
test.group('File | mimeTypesRule', () => {
94+
test('should pass when file has an allowed MIME type', () => {
95+
const rule = mimeTypesRule({ mimeTypes: ['text/plain', 'application/json'] })
96+
const file = new File([''], 'file.txt', { type: 'text/plain' })
97+
const validated = validator.execute(rule, file)
98+
validated.assertSucceeded()
99+
})
100+
101+
test('does not run when is not a file', () => {
102+
const rule = mimeTypesRule({ mimeTypes: ['text/plain', 'application/json'] })
103+
const file = 'string'
104+
const validated = validator.execute(rule, file)
105+
validated.assertSucceeded()
106+
})
107+
108+
test('does not run when is not valid', () => {
109+
const rule = mimeTypesRule({ mimeTypes: ['text/plain', 'application/json'] })
110+
const file = new File([''], 'file.txt', { type: 'text/plain' })
111+
const validated = validator.withContext({ isValid: false }).execute(rule, file)
112+
validated.assertSucceeded()
113+
})
114+
115+
test('should fail when file has a disallowed MIME type', () => {
116+
const rule = mimeTypesRule({ mimeTypes: ['text/plain', 'application/json'] })
117+
const file = new File([''], 'file.txt', { type: 'image/png' })
118+
const validated = validator.execute(rule, file)
119+
validated.assertError(
120+
'The dummy field must be one of the following mime types: text/plain,application/json'
121+
)
122+
})
123+
})

0 commit comments

Comments
 (0)