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 { Value, type ValueError } from '@sinclair/typebox/value'
import type { Context, Env, MiddlewareHandler, ValidationTargets } from 'hono' import type { Context, Env, MiddlewareHandler, ValidationTargets } from 'hono'
import { validator } from 'hono/validator' import { validator } from 'hono/validator'
import IsObject = ValueGuard.IsObject
import IsArray = ValueGuard.IsArray
export type Hook<T, E extends Env, P extends string> = ( export type Hook<T, E extends Env, P extends string> = (
result: { success: true; data: T } | { success: false; errors: ValueError[] }, result: { success: true; data: T } | { success: false; errors: ValueError[] },
c: Context<E, P> c: Context<E, P>,
) => Response | Promise<Response> | void ) => Response | Promise<Response> | void
/** /**
@ -59,10 +61,12 @@ export function tbValidator<
E extends Env, E extends Env,
P extends string, P extends string,
V extends { in: { [K in Target]: Static<T> }; out: { [K in Target]: Static<T> } } 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 // 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. // 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 (Value.Check(schema, data)) {
if (hook) { if (hook) {
const hookResult = hook({ success: true, data }, c) const hookResult = hook({ success: true, data }, c)
@ -73,14 +77,39 @@ export function tbValidator<
return data return data
} }
const errors = Array.from(Value.Errors(schema, data)); const errors = Array.from(Value.Errors(schema, data))
if (hook) { if (hook) {
const hookResult = hook({ success: false, errors }, c); const hookResult = hook({ success: false, errors }, c)
if (hookResult instanceof Response || hookResult instanceof Promise) { 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 { Hono } from 'hono'
import type { Equal, Expect } from 'hono/utils/types' import type { Equal, Expect } from 'hono/utils/types'
import { tbValidator } from '../src' 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 // eslint-disable-next-line @typescript-eslint/no-unused-vars
type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never
@ -106,7 +106,7 @@ describe('With Hook', () => {
success: true, success: true,
message: `${data.id} is ${data.title}`, message: `${data.id} is ${data.title}`,
}) })
} },
).post( ).post(
'/errorTest', '/errorTest',
tbValidator('json', schema, (result, c) => { tbValidator('json', schema, (result, c) => {
@ -118,7 +118,7 @@ describe('With Hook', () => {
success: true, success: true,
message: `${data.id} is ${data.title}`, message: `${data.id} is ${data.title}`,
}) })
} },
) )
it('Should return 200 response', async () => { it('Should return 200 response', async () => {
@ -168,24 +168,156 @@ describe('With Hook', () => {
expect(res).not.toBeNull() expect(res).not.toBeNull()
expect(res.status).toBe(400) expect(res.status).toBe(400)
const {errors, success} = (await res.json()) as { success: boolean; errors: any[] } const { errors, success } = (await res.json()) as { success: boolean; errors: any[] }
expect(success).toBe(false) expect(success).toBe(false)
expect(Array.isArray(errors)).toBe(true) expect(Array.isArray(errors)).toBe(true)
expect(errors.map((e: ValueError) => ({ expect(errors.map((e: ValueError) => ({
'type': e?.schema?.type, 'type': e?.schema?.type,
path: e?.path, path: e?.path,
message: e?.message message: e?.message,
}))).toEqual([ }))).toEqual([
{ {
"type": "string", 'type': 'string',
"path": "/title", 'path': '/title',
"message": "Required property" 'message': 'Required property',
}, },
{ {
"type": "string", 'type': 'string',
"path": "/title", 'path': '/title',
"message": "Expected string" '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',
}],
)
})
})