feat(typebox-validator): Add strict schema (#866)

pull/868/head
Lindelwe Michael Ncube 2024-12-03 17:14:44 +10:00 committed by GitHub
parent 21403ec5c0
commit c815055bb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 193 additions and 27 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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',
}],
)
})
})