Skip to content

Commit 6387023

Browse files
committed
Add runtime validation and more tests
1 parent 77b2713 commit 6387023

File tree

3 files changed

+239
-31
lines changed

3 files changed

+239
-31
lines changed

src/type-system/index.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
1-
import { Type, Kind, Static } from '@sinclair/typebox'
21
import type {
32
ArrayOptions,
43
DateOptions,
54
IntegerOptions,
5+
JavaScriptTypeBuilder,
6+
NumberOptions,
67
ObjectOptions,
78
SchemaOptions,
9+
StringOptions,
810
TAnySchema,
911
TArray,
1012
TBoolean,
1113
TDate,
14+
TEnum,
1215
TEnumValue,
1316
TInteger,
1417
TNumber,
1518
TObject,
1619
TProperties,
1720
TSchema,
1821
TString,
19-
NumberOptions,
20-
JavaScriptTypeBuilder,
21-
StringOptions,
2222
TUnsafe,
23-
Uint8ArrayOptions,
24-
TEnum
23+
Uint8ArrayOptions
2524
} from '@sinclair/typebox'
25+
import { Kind, Type } from '@sinclair/typebox'
26+
import {
27+
DefaultErrorFunction,
28+
SetErrorFunction
29+
} from '@sinclair/typebox/errors'
2630

