feat(typebox-validator): Add strict schema (#866)
parent
21403ec5c0
commit
c815055bb6
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/typebox-validator': minor
|
||||
---
|
||||
|
||||
Added ability to remove properties that are not in the schema to emulate other validators like zod
|
|
@ -1,11 +1,13 @@
|
|||
import type { TSchema, Static } from '@sinclair/typebox'
|
||||
import { TSchema, Static, TypeGuard, ValueGuard } from '@sinclair/typebox'
|
||||
import { Value, type ValueError } from '@sinclair/typebox/value'
|
||||
import type { Context, Env, MiddlewareHandler, ValidationTargets } from 'hono'
|
||||
import { validator } from 'hono/validator'
|
||||
import IsObject = ValueGuard.IsObject
|
||||
import IsArray = ValueGuard.IsArray
|
||||
|
||||
export type Hook<T, E extends Env, P extends string> = (
|
||||
result: { success: true; data: T } | { success: false; errors: ValueError[] },
|
||||
c: Context<E, P>
|
||||
c: Context<E, P>,
|
||||
) => Response | Promise<Response> | void
|
||||
|
||||
/**
|
||||
|
@ -59,10 +61,12 @@ export function tbValidator<
|
|||
E extends Env,
|
||||
P extends string,
|
||||
V extends { in: { [K in Target]: Static<T> }; out: { [K in Target]: Static<T> } }
|
||||
>(target: Target, schema: T, hook?: Hook<Static<T>, E, P>): MiddlewareHandler<E, P, V> {
|
||||
>(target: Target, schema: T, hook?: Hook<Static<T>, E, P>, stripNonSchemaItems?: boolean): MiddlewareHandler<E, P, V> {
|
||||
// Compile the provided schema once rather than per validation. This could be optimized further using a shared schema
|
||||
// compilation pool similar to the Fastify implementation.
|
||||
return validator(target, (data, c) => {
|
||||
return validator(target, (unprocessedData, c) => {
|
||||
const data = stripNonSchemaItems ? removeNonSchemaItems(schema, unprocessedData) : unprocessedData
|
||||
|
||||
if (Value.Check(schema, data)) {
|
||||
if (hook) {
|
||||
const hookResult = hook({ success: true, data }, c)
|
||||
|
@ -73,14 +77,39 @@ export function tbValidator<
|
|||
return data
|
||||
}
|
||||
|
||||
const errors = Array.from(Value.Errors(schema, data));
|
||||
const errors = Array.from(Value.Errors(schema, data))
|
||||
if (hook) {
|
||||
const hookResult = hook({ success: false, errors }, c);
|
||||
const hookResult = hook({ success: false, errors }, c)
|
||||
if (hookResult instanceof Response || hookResult instanceof Promise) {
|
||||
return hookResult;
|
||||
return hookResult
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ success: false, errors }, 400);
|
||||
return c.json({ success: false, errors }, 400)
|
||||
})
|
||||
}
|
||||
|
||||
function removeNonSchemaItems<T extends TSchema>(schema: T, obj: any): Static<T> {
|
||||
if (typeof obj !== 'object' || obj === null) return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => removeNonSchemaItems(schema.items, item))
|
||||
}
|
||||
|
||||
const result: any = {}
|
||||
for (const key in schema.properties) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const propertySchema = schema.properties[key]
|
||||
if (
|
||||
IsObject(propertySchema) &&
|
||||
!IsArray(propertySchema)
|
||||
) {
|
||||
result[key] = removeNonSchemaItems(propertySchema as unknown as TSchema, obj[key])
|
||||
} else {
|
||||
result[key] = obj[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Type as T, TypeBoxError } from '@sinclair/typebox';
|
||||
import { Type as T } from '@sinclair/typebox'
|
||||
import { Hono } from 'hono'
|
||||
import type { Equal, Expect } from 'hono/utils/types'
|
||||
import { tbValidator } from '../src'
|
||||
import { ValueError } from '@sinclair/typebox/value';
|
||||
import { ValueError } from '@sinclair/typebox/value'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never
|
||||
|
@ -106,7 +106,7 @@ describe('With Hook', () => {
|
|||
success: true,
|
||||
message: `${data.id} is ${data.title}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
).post(
|
||||
'/errorTest',
|
||||
tbValidator('json', schema, (result, c) => {
|
||||
|
@ -118,7 +118,7 @@ describe('With Hook', () => {
|
|||
success: true,
|
||||
message: `${data.id} is ${data.title}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('Should return 200 response', async () => {
|
||||
|
@ -174,18 +174,150 @@ describe('With Hook', () => {
|
|||
expect(errors.map((e: ValueError) => ({
|
||||
'type': e?.schema?.type,
|
||||
path: e?.path,
|
||||
message: e?.message
|
||||
message: e?.message,
|
||||
}))).toEqual([
|
||||
{
|
||||
"type": "string",
|
||||
"path": "/title",
|
||||
"message": "Required property"
|
||||
'type': 'string',
|
||||
'path': '/title',
|
||||
'message': 'Required property',
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"path": "/title",
|
||||
"message": "Expected string"
|
||||
}
|
||||
'type': 'string',
|
||||
'path': '/title',
|
||||
'message': 'Expected string',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remove non schema items', () => {
|
||||
const app = new Hono()
|
||||
const schema = T.Object({
|
||||
id: T.Number(),
|
||||
title: T.String(),
|
||||
})
|
||||
|
||||
const nestedSchema = T.Object({
|
||||
id: T.Number(),
|
||||
itemArray: T.Array(schema),
|
||||
item: schema,
|
||||
itemObject: T.Object({
|
||||
item1: schema,
|
||||
item2: schema,
|
||||
}),
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/stripValuesNested',
|
||||
tbValidator('json', nestedSchema, undefined, true),
|
||||
(c) => {
|
||||
return c.json({
|
||||
success: true,
|
||||
message: c.req.valid('json'),
|
||||
})
|
||||
},
|
||||
).post(
|
||||
'/stripValuesArray',
|
||||
tbValidator('json', T.Array(schema), undefined, true),
|
||||
(c) => {
|
||||
return c.json({
|
||||
success: true,
|
||||
message: c.req.valid('json'),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('Should remove all the values in the nested object and return a 200 response', async () => {
|
||||
const req = new Request('http://localhost/stripValuesNested', {
|
||||
body: JSON.stringify({
|
||||
id: 123,
|
||||
nonExistentKey: 'error',
|
||||
itemArray: [
|
||||
{
|
||||
id: 123,
|
||||
title: 'Hello',
|
||||
nonExistentKey: 'error',
|
||||
},
|
||||
{
|
||||
id: 123,
|
||||
title: 'Hello',
|
||||
nonExistentKey: 'error',
|
||||
nonExistentKey2: 'error 2',
|
||||
},
|
||||
],
|
||||
item: {
|
||||
id: 123,
|
||||
title: 'Hello',
|
||||
nonExistentKey: 'error',
|
||||
},
|
||||
itemObject: {
|
||||
item1: {
|
||||
id: 123,
|
||||
title: 'Hello',
|
||||
imaginaryKey: 'error',
|
||||
},
|
||||
item2: {
|
||||
id: 123,
|
||||
title: 'Hello',
|
||||
error: 'error',
|
||||
},
|
||||
},
|
||||
}),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
const res = await app.request(req)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const { message, success } = (await res.json()) as { success: boolean; message: any }
|
||||
expect(success).toBe(true)
|
||||
expect(message).toEqual(
|
||||
{
|
||||
'id': 123,
|
||||
'itemArray': [{ 'id': 123, 'title': 'Hello' }, {
|
||||
'id': 123,
|
||||
'title': 'Hello',
|
||||
}],
|
||||
'item': { 'id': 123, 'title': 'Hello' },
|
||||
'itemObject': {
|
||||
'item1': { 'id': 123, 'title': 'Hello' },
|
||||
'item2': { 'id': 123, 'title': 'Hello' },
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('Should remove all the values in the array and return a 200 response', async () => {
|
||||
const req = new Request('http://localhost/stripValuesArray', {
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 123,
|
||||
title: 'Hello',
|
||||
nonExistentKey: 'error',
|
||||
},
|
||||
{
|
||||
id: 123,
|
||||
title: 'Hello 2',
|
||||
nonExistentKey: 'error',
|
||||
},
|
||||
]), method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await app.request(req)
|
||||
const { message, success } = (await res.json()) as { success: boolean; message: Array<any> }
|
||||
expect(res.status).toBe(200)
|
||||
expect(success).toBe(true)
|
||||
expect(message).toEqual([{ 'id': 123, 'title': 'Hello' }, {
|
||||
'id': 123,
|
||||
'title': 'Hello 2',
|
||||
}],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in New Issue