Skip to content

Commit 727767e

Browse files
committed
🎉 feat: union file extension check
1 parent dcbd815 commit 727767e

File tree

11 files changed

+408
-68
lines changed

11 files changed

+408
-68
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 1.3.0
22
Feature:
3-
- add `jsonAccelerator`,`exactMirror`
3+
- add `jsonAccelerator`, `exactMirror`
44
- add `systemRouter` config
55
- `standalone Validator`
66
- add `Elysia.Ref` for referencing schema with autocompletion instead of `t.Ref`

bun.lock

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44
"": {
55
"name": "elysia",
66
"dependencies": {
7-
"@sinclair/typebox": "^0.34.33",
87
"cookie": "^1.0.2",
98
"exact-mirror": "0.1.1",
109
"fast-decode-uri-component": "^1.0.1",
11-
"openapi-types": "^12.1.3",
1210
},
1311
"devDependencies": {
1412
"@types/bun": "^1.2.9",
@@ -27,19 +25,17 @@
2725
"tsup": "^8.4.0",
2826
"typescript": "^5.8.3",
2927
},
28+
"optionalDependencies": {
29+
"@sinclair/typebox": "^0.34.33",
30+
"openapi-types": "^12.1.3",
31+
},
3032
"peerDependencies": {
3133
"@sinclair/typebox": ">= 0.34.0",
3234
"exact-mirror": ">= 0.0.9",
3335
"file-type": ">= 20.0.0",
3436
"openapi-types": ">= 12.0.0",
3537
"typescript": ">= 5.0.0",
3638
},
37-
"optionalPeers": [
38-
"exact-mirror",
39-
"file-type",
40-
"openapi-types",
41-
"typescript",
42-
],
4339
},
4440
},
4541
"packages": {

example/a.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,41 @@
11
import { Elysia, t } from '../src'
2-
import { req } from '../test/utils'
2+
import { hasType } from '../src/schema'
3+
import { req, upload } from '../test/utils'
34

45
const app = new Elysia()
5-
// .onRequest(async () => {})
6-
.mount('/auth', () => new Response('OK'))
6+
.post('/', ({ body }) => 'ok', {
7+
body: t.Union([
8+
t.Object({
9+
hello: t.String(),
10+
file: t.File({
11+
type: 'image'
12+
}),
13+
a: t.File({
14+
type: 'image'
15+
})
16+
}),
17+
t.Object({
18+
world: t.String(),
19+
image: t.File({
20+
type: 'image'
21+
})
22+
}),
23+
t.Object({
24+
world: t.String()
25+
})
26+
])
27+
})
728
.listen(3000)
829

9-
console.log(app.router)
30+
// console.log(app.routes[0].compile().toString())
31+
32+
// app.handle(
33+
// upload('/', {
34+
// hello: 'world',
35+
// file: 'aris-yuzu.jpg'
36+
// }).request
37+
// )
38+
// .then((x) => x.text())
39+
// .then(console.log)
40+
41+
// // console.log(app.router)

package.json

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,9 @@
178178
"release": "npm run build && npm run test && npm publish"
179179
},
180180
"dependencies": {
181-
"@sinclair/typebox": "^0.34.33",
182181
"cookie": "^1.0.2",
183182
"exact-mirror": "0.1.1",
184-
"fast-decode-uri-component": "^1.0.1",
185-
"openapi-types": "^12.1.3"
183+
"fast-decode-uri-component": "^1.0.1"
186184
},
187185
"devDependencies": {
188186
"@types/bun": "^1.2.9",
@@ -208,21 +206,8 @@
208206
"openapi-types": ">= 12.0.0",
209207
"typescript": ">= 5.0.0"
210208
},
211-
"peerDependenciesMeta": {
212-
"exact-mirror": {
213-
"optional": true
214-
},
215-
"json-accelerator": {
216-
"optional": true
217-
},
218-
"file-type": {
219-
"optional": true
220-
},
221-
"openapi-types": {
222-
"optional": true
223-
},
224-
"typescript": {
225-
"optional": true
226-
}
209+
"optionalDependencies": {
210+
"@sinclair/typebox": "^0.34.33",
211+
"openapi-types": "^12.1.3"
227212
}
228213
}

src/compose.ts

Lines changed: 109 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,14 @@ import {
3434
} from './error'
3535
import { ELYSIA_TRACE, type TraceHandler } from './trace'
3636

37-
import { ElysiaTypeCheck, getCookieValidator, hasType, isUnion } from './schema'
37+
import {
38+
coercePrimitiveRoot,
39+
ElysiaTypeCheck,
40+
getCookieValidator,
41+
getSchemaValidator,
42+
hasType,
43+
isUnion
44+
} from './schema'
3845
import { Sucrose, sucrose } from './sucrose'
3946
import { parseCookie, type CookieOptions } from './cookies'
4047
import { validateFileExtension } from './type-system/utils'
@@ -1172,6 +1179,8 @@ export const composeHandler = ({
11721179
reporter.resolve()
11731180
}
11741181

1182+
const fileUnions = <ElysiaTypeCheck<any>[]>[]
1183+
11751184
if (validator) {
11761185
if (validator.headers) {
11771186
if (validator.headers.hasDefault)
@@ -1291,10 +1300,8 @@ export const composeHandler = ({
12911300
if (validator.body.hasTransform || validator.body.isOptional)
12921301
fnLiteral += `const isNotEmptyObject=c.body&&(typeof c.body==="object"&&isNotEmpty(c.body))\n`
12931302

1294-
const hasFile =
1295-
!isUnion(validator.body.schema) &&
1296-
(hasType('File', validator.body.schema) ||
1297-
hasType('Files', validator.body.schema))
1303+
const hasUnion = isUnion(validator.body.schema)
1304+
let hasNonUnionFileWithDefault = false
12981305

12991306
if (validator.body.hasDefault) {
13001307
let value = Value.Default(
@@ -1308,8 +1315,15 @@ export const composeHandler = ({
13081315
: undefined
13091316
)
13101317

1311-
// remove default value of t.File / t.Files
1312-
if (hasFile && value && typeof value === 'object') {
1318+
if (
1319+
!hasUnion &&
1320+
value &&
1321+
typeof value === 'object' &&
1322+
(hasType('File', validator.body.schema) ||
1323+
hasType('Files', validator.body.schema))
1324+
) {
1325+
hasNonUnionFileWithDefault = true
1326+
13131327
for (const [k, v] of Object.entries(value))
13141328
if (v === 'File' || v === 'Files')
13151329
// @ts-ignore
@@ -1377,26 +1391,90 @@ export const composeHandler = ({
13771391
if (validator.body.hasTransform)
13781392
fnLiteral += `if(isNotEmptyObject)c.body=validator.body.Decode(c.body)\n`
13791393

1380-
if (hasFile) {
1394+
if (hasUnion && validator.body.schema.anyOf?.length) {
1395+
const iterator = Object.values(
1396+
validator.body.schema.anyOf
1397+
) as TAnySchema[]
1398+
1399+
for (let i = 0; i < iterator.length; i++) {
1400+
const type = iterator[i]
1401+
1402+
if (hasType('File', type) || hasType('Files', type)) {
1403+
const candidate = getSchemaValidator(type, {
1404+
// @ts-expect-error private property
1405+
modules: app.definitions.typebox,
1406+
dynamic: !app.config.aot,
1407+
// @ts-expect-error private property
1408+
models: app.definitions.type,
1409+
normalize: app.config.normalize,
1410+
additionalCoerce: coercePrimitiveRoot(),
1411+
sanitize: () => app.config.sanitize
1412+
})
1413+
1414+
if (candidate) {
1415+
const isFirst = fileUnions.length === 0
1416+
1417+
const iterator = Object.entries(
1418+
type.properties
1419+
) as [string, TSchema][]
1420+
1421+
let validator = isFirst ? '\n' : ' else '
1422+
validator += `if(fileUnions[${fileUnions.length}].Check(c.body)){`
1423+
1424+
let validateFile = ''
1425+
let validatorLength = 0
1426+
for (let i = 0; i < iterator.length; i++) {
1427+
const [k, v] = iterator[i]
1428+
1429+
if (
1430+
!v.extension ||
1431+
(v[Kind] !== 'File' && v[Kind] !== 'Files')
1432+
)
1433+
continue
1434+
1435+
if (validatorLength) validateFile += ','
1436+
validateFile += `validateFileExtension(c.body.${k},${JSON.stringify(v.extension)},'body.${k}')`
1437+
1438+
validatorLength++
1439+
}
1440+
1441+
if (validateFile) {
1442+
if (validatorLength === 1)
1443+
validator += `await ${validateFile}\n`
1444+
else if (validatorLength > 1)
1445+
validator += `await Promise.all([${validateFile}])\n`
1446+
1447+
validator += '}'
1448+
1449+
fnLiteral += validator
1450+
fileUnions.push(candidate)
1451+
}
1452+
}
1453+
}
1454+
}
1455+
} else if (
1456+
hasNonUnionFileWithDefault ||
1457+
(!hasUnion &&
1458+
(hasType('File', validator.body.schema) ||
1459+
hasType('Files', validator.body.schema)))
1460+
) {
13811461
let validateFile = ''
13821462

13831463
let i = 0
1464+
for (const [k, v] of Object.entries(
1465+
validator.body.schema.properties
1466+
) as [string, TSchema][]) {
1467+
if (
1468+
!v.extension ||
1469+
(v[Kind] !== 'File' && v[Kind] !== 'Files')
1470+
)
1471+
continue
13841472

1385-
if (validator.body.schema.properties)
1386-
for (const [k, v] of Object.entries(
1387-
validator.body.schema.properties
1388-
) as [string, TSchema][]) {
1389-
if (
1390-
!v.extension ||
1391-
(v[Kind] !== 'File' && v[Kind] !== 'Files')
1392-
)
1393-
continue
1394-
1395-
if (i) validateFile += ','
1396-
validateFile += `validateFileExtension(c.body.${k},${JSON.stringify(v.extension)},'body.${k}')`
1473+
if (i) validateFile += ','
1474+
validateFile += `validateFileExtension(c.body.${k},${JSON.stringify(v.extension)},'body.${k}')`
13971475

1398-
i++
1399-
}
1476+
i++
1477+
}
14001478

14011479
if (i) fnLiteral += '\n'
14021480

@@ -1932,6 +2010,7 @@ export const composeHandler = ({
19322010
allocateIf(`ELYSIA_REQUEST_ID,`, hasTrace) +
19332011
allocateIf('parser,', hooks.parse?.length) +
19342012
allocateIf(`getServer,`, inference.server) +
2013+
allocateIf(`fileUnions,`, fileUnions.length) +
19352014
adapterVariables +
19362015
allocateIf('TypeBoxError', hasValidation) +
19372016
`}=hooks\n` +
@@ -1983,6 +2062,7 @@ export const composeHandler = ({
19832062
ELYSIA_REQUEST_ID: hasTrace ? ELYSIA_REQUEST_ID : undefined,
19842063
// @ts-expect-error private property
19852064
getServer: () => app.getServer(),
2065+
fileUnions: fileUnions.length ? fileUnions : undefined,
19862066
TypeBoxError: hasValidation ? TypeBoxError : undefined,
19872067
parser: app['~parser'],
19882068
...adapter.inject
@@ -2394,13 +2474,17 @@ export const composeErrorHandler = (app: AnyElysia) => {
23942474
}
23952475

23962476
fnLiteral +=
2397-
`if(error.constructor.name==="ValidationError"||error.constructor.name==="TransformDecodeError"){` +
2477+
`if(error.constructor.name==="ValidationError"||error.constructor.name==="TransformDecodeError"){\n` +
23982478
`if(error.error)error=error.error\n` +
23992479
`set.status=error.status??422\n` +
24002480
adapter.validationError +
2401-
`}`
2481+
`\n}\n`
24022482

2403-
fnLiteral += `if(error instanceof Error){` + adapter.unknownError + `}`
2483+
fnLiteral +=
2484+
`if(error instanceof Error){` +
2485+
`\nif(typeof error.toResponse==='function')return context.response=error.toResponse()\n` +
2486+
adapter.unknownError +
2487+
`\n}`
24042488

24052489
const mapResponseReporter = report('mapResponse', {
24062490
total: hooks.mapResponse?.length,

src/error.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,51 @@ export const mapValueError = (error: ValueError | undefined) => {
183183

184184
export class InvalidFileType extends Error {
185185
code = 'INVALID_FILE_TYPE'
186-
status = 401
186+
status = 422
187187

188188
constructor(
189-
public key: string,
190-
message?: string
189+
public property: string,
190+
public expected: string | string[],
191+
public message = `"${property}" has invalid file type`
191192
) {
192-
super(message ?? `"${key}" has invalid file type`)
193+
super(message)
194+
195+
Object.setPrototypeOf(this, InvalidFileType.prototype)
196+
}
197+
198+
toResponse(headers?: Record<string, any>) {
199+
if (isProduction)
200+
return new Response(
201+
JSON.stringify({
202+
type: 'validation',
203+
on: 'body'
204+
}),
205+
{
206+
status: 422,
207+
headers: {
208+
...headers,
209+
'content-type': 'application/json'
210+
}
211+
}
212+
)
213+
214+
return new Response(
215+
JSON.stringify({
216+
type: 'validation',
217+
on: 'body',
218+
summary: 'Invalid file type',
219+
message: this.message,
220+
property: this.property,
221+
expected: this.expected
222+
}),
223+
{
224+
status: 422,
225+
headers: {
226+
...headers,
227+
'content-type': 'application/json'
228+
}
229+
}
230+
)
193231
}
194232
}
195233

0 commit comments

Comments
 (0)