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 { 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
|
||||||
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue