Skip to content

Commit c815055

Browse files
authored
feat(typebox-validator): Add strict schema (#866)
1 parent 21403ec commit c815055

File tree

3 files changed

+193
-27
lines changed

3 files changed

+193
-27
lines changed

.changeset/giant-eyes-perform.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hono/typebox-validator': minor
3+
---
4+
5+
Added ability to remove properties that are not in the schema to emulate other validators like zod

packages/typebox-validator/src/index.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import type { TSchema, Static } from '@sinclair/typebox'
1+
import { TSchema, Static, TypeGuard, ValueGuard } from '@sinclair/typebox'
22
import { Value, type ValueError } from '@sinclair/typebox/value'
33
import type { Context, Env, MiddlewareHandler, ValidationTargets } from 'hono'
44
import { validator } from 'hono/validator'
5+
import IsObject = ValueGuard.IsObject
6+
import IsArray = ValueGuard.IsArray
57

68
export type Hook<T, E extends Env, P extends string> = (
79
result: { success: true; data: T } | { success: false; errors: ValueError[] },
8-
c: Context<E, P>
10+
c: Context<E, P>,
911
) => Response | Promise<Response> | void
1012

1113
/**
@@ -59,10 +61,12 @@ export function tbValidator<
5961
E extends Env,
6062
P extends string,
6163
V extends { in: { [K in Target]: Static<T> }; out: { [K in Target]: Static<T> } }
62-
>(target: Target, schema: T, hook?: Hook<Static<T>, E, P>): MiddlewareHandler<E, P, V> {
64+
>(target: Target, schema: T, hook?: Hook<Static<T>, E, P>, stripNonSchemaItems?: boolean): MiddlewareHandler<E, P, V> {
6365
// Compile the provided schema once rather than per validation. This could be optimized further using a shared schema
6466
// compilation pool similar to the Fastify implementation.
65-
return validator(target, (data, c) => {
67+
return validator(target, (unprocessedData, c) => {
68+
const data = stripNonSchemaItems ? removeNonSchemaItems(schema, unprocessedData) : unprocessedData
69+
6670
if (Value.Check(schema, data)) {
6771
if (hook) {
6872
const hookResult = hook({ success: true, data }, c)
@@ -72,15 +76,40 @@ export function tbValidator<
7276
}
7377
return data
7478
}
75-
76-
const errors = Array.from(Value.Errors(schema, data));
77-
if (hook) {
78-
const hookResult = hook({ success: false, errors }, c);
79-
if (hookResult instanceof Response || hookResult instanceof Promise) {
80-
return hookResult;
81-
}
82-
}
8379

84-
return c.json({ success: false, errors }, 400);
80+
const errors = Array.from(Value.Errors(schema, data))
81+
if (hook) {
82+
const hookResult = hook({ success: false, errors }, c)
83+
if (hookResult instanceof Response || hookResult instanceof Promise) {
84+
return hookResult
85+
}
86+
}
87+
88+
return c.json({ success: false, errors }, 400)
8589
})
8690
}
91+
92+
function removeNonSchemaItems<T extends TSchema>(schema: T, obj: any): Static<T> {
93+
if (typeof obj !== 'object' || obj === null) return obj
94+
95+
if (Array.isArray(obj)) {
96+
return obj.map((item) => removeNonSchemaItems(schema.items, item))
97+
}
98+
99+
const result: any = {}
100+
for (const key in schema.properties) {
101+
if (obj.hasOwnProperty(key)) {
102+
const propertySchema = schema.properties[key]
103+
if (
104+
IsObject(propertySchema) &&
105+
!IsArray(propertySchema)
106+
) {
107+
result[key] = removeNonSchemaItems(propertySchema as unknown as TSchema, obj[key])
108+
} else {
109+
result[key] = obj[key]
110+
}
111+
}
112+
}
113+
114+
return result
115+
}

packages/typebox-validator/test/index.test.ts

Lines changed: 146 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { Type as T, TypeBoxError } from '@sinclair/typebox';
1+
import { Type as T } from '@sinclair/typebox'
22
import { Hono } from 'hono'
33
import type { Equal, Expect } from 'hono/utils/types'
44
import { tbValidator } from '../src'
5-
import { ValueError } from '@sinclair/typebox/value';
5+
import { ValueError } from '@sinclair/typebox/value'
66

77
// eslint-disable-next-line @typescript-eslint/no-unused-vars
88
type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never
@@ -106,7 +106,7 @@ describe('With Hook', () => {
106106
success: true,
107107
message: `${data.id} is ${data.title}`,
108108
})
109-
}
109+
},
110110
).post(
111111
'/errorTest',
112112
tbValidator('json', schema, (result, c) => {
@@ -118,7 +118,7 @@ describe('With Hook', () => {
118118
success: true,
119119
message: `${data.id} is ${data.title}`,
120120
})
121-
}
121+
},
122122
)
123123

124124
it('Should return 200 response', async () => {
@@ -168,24 +168,156 @@ describe('With Hook', () => {
168168
expect(res).not.toBeNull()
169169
expect(res.status).toBe(400)
170170

171-
const {errors, success} = (await res.json()) as { success: boolean; errors: any[] }
171+
const { errors, success } = (await res.json()) as { success: boolean; errors: any[] }
172172
expect(success).toBe(false)
173173
expect(Array.isArray(errors)).toBe(true)
174174
expect(errors.map((e: ValueError) => ({
175175
'type': e?.schema?.type,
176-
path: e?.path,
177-
message: e?.message
176+
path: e?.path,
177+
message: e?.message,
178178
}))).toEqual([
179179
{
180-
"type": "string",
181-
"path": "/title",
182-
"message": "Required property"
180+
'type': 'string',
181+
'path': '/title',
182+
'message': 'Required property',
183183
},
184184
{
185-
"type": "string",
186-
"path": "/title",
187-
"message": "Expected string"
188-
}
185+
'type': 'string',
186+
'path': '/title',
187+
'message': 'Expected string',
188+
},
189189
])
190190
})
191191
})
192+
193+
describe('Remove non schema items', () => {
194+
const app = new Hono()
195+
const schema = T.Object({
196+
id: T.Number(),
197+
title: T.String(),
198+
})
199+
200+
const nestedSchema = T.Object({
201+
id: T.Number(),
202+
itemArray: T.Array(schema),
203+
item: schema,
204+
itemObject: T.Object({
205+
item1: schema,
206+
item2: schema,
207+
}),
208+
})
209+
210+
app.post(
211+
'/stripValuesNested',
212+
tbValidator('json', nestedSchema, undefined, true),
213+
(c) => {
214+
return c.json({
215+
success: true,
216+
message: c.req.valid('json'),
217+
})
218+
},
219+
).post(
220+
'/stripValuesArray',
221+
tbValidator('json', T.Array(schema), undefined, true),
222+
(c) => {
223+
return c.json({
224+
success: true,
225+
message: c.req.valid('json'),
226+
})
227+
},
228+
)
229+
230+
it('Should remove all the values in the nested object and return a 200 response', async () => {
231+
const req = new Request('http://localhost/stripValuesNested', {
232+
body: JSON.stringify({
233+
id: 123,
234+
nonExistentKey: 'error',
235+
itemArray: [
236+
{
237+
id: 123,
238+
title: 'Hello',
239+
nonExistentKey: 'error',
240+
},
241+
{
242+
id: 123,
243+
title: 'Hello',
244+
nonExistentKey: 'error',
245+
nonExistentKey2: 'error 2',
246+
},
247+
],
248+
item: {
249+
id: 123,
250+
title: 'Hello',
251+
nonExistentKey: 'error',
252+
},
253+
itemObject: {
254+
item1: {
255+
id: 123,
256+
title: 'Hello',
257+
imaginaryKey: 'error',
258+
},
259+
item2: {
260+
id: 123,
261+
title: 'Hello',
262+
error: 'error',
263+
},
264+
},
265+
}),
266+
method: 'POST',
267+
headers: {
268+
'Content-Type': 'application/json',
269+
},
270+
})
271+
const res = await app.request(req)
272+
expect(res).not.toBeNull()
273+
expect(res.status).toBe(200)
274+
275+
const { message, success } = (await res.json()) as { success: boolean; message: any }
276+
expect(success).toBe(true)
277+
expect(message).toEqual(
278+
{
279+
'id': 123,
280+
'itemArray': [{ 'id': 123, 'title': 'Hello' }, {
281+
'id': 123,
282+
'title': 'Hello',
283+
}],
284+
'item': { 'id': 123, 'title': 'Hello' },
285+
'itemObject': {
286+
'item1': { 'id': 123, 'title': 'Hello' },
287+
'item2': { 'id': 123, 'title': 'Hello' },
288+
},
289+
},
290+
)
291+
})
292+
293+
it('Should remove all the values in the array and return a 200 response', async () => {
294+
const req = new Request('http://localhost/stripValuesArray', {
295+
body: JSON.stringify([
296+
{
297+
id: 123,
298+
title: 'Hello',
299+
nonExistentKey: 'error',
300+
},
301+
{
302+
id: 123,
303+
title: 'Hello 2',
304+
nonExistentKey: 'error',
305+
},
306+
]), method: 'POST',
307+
headers: {
308+
'Content-Type': 'application/json',
309+
},
310+
})
311+
312+
const res = await app.request(req)
313+
const { message, success } = (await res.json()) as { success: boolean; message: Array<any> }
314+
expect(res.status).toBe(200)
315+
expect(success).toBe(true)
316+
expect(message).toEqual([{ 'id': 123, 'title': 'Hello' }, {
317+
'id': 123,
318+
'title': 'Hello 2',
319+
}],
320+
)
321+
})
322+
})
323+

0 commit comments

Comments
 (0)