From 57b24c1cf531844607fd0e46f7b6b7a920f117f6 Mon Sep 17 00:00:00 2001 From: saltyaom Date: Fri, 27 Dec 2024 17:14:21 +0700 Subject: [PATCH] :wrench: fix: unable to reference schema --- CHANGELOG.md | 1 + example/a.ts | 41 ++++----- src/adapter/bun/index.ts | 4 + src/compose.ts | 45 +++++++--- src/index.ts | 46 ++++++++-- src/utils.ts | 35 +++++++- test/extends/models.test.ts | 170 +++++++++++++++++++++++++++++++++++- 7 files changed, 296 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f35505..b03c02b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 1.2.7 - 27 Dec 2024 Bug fix: - macro doesn't work with guard +- [#981](https://github.com/elysiajs/elysia/issues/981) unable to deference schema, create default, and coerce value # 1.2.6 - 25 Dec 2024 Bug fix: diff --git a/example/a.ts b/example/a.ts index ec59ff24..1e872c4f 100644 --- a/example/a.ts +++ b/example/a.ts @@ -1,29 +1,24 @@ -import { Elysia } from '../src' -import { req } from '../test/utils' +import { Elysia, t } from '../src' +import { post, req } from '../test/utils' -const plugin = new Elysia() - .macro({ - account: (a: boolean) => ({ - resolve: ({ error }) => ({ - account: 'A' - }) - }) +const app = new Elysia() + .onError(({ error }) => { + console.log({ error }) }) - .guard({ - account: true + .model({ + session: t.Cookie({ token: t.Number() }), + optionalSession: t.Optional(t.Ref('session')) }) - .get('/local', ({ account }) => { - console.log(account) + .get('/', () => 'Hello Elysia', { + cookie: 'optionalSession' }) -const parent = new Elysia().use(plugin).get('/plugin', (context) => { - console.log(context.account) -}) - -const app = new Elysia().use(parent).get('/global', (context) => { - console.log(context.account) -}) - -await Promise.all( - ['/local', '/plugin', '/global'].map((path) => app.handle(req(path))) +const correct = await app.handle( + new Request('http://localhost/', { + headers: { + cookie: 'token=1' + } + }) ) + +console.log(correct) diff --git a/src/adapter/bun/index.ts b/src/adapter/bun/index.ts index 4780a563..60e22394 100644 --- a/src/adapter/bun/index.ts +++ b/src/adapter/bun/index.ts @@ -119,12 +119,16 @@ export const BunAdapter: ElysiaAdapter = { const { parse, body, response, ...rest } = options const validateMessage = getSchemaValidator(body, { + // @ts-expect-error private property + modules: app.definitions.typebox, // @ts-expect-error private property models: app.definitions.type as Record, normalize: app.config.normalize }) const validateResponse = getSchemaValidator(response as any, { + // @ts-expect-error private property + modules: app.definitions.typebox, // @ts-expect-error private property models: app.definitions.type as Record, normalize: app.config.normalize diff --git a/src/compose.ts b/src/compose.ts index 94ef8819..c84393f5 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -53,6 +53,9 @@ const isOptional = (validator?: TypeCheck) => { // @ts-expect-error const schema = validator?.schema + if (schema?.[TypeBoxSymbol.kind] === 'Import') + return validator.References().some(isOptional as any) + return !!schema && TypeBoxSymbol.optional in schema } @@ -63,9 +66,13 @@ const defaultParsers = [ 'arrayBuffer', 'formdata', 'application/json', + // eslint-disable-next-line sonarjs/no-duplicate-string 'text/plain', + // eslint-disable-next-line sonarjs/no-duplicate-string 'application/x-www-form-urlencoded', + // eslint-disable-next-line sonarjs/no-duplicate-string 'application/octet-stream', + // eslint-disable-next-line sonarjs/no-duplicate-string 'multipart/form-data' ] @@ -77,6 +84,11 @@ export const hasAdditionalProperties = ( // @ts-expect-error private property const schema: TAnySchema = (_schema as TypeCheck)?.schema ?? _schema + // @ts-expect-error private property + if (schema[TypeBoxSymbol.kind] === 'Import' && _schema.References()) { + return _schema.References().some(hasAdditionalProperties) + } + if (schema.anyOf) return schema.anyOf.some(hasAdditionalProperties) if (schema.someOf) return schema.someOf.some(hasAdditionalProperties) if (schema.allOf) return schema.allOf.some(hasAdditionalProperties) @@ -287,8 +299,19 @@ export const hasType = (type: string, schema: TAnySchema) => { ) } -export const hasProperty = (expectedProperty: string, schema: TAnySchema) => { - if (!schema) return +export const hasProperty = ( + expectedProperty: string, + _schema: TAnySchema | TypeCheck +) => { + if (!_schema) return + + // @ts-expect-error private property + const schema = _schema.schema ?? _schema + + if (schema[TypeBoxSymbol.kind] === 'Import') + return _schema + .References() + .some((schema: TAnySchema) => hasProperty(expectedProperty, schema)) if (schema.type === 'object') { const properties = schema.properties as Record @@ -526,6 +549,8 @@ export const composeHandler = ({ const cookieValidator = hasCookie ? getCookieValidator({ + // @ts-expect-error private property + modules: app.definitions.typebox, validator: validator.cookie as any, defaultConfig: app.config.cookie, dynamic: !!app.config.aot, @@ -1168,8 +1193,7 @@ export const composeHandler = ({ ) fnLiteral += 'c.headers=validator.headers.Clean(c.headers);\n' - // @ts-ignore - if (hasProperty('default', validator.headers.schema)) + if (hasProperty('default', validator.headers)) for (const [key, value] of Object.entries( Value.Default( // @ts-ignore @@ -1204,8 +1228,7 @@ export const composeHandler = ({ } if (validator.params) { - // @ts-ignore - if (hasProperty('default', validator.params.schema)) + if (hasProperty('default', validator.params)) for (const [key, value] of Object.entries( Value.Default( // @ts-ignore @@ -1242,8 +1265,7 @@ export const composeHandler = ({ ) fnLiteral += 'c.query=validator.query.Clean(c.query)\n' - // @ts-ignore - if (hasProperty('default', validator.query.schema)) + if (hasProperty('default', validator.query)) for (const [key, value] of Object.entries( Value.Default( // @ts-ignore @@ -1291,8 +1313,7 @@ export const composeHandler = ({ if (doesHaveTransform || isOptional(validator.body)) fnLiteral += `const isNotEmptyObject=c.body&&(typeof c.body==="object"&&isNotEmpty(c.body))\n` - // @ts-ignore - if (hasProperty('default', validator.body.schema)) { + if (hasProperty('default', validator.body)) { const value = Value.Default( // @ts-expect-error private property validator.body.schema, @@ -1343,6 +1364,7 @@ export const composeHandler = ({ } if ( + cookieValidator && isNotEmpty( // @ts-ignore cookieValidator?.schema?.properties ?? @@ -1356,8 +1378,7 @@ export const composeHandler = ({ `for(const [key,value] of Object.entries(c.cookie))` + `cookieValue[key]=value.value\n` - // @ts-ignore - if (hasProperty('default', cookieValidator.schema)) + if (hasProperty('default', cookieValidator)) for (const [key, value] of Object.entries( Value.Default( // @ts-ignore diff --git a/src/index.ts b/src/index.ts index dfc384bf..48ea1446 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,8 @@ import { PromiseGroup, promoteEvent, stringToStructureCoercions, - isNotEmpty + isNotEmpty, + replaceSchemaType } from './utils' import { @@ -351,6 +352,7 @@ export default class Elysia< env(model: TObject, _env = env) { const validator = getSchemaValidator(model, { + modules: this.definitions.typebox, dynamic: true, additionalProperties: true, coerce: true @@ -453,9 +455,10 @@ export default class Elysia< } { const models: Record> = {} - for (const [name, schema] of Object.entries(this.definitions.type)) + for (const name of Object.keys(this.definitions.type)) models[name] = getSchemaValidator( - schema as any + // @ts-expect-error + this.definitions.typebox.Import(name) ) as TypeCheck // @ts-expect-error @@ -525,6 +528,7 @@ export default class Elysia< const cookieValidator = () => cloned.cookie ? getCookieValidator({ + modules, validator: cloned.cookie, defaultConfig: this.config.cookie, config: cloned.cookie?.config ?? {}, @@ -534,6 +538,7 @@ export default class Elysia< : undefined const normalize = this.config.normalize + const modules = this.definitions.typebox const validator = this.config.precompile === true || @@ -541,12 +546,14 @@ export default class Elysia< this.config.precompile.schema === true) ? { body: getSchemaValidator(cloned.body, { + modules, dynamic, models, normalize, additionalCoerce: coercePrimitiveRoot() }), headers: getSchemaValidator(cloned.headers, { + modules, dynamic, models, additionalProperties: !this.config.normalize, @@ -554,12 +561,14 @@ export default class Elysia< additionalCoerce: stringToStructureCoercions() }), params: getSchemaValidator(cloned.params, { + modules, dynamic, models, coerce: true, additionalCoerce: stringToStructureCoercions() }), query: getSchemaValidator(cloned.query, { + modules, dynamic, models, normalize, @@ -568,6 +577,7 @@ export default class Elysia< }), cookie: cookieValidator(), response: getResponseSchemaValidator(cloned.response, { + modules, dynamic, models, normalize @@ -580,6 +590,7 @@ export default class Elysia< return (this.body = getSchemaValidator( cloned.body, { + modules, dynamic, models, normalize, @@ -593,6 +604,7 @@ export default class Elysia< return (this.headers = getSchemaValidator( cloned.headers, { + modules, dynamic, models, additionalProperties: !normalize, @@ -608,6 +620,7 @@ export default class Elysia< return (this.params = getSchemaValidator( cloned.params, { + modules, dynamic, models, coerce: true, @@ -622,6 +635,7 @@ export default class Elysia< return (this.query = getSchemaValidator( cloned.query, { + modules, dynamic, models, coerce: true, @@ -641,6 +655,7 @@ export default class Elysia< return (this.response = getResponseSchemaValidator( cloned.response, { + modules, dynamic, models, normalize @@ -5626,24 +5641,41 @@ export default class Elysia< > model(name: string | Record | Function, model?: TSchema) { + const coerce = (schema: TSchema) => + replaceSchemaType(schema, [ + { + from: t.Number(), + to: (options) => t.Numeric(options), + untilObjectFound: true + }, + { + from: t.Boolean(), + to: (options) => t.BooleanString(options), + untilObjectFound: true + } + ]) + switch (typeof name) { case 'object': + const parsedSchemas = {} as Record + Object.entries(name).forEach(([key, value]) => { if (!(key in this.definitions.type)) - this.definitions.type[key] = value as TSchema + parsedSchemas[key] = this.definitions.type[key] = + coerce(value) as TSchema }) // @ts-expect-error this.definitions.typebox = t.Module({ ...(this.definitions.typebox['$defs'] as TModule<{}>), - ...name + ...parsedSchemas } as any) return this case 'function': - const result = name(this.definitions.type) + const result = coerce(name(this.definitions.type)) this.definitions.type = result - this.definitions.typebox = t.Module(result) + this.definitions.typebox = t.Module(result as any) return this as any } diff --git a/src/utils.ts b/src/utils.ts index 075dc6f3..59ed7b11 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ import type { BunFile } from 'bun' import { Kind, TAnySchema, + TModule, TransformKind, type TSchema } from '@sinclair/typebox' @@ -573,27 +574,40 @@ export const getSchemaValidator = ( { models = {}, dynamic = false, + modules, normalize = false, additionalProperties = false, coerce = false, additionalCoerce = [] }: { models?: Record + modules: TModule additionalProperties?: boolean dynamic?: boolean normalize?: boolean coerce?: boolean additionalCoerce?: MaybeArray - } = {} + } = { + modules: t.Module({}) + } ): T extends TSchema ? TypeCheck : undefined => { if (!s) return undefined as any if (typeof s === 'string' && !(s in models)) return undefined as any - let schema: TSchema = typeof s === 'string' ? models[s] : s + let schema: TSchema = + typeof s === 'string' + ? // @ts-expect-error + ((modules as TModule<{}, {}>).Import(s) ?? models[s]) + : s if (coerce || additionalCoerce) { if (coerce) schema = replaceSchemaType(schema, [ + { + from: t.Ref(''), + // @ts-expect-error + to: (options) => modules.Import(options['$ref']) + }, { from: t.Number(), to: (options) => t.Numeric(options), @@ -610,6 +624,11 @@ export const getSchemaValidator = ( ]) else { schema = replaceSchemaType(schema, [ + { + from: t.Ref(''), + // @ts-expect-error + to: (options) => modules.Import(options['$ref']) + }, ...(Array.isArray(additionalCoerce) ? additionalCoerce : [additionalCoerce]) @@ -726,10 +745,12 @@ export const getResponseSchemaValidator = ( s: InputSchema['response'] | undefined, { models = {}, + modules, dynamic = false, normalize = false, additionalProperties = false }: { + modules: TModule models?: Record additionalProperties?: boolean dynamic?: boolean @@ -739,7 +760,11 @@ export const getResponseSchemaValidator = ( if (!s) return if (typeof s === 'string' && !(s in models)) return - const maybeSchemaOrRecord = typeof s === 'string' ? models[s] : s + const maybeSchemaOrRecord = + typeof s === 'string' + ? // @ts-ignore + ((modules as TModule<{}, {}>).Import(s) ?? models[s]) + : s const compile = (schema: TSchema, references?: TSchema[]) => { if (dynamic) @@ -867,18 +892,21 @@ export const coercePrimitiveRoot = () => { export const getCookieValidator = ({ validator, + modules, defaultConfig = {}, config, dynamic, models }: { validator: TSchema | string | undefined + modules: TModule defaultConfig: CookieOptions | undefined config: CookieOptions dynamic: boolean models: Record | undefined }) => { let cookieValidator = getSchemaValidator(validator, { + modules, dynamic, models, additionalProperties: true, @@ -896,6 +924,7 @@ export const getCookieValidator = ({ ) } else { cookieValidator = getSchemaValidator(t.Cookie({}), { + modules, dynamic, models, additionalProperties: true diff --git a/test/extends/models.test.ts b/test/extends/models.test.ts index 83518dcc..4ea269f7 100644 --- a/test/extends/models.test.ts +++ b/test/extends/models.test.ts @@ -2,7 +2,7 @@ import { Elysia, t } from '../../src' import { describe, expect, it } from 'bun:test' -import { req } from '../utils' +import { post, req } from '../utils' describe('Model', () => { it('add single', async () => { @@ -172,4 +172,172 @@ describe('Model', () => { const res = await app.handle(req('/')).then((r) => r.json()) expect(res).toEqual(['string', 'numba']) }) + + it('use reference model', async () => { + const app = new Elysia() + .model({ + string: t.Object({ + data: t.String() + }) + }) + .post('/', ({ body }) => body, { + body: 'string' + }) + + const error = await app.handle(post('/')) + expect(error.status).toBe(422) + + const correct = await app.handle( + post('/', { + data: 'hi' + }) + ) + expect(correct.status).toBe(200) + }) + + it('use coerce with reference model', async () => { + const app = new Elysia() + .model({ + number: t.Number(), + optionalNumber: t.Optional(t.Ref('number')) + }) + .post('/', ({ body }) => body, { + body: 'optionalNumber' + }) + + const error = await app.handle(post('/')) + expect(error.status).toBe(422) + + const correct = await app.handle( + new Request('http://localhost/', { + method: 'POST', + headers: { + 'content-type': 'text/plain; charset=utf-8' + }, + body: '0' + }) + ) + expect(correct.status).toBe(200) + }) + + it('create default value with nested reference model', async () => { + const app = new Elysia() + .model({ + number: t.Number({ default: 0 }), + optionalNumber: t.Optional(t.Ref('number')) + }) + .post('/', ({ body }) => body, { + body: t.Optional(t.Ref('number')) + }) + + const result = await app.handle(post('/')).then((x) => x.text()) + + expect(result).toBe('0') + }) + + it('create default value with nested reference model', async () => { + const app = new Elysia() + .model({ + number: t.Number({ default: 0 }), + optionalNumber: t.Optional(t.Ref('number')) + }) + .post('/', ({ body }) => body, { + body: t.Optional(t.Ref('number')) + }) + + const result = await app.handle(post('/')).then((x) => x.text()) + + expect(result).toBe('0') + }) + + it('create default value with reference model', async () => { + const app = new Elysia() + .model({ + number: t.Number({ default: 0 }), + optionalNumber: t.Optional(t.Ref('number')) + }) + .post('/', ({ body }) => body, { body: 'optionalNumber' }) + + const result = await app.handle(post('/')).then((x) => x.text()) + + expect(result).toBe('0') + }) + + it('use reference model with cookie', async () => { + const app = new Elysia() + .model({ + session: t.Cookie({ token: t.Number() }), + optionalSession: t.Optional(t.Ref('session')) + }) + .get('/', () => 'Hello Elysia', { cookie: 'optionalSession' }) + + const error = await app.handle( + new Request('http://localhost/', { + headers: { + cookie: 'token=string' + } + }) + ) + expect(error.status).toBe(422) + + const correct = await app.handle( + new Request('http://localhost/', { + headers: { + cookie: 'token=1' + } + }) + ) + expect(correct.status).toBe(200) + }) + + it('use reference model with response', async () => { + const app = new Elysia() + .model({ + res: t.String() + }) + .get('/correct', () => 'Hello Elysia', { response: 'res' }) + // @ts-expect-error + .get('/error', () => 1, { response: 'res' }) + + const error = await app.handle(req('/error')) + expect(error.status).toBe(422) + + const correct = await app.handle(req('/correct')) + expect(correct.status).toBe(200) + }) + + it('use reference model with response per status', async () => { + const app = new Elysia() + .model({ + res: t.String() + }) + .get('/correct', () => 'Hello Elysia', { + response: { + 200: 'res', + 400: 'res' + } + }) + .get('/400', ({ error }) => error(400, 'ok'), { + response: { + 200: 'res', + 400: 'res' + } + }) + // @ts-expect-error + .get('/error', ({ error }) => error(400, 1), { + response: { + 200: 'res', + 400: 'res' + } + }) + + const error = await app.handle(req('/error')) + expect(error.status).toBe(422) + + const correct = await app.handle(req('/correct')) + expect(correct.status).toBe(200) + + const correct400 = await app.handle(req('/400')) + expect(correct400.status).toBe(400) + }) })