From c815055bb6669aff8fccf5f478983906c65aca9d Mon Sep 17 00:00:00 2001 From: Lindelwe Michael Ncube Date: Tue, 3 Dec 2024 17:14:44 +1000 Subject: [PATCH] feat(typebox-validator): Add strict schema (#866) --- .changeset/giant-eyes-perform.md | 5 + packages/typebox-validator/src/index.ts | 55 ++++-- packages/typebox-validator/test/index.test.ts | 160 ++++++++++++++++-- 3 files changed, 193 insertions(+), 27 deletions(-) create mode 100644 .changeset/giant-eyes-perform.md diff --git a/.changeset/giant-eyes-perform.md b/.changeset/giant-eyes-perform.md new file mode 100644 index 00000000..b682ebf3 --- /dev/null +++ b/.changeset/giant-eyes-perform.md @@ -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 diff --git a/packages/typebox-validator/src/index.ts b/packages/typebox-validator/src/index.ts index 3b38f5d8..7b5aa8c0 100644 --- a/packages/typebox-validator/src/index.ts +++ b/packages/typebox-validator/src/index.ts @@ -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 = ( result: { success: true; data: T } | { success: false; errors: ValueError[] }, - c: Context + c: Context, ) => Response | Promise | void /** @@ -59,10 +61,12 @@ export function tbValidator< E extends Env, P extends string, V extends { in: { [K in Target]: Static }; out: { [K in Target]: Static } } ->(target: Target, schema: T, hook?: Hook, E, P>): MiddlewareHandler { +>(target: Target, schema: T, hook?: Hook, E, P>, stripNonSchemaItems?: boolean): MiddlewareHandler { // 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) @@ -72,15 +76,40 @@ export function tbValidator< } return data } - - const errors = Array.from(Value.Errors(schema, data)); - if (hook) { - const hookResult = hook({ success: false, errors }, c); - if (hookResult instanceof Response || hookResult instanceof Promise) { - return hookResult; - } - } - return c.json({ success: false, errors }, 400); + const errors = Array.from(Value.Errors(schema, data)) + if (hook) { + const hookResult = hook({ success: false, errors }, c) + if (hookResult instanceof Response || hookResult instanceof Promise) { + return hookResult + } + } + + return c.json({ success: false, errors }, 400) }) } + +function removeNonSchemaItems(schema: T, obj: any): Static { + 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 +} diff --git a/packages/typebox-validator/test/index.test.ts b/packages/typebox-validator/test/index.test.ts index 1d808477..4197e0cd 100644 --- a/packages/typebox-validator/test/index.test.ts +++ b/packages/typebox-validator/test/index.test.ts @@ -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 extends Hono ? 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 () => { @@ -168,24 +168,156 @@ describe('With Hook', () => { expect(res).not.toBeNull() 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(Array.isArray(errors)).toBe(true) expect(errors.map((e: ValueError) => ({ 'type': e?.schema?.type, - path: e?.path, - message: e?.message + path: e?.path, + 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 } + expect(res.status).toBe(200) + expect(success).toBe(true) + expect(message).toEqual([{ 'id': 123, 'title': 'Hello' }, { + 'id': 123, + 'title': 'Hello 2', + }], + ) + }) +}) +