diff --git a/src/type-system/index.ts b/src/type-system/index.ts index a5a3a082..01e04946 100644 --- a/src/type-system/index.ts +++ b/src/type-system/index.ts @@ -1,14 +1,18 @@ -import { Type, Kind } from '@sinclair/typebox' -import type { +import { ArrayOptions, DateOptions, IntegerOptions, + JavaScriptTypeBuilder, + Kind, + NumberOptions, ObjectOptions, SchemaOptions, + StringOptions, TAnySchema, TArray, TBoolean, TDate, + TEnum, TEnumValue, TInteger, TNumber, @@ -16,13 +20,14 @@ import type { TProperties, TSchema, TString, - NumberOptions, - JavaScriptTypeBuilder, - StringOptions, TUnsafe, - Uint8ArrayOptions, - TEnum + Type, + Uint8ArrayOptions } from '@sinclair/typebox' +import { + DefaultErrorFunction, + SetErrorFunction +} from '@sinclair/typebox/errors' import { compile, @@ -32,22 +37,25 @@ import { validateFile } from './utils' import { + AssertNumericEnum, CookieValidatorOptions, - TFile, - TFiles, + ElysiaTransformDecodeBuilder, FileOptions, FilesOptions, NonEmptyArray, + TArrayBuffer, + TFile, + TFiles, TForm, + TMaybeNull, TUnionEnum, - ElysiaTransformDecodeBuilder, - TArrayBuffer, - AssertNumericEnum + WrappedKind } from './types' import { ELYSIA_FORM_DATA, form } from '../utils' import { ValidationError } from '../error' import { parseDateTimeEmptySpace } from './format' +import { Value } from '@sinclair/typebox/value' const t = Object.assign({}, Type) as unknown as Omit< JavaScriptTypeBuilder, @@ -73,6 +81,31 @@ createType( (schema, value) => value instanceof ArrayBuffer ) +createType( + 'MaybeNull', + ({ [Kind]: kind, [WrappedKind]: wrappedKind, ...schema }, value) => { + return ( + value === null || + Value.Check( + { + [Kind]: wrappedKind, + ...schema + }, + value + ) + ) + } +) + +SetErrorFunction((error) => { + switch (error.schema[Kind]) { + case 'MaybeNull': + return `Expected '${error.schema.type ?? (error.schema as TMaybeNull)[WrappedKind]}' or 'null'` + default: + return DefaultErrorFunction(error) + } +}) + const internalFiles = createType( 'Files', (options, value) => { @@ -153,7 +186,10 @@ export const ElysiaType = { .Encode((value) => value) as any as TNumber }, - NumericEnum>(item: T, property?: SchemaOptions) { + NumericEnum>( + item: T, + property?: SchemaOptions + ) { const schema = Type.Enum(item, property) const compiler = compile(schema) @@ -492,12 +528,25 @@ export const ElysiaType = { }) .Encode((value) => value) as unknown as TUnsafe, + /** + * @deprecated Use MaybeNull instead which is OpenAPI 3.0 compliant. Will be removed in the next major release + */ Nullable: (schema: T, options?: SchemaOptions) => t.Union([schema, t.Null()], { ...options, nullable: true }), + MaybeNull: ({ [Kind]: kind, ...schema }: T) => { + return { + [Kind]: 'MaybeNull', + [WrappedKind]: kind, + default: undefined, + ...schema, + nullable: true + } as unknown as TMaybeNull + }, + /** * Allow Optional, Nullable and Undefined */ @@ -660,6 +709,7 @@ t.Files = (arg) => { } t.Nullable = ElysiaType.Nullable +t.MaybeNull = ElysiaType.MaybeNull t.MaybeEmpty = ElysiaType.MaybeEmpty t.Cookie = ElysiaType.Cookie t.Date = ElysiaType.Date diff --git a/src/type-system/types.ts b/src/type-system/types.ts index 5b913c4a..03da4366 100644 --- a/src/type-system/types.ts +++ b/src/type-system/types.ts @@ -140,6 +140,13 @@ export interface TUnionEnum< enum: T } +export const WrappedKind = Symbol('WrappedKind'); + +export interface TMaybeNull extends TSchema { + [Kind]: 'MaybeNull', + [WrappedKind]: string, +} + export interface TArrayBuffer extends Uint8ArrayOptions {} export type TForm = TUnsafe< diff --git a/test/type-system/maybe-null.test.ts b/test/type-system/maybe-null.test.ts new file mode 100644 index 00000000..0adb3218 --- /dev/null +++ b/test/type-system/maybe-null.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'bun:test' +import Elysia, { t } from '../../src' +import { post } from '../utils' + +describe('TypeSystem - MaybeNull', () => { + it('OpenAPI compliant', () => { + const schema = t.MaybeNull(t.String()) + + expect(schema).toMatchObject({ + type: 'string', + nullable: true + }) + + const objSchema = t.Object({ + name: t.MaybeNull(t.String()) + }) + + expect(objSchema).toMatchObject({ + type: 'object', + properties: { + name: { + type: 'string', + nullable: true + } + } + }) + + const schema1 = t.MaybeNull( + t.Object({ + a: t.String(), + b: t.Number() + }) + ) + + expect(schema1).toMatchObject({ + type: 'object', + properties: { + a: { + type: 'string' + }, + b: { + type: 'number' + } + }, + nullable: true + }) + }) + + it('Validates primitive values', async () => { + const app = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull(t.String()) + }) + }) + + const res1 = await app.handle( + post('/', { + name: '123' + }) + ) + expect(res1.status).toBe(200) + expect(await res1.json()).toEqual({ name: '123' }) + + const res2 = await app.handle( + post('/', { + name: null + }) + ) + + expect(res2.status).toBe(200) + expect(await res2.json()).toEqual({ name: null }) + + const res3 = await app.handle(post('/', {})) + expect(res3.status).toBe(422) + + const res4 = await app.handle( + post('/', { + name: 123 + }) + ) + + expect(res4.status).toBe(422) + + const res5 = await app.handle( + post('/', { + name: '' + }) + ) + expect(res5.status).toBe(200) + expect(await res5.json()).toEqual({ name: '' }) + + const app1 = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull(t.Number()) + }) + }) + + const res6 = await app1.handle( + post('/', { + name: '123' + }) + ) + + expect(res6.status).toBe(422) + + const res7 = await app1.handle( + post('/', { + name: 123 + }) + ) + + expect(res7.status).toBe(200) + expect(await res7.json()).toEqual({ name: 123 }) + }) + + it('Validates objects', async () => { + const appWithArray = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.Object({ + value: t.MaybeNull(t.Array(t.Number())) + }) + }) + }) + + const res1 = await appWithArray.handle( + post('/', { + name: { + value: [1, 2, 3] + } + }) + ) + + expect(res1.status).toBe(200) + expect(await res1.json()).toEqual({ name: { value: [1, 2, 3] } }) + + const res2 = await appWithArray.handle( + post('/', { + name: { + value: 'failable' + } + }) + ) + + expect(res2.status).toBe(422) + + const res3 = await appWithArray.handle( + post('/', { + name: { + value: ['1', '2', '3'] + } + }) + ) + + expect(res3.status).toBe(422) + + const appWithObj = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull( + t.Object({ + a: t.String(), + b: t.Number(), + c: t.Boolean() + }) + ) + }) + }) + + const res4 = await appWithObj.handle( + post('/', { + name: { + a: '1', + b: 2, + c: true + } + }) + ) + + expect(res4.status).toBe(200) + expect(await res4.json()).toEqual({ name: { a: '1', b: 2, c: true } }) + + const res5 = await appWithObj.handle( + post('/', { + name: { + a: '1', + b: '2', + c: true + } + }) + ) + + expect(res5.status).toBe(422) + + const res6 = await appWithObj.handle( + post('/', { + name: 'abc' + }) + ) + + expect(res6.status).toBe(422) + + const res7 = await appWithObj.handle( + post('/', { + name: null + }) + ) + + expect(res7.status).toBe(200) + expect(await res7.json()).toEqual({ name: null }) + + const res8 = await appWithObj.handle( + post('/', {}) + ) + + expect(res8.status).toBe(422) + }) + + it("Validates with default", async () => { + const appWithDefault = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull(t.Number({ + default: 1 + })) + }) + }); + + const res = await appWithDefault.handle(post('/', {})); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ name: 1 }); + }); + + it("Validates with correct error on complex types", async () => { + const appWithDefault = new Elysia().post('/', ({ body }) => body, { + body: t.Object({ + name: t.MaybeNull(t.ArrayBuffer()) + }) + }); + + const res = await appWithDefault.handle(post('/', { + name: 1 + })); + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ + message: "Expected 'ArrayBuffer' or 'null'" + }) + }); +})