2731
import {
2832
compile,
@@ -32,22 +36,25 @@ import {
3236
validateFile
3337
} from './utils'
3438
import {
39+
AssertNumericEnum,
3540
CookieValidatorOptions,
36-
TFile,
37-
TFiles,
41+
ElysiaTransformDecodeBuilder,
3842
FileOptions,
3943
FilesOptions,
4044
NonEmptyArray,
45+
TArrayBuffer,
46+
TFile,
47+
TFiles,
4148
TForm,
49+
TMaybeNull,
4250
TUnionEnum,
43-
ElysiaTransformDecodeBuilder,
44-
TArrayBuffer,
45-
AssertNumericEnum
51+
WrappedKind
4652
} from './types'
4753

4854
import { ELYSIA_FORM_DATA, form } from '../utils'
4955
import { ValidationError } from '../error'
5056
import { parseDateTimeEmptySpace } from './format'
57+
import { Value } from '@sinclair/typebox/value'
5158

5259
const t = Object.assign({}, Type) as unknown as Omit<
5360
JavaScriptTypeBuilder,
@@ -73,6 +80,30 @@ createType<TArrayBuffer>(
7380
(schema, value) => value instanceof ArrayBuffer
7481
)
7582

83+
createType<TMaybeNull>(
84+
'MaybeNull',
85+
({ [Kind]: kind, [WrappedKind]: wrappedKind, ...schema }, value) => {
86+
return (
87+
Value.Check(
88+
{
89+
[Kind]: wrappedKind,
90+
...schema
91+
},
92+
value
93+
) || value === null
94+
)
95+
}
96+
)
97+
98+
SetErrorFunction((error) => {
99+
switch (error.schema[Kind]) {
100+
case 'MaybeNull':
101+
return `Expected either ${error.schema.type} or null`
102+
default:
103+
return DefaultErrorFunction(error)
104+
}
105+
})
106+
76107
const internalFiles = createType<FilesOptions, File[]>(
77108
'Files',
78109
(options, value) => {
@@ -153,7 +184,10 @@ export const ElysiaType = {
153184
.Encode((value) => value) as any as TNumber
154185
},
155186

156-
NumericEnum<T extends AssertNumericEnum<T>>(item: T, property?: SchemaOptions) {
187+
NumericEnum<T extends AssertNumericEnum<T>>(
188+
item: T,
189+
property?: SchemaOptions
190+
) {
157191
const schema = Type.Enum(item, property)
158192
const compiler = compile(schema)
159193

@@ -501,11 +535,15 @@ export const ElysiaType = {
501535
nullable: true
502536
}),
503537

504-
MaybeNull: <T extends TSchema>(schema: T): TUnsafe<Static<T> | null> =>
505-
Type.Unsafe({
538+
MaybeNull: <T extends TSchema>({ [Kind]: kind, ...schema }: T): TSchema => {
539+
return {
540+
[Kind]: 'MaybeNull',
541+
[WrappedKind]: kind,
506542
...schema,
507-
nullable: true,
508-
}),
543+
default: undefined,
544+
nullable: true
545+
} as unknown as TMaybeNull
546+
},
509547

510548
/**
511549
* Allow Optional, Nullable and Undefined

src/type-system/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ export interface TUnionEnum<
140140
enum: T
141141
}
142142

143+
export const WrappedKind = Symbol('WrappedKind');
144+
145+
export interface TMaybeNull extends TSchema {
146+
[Kind]: 'MaybeNull',
147+
[WrappedKind]: string,
148+
}
149+
143150
export interface TArrayBuffer extends Uint8ArrayOptions {}
144151

145152
export type TForm<T extends TProperties = TProperties> = TUnsafe<

test/type-system/maybe-null.test.ts

Lines changed: 178 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,52 @@ import { post } from '../utils'
44

55
describe('TypeSystem - MaybeNull', () => {
66
it('OpenAPI compliant', () => {
7-
const schema = t.MaybeNull(t.String());
7+
const schema = t.MaybeNull(t.String())
88

99
expect(schema).toMatchObject({
10-
type: "string",
10+
type: 'string',
1111
nullable: true
12-
});
12+
})
1313

1414
const objSchema = t.Object({
1515
name: t.MaybeNull(t.String())
16-
});
16+
})
1717

1818
expect(objSchema).toMatchObject({
19-
type: "object",
19+
type: 'object',
2020
properties: {
2121
name: {
22-
type: "string",
22+
type: 'string',
2323
nullable: true
2424
}
25+
}
26+
})
27+
28+
const schema1 = t.MaybeNull(
29+
t.Object({
30+
a: t.String(),
31+
b: t.Number()
32+
})
33+
)
34+
35+
expect(schema1).toMatchObject({
36+
type: 'object',
37+
properties: {
38+
a: {
39+
type: 'string'
40+
},
41+
b: {
42+
type: 'number'
43+
}
2544
},
26-
});
27-
});
45+
nullable: true
46+
})
47+
})
2848

29-
it('Validate', async () => {
49+
it('Validates primitive values', async () => {
3050
const app = new Elysia().post('/', ({ body }) => body, {
3151
body: t.Object({
32-
name: t.Nullable(t.String())
52+
name: t.MaybeNull(t.String())
3353
})
3454
})
3555

@@ -41,13 +61,156 @@ describe('TypeSystem - MaybeNull', () => {
4161
expect(res1.status).toBe(200)
4262
expect(await res1.json()).toEqual({ name: '123' })
4363

44-
const res2 = await app.handle(post('/', {
45-
name: null
46-
}))
64+
const res2 = await app.handle(
65+
post('/', {
66+
name: null
67+
})
68+
)
69+
4770
expect(res2.status).toBe(200)
4871
expect(await res2.json()).toEqual({ name: null })
4972

5073
const res3 = await app.handle(post('/', {}))
5174
expect(res3.status).toBe(422)
52-
});
53-
})
75+
76+
const res4 = await app.handle(
77+
post('/', {
78+
name: 123
79+
})
80+
)
81+
82+
expect(res4.status).toBe(422)
83+
84+
const res5 = await app.handle(
85+
post('/', {
86+
name: ''
87+
})
88+
)
89+
expect(res5.status).toBe(200)
90+
expect(await res5.json()).toEqual({ name: '' })
91+
92+
const app1 = new Elysia().post('/', ({ body }) => body, {
93+
body: t.Object({
94+
name: t.MaybeNull(t.Number())
95+
})
96+
})
97+
98+
const res6 = await app1.handle(
99+
post('/', {
100+
name: '123'
101+
})
102+
)
103+
104+
expect(res6.status).toBe(422)
105+
106+
const res7 = await app1.handle(
107+
post('/', {
108+
name: 123
109+
})
110+
)
111+
112+
expect(res7.status).toBe(200)
113+
expect(await res7.json()).toEqual({ name: 123 })
114+
})
115+
116+
it('Validates objects', async () => {
117+
const appWithArray = new Elysia().post('/', ({ body }) => body, {
118+
body: t.Object({
119+
name: t.Object({
120+
value: t.MaybeNull(t.Array(t.Number()))
121+
})
122+
})
123+
})
124+
125+
const res1 = await appWithArray.handle(
126+
post('/', {
127+
name: {
128+
value: [1, 2, 3]
129+
}
130+
})
131+
)
132+
133+
expect(res1.status).toBe(200)
134+
expect(await res1.json()).toEqual({ name: { value: [1, 2, 3] } })
135+
136+
const res2 = await appWithArray.handle(
137+
post('/', {
138+
name: {
139+
value: 'failable'
140+
}
141+
})
142+
)
143+
144+
expect(res2.status).toBe(422)
145+
146+
const res3 = await appWithArray.handle(
147+
post('/', {
148+
name: {
149+
value: ['1', '2', '3']
150+
}
151+
})
152+
)
153+
154+
expect(res3.status).toBe(422)
155+
156+
const appWithObj = new Elysia().post('/', ({ body }) => body, {
157+
body: t.Object({
158+
name: t.MaybeNull(
159+
t.Object({
160+
a: t.String(),
161+
b: t.Number(),
162+
c: t.Boolean()
163+
})
164+
)
165+
})
166+
})
167+
168+
const res4 = await appWithObj.handle(
169+
post('/', {
170+
name: {
171+
a: '1',
172+
b: 2,
173+
c: true
174+
}
175+
})
176+
)
177+
178+
expect(res4.status).toBe(200)
179+
expect(await res4.json()).toEqual({ name: { a: '1', b: 2, c: true } })
180+
181+
const res5 = await appWithObj.handle(
182+
post('/', {
183+
name: {
184+
a: '1',
185+
b: '2',
186+
c: true
187+
}
188+
})
189+
)
190+
191+
expect(res5.status).toBe(422)
192+
193+
const res6 = await appWithObj.handle(
194+
post('/', {
195+
name: 'abc'
196+
})
197+
)
198+
199+
expect(res6.status).toBe(422)
200+
201+
const res7 = await appWithObj.handle(
202+
post('/', {
203+
name: null
204+
})
205+
)
206+
207+
expect(res7.status).toBe(200)
208+
expect(await res7.json()).toEqual({ name: null })
209+
210+
const res8 = await appWithObj.handle(
211+
post('/', {})
212+
)
213+
214+
expect(res8.status).toBe(422)
215+
})
216+
})

0 commit comments

Comments
 (0)