diff --git a/src/compose.ts b/src/compose.ts index 5304c6758..ddcf41f1b 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -42,6 +42,7 @@ import { ELYSIA_TRACE, type TraceHandler } from './trace' import { ElysiaTypeCheck, getCookieValidator, + getSchemaProperties, getSchemaValidator, hasElysiaMeta, hasType, @@ -734,9 +735,10 @@ export const composeHandler = ({ if (validator.query?.schema) { const schema = unwrapImportSchema(validator.query?.schema) + const properties = getSchemaProperties(schema) - if (Kind in schema && schema.properties) { - for (const [key, value] of Object.entries(schema.properties)) { + if (properties) { + for (const [key, value] of Object.entries(properties)) { if (hasElysiaMeta('ArrayQuery', value as TSchema)) { arrayProperties[key] = true hasArrayProperty = true @@ -1494,22 +1496,10 @@ export const composeHandler = ({ if (candidate) { const isFirst = fileUnions.length === 0 - // Handle case where schema is wrapped in a Union (e.g., ObjectString coercion) - let properties = - candidate.schema?.properties ?? type.properties - - // If no properties but schema is a Union, try to find the Object in anyOf - if (!properties && candidate.schema?.anyOf) { - const objectSchema = - candidate.schema.anyOf.find( - (s: any) => - s.type === 'object' || - (Kind in s && s[Kind] === 'Object') - ) - if (objectSchema) { - properties = objectSchema.properties - } - } + // Handle case where schema is wrapped in a Union/Intersect (e.g., ObjectString coercion) + const properties = + getSchemaProperties(candidate.schema) ?? + getSchemaProperties(type) if (!properties) continue @@ -1566,20 +1556,27 @@ export const composeHandler = ({ ) { let validateFile = '' + const bodyProperties = getSchemaProperties( + unwrapImportSchema(validator.body.schema) + ) + let i = 0 - for (const [k, v] of Object.entries( - unwrapImportSchema(validator.body.schema).properties - ) as [string, TSchema][]) { - if ( - !v.extension || - (v[Kind] !== 'File' && v[Kind] !== 'Files') - ) - continue + if (bodyProperties) { + for (const [k, v] of Object.entries(bodyProperties) as [ + string, + TSchema + ][]) { + if ( + !v.extension || + (v[Kind] !== 'File' && v[Kind] !== 'Files') + ) + continue - if (i) validateFile += ',' - validateFile += `fileType(c.body.${k},${JSON.stringify(v.extension)},'body.${k}')` + if (i) validateFile += ',' + validateFile += `fileType(c.body.${k},${JSON.stringify(v.extension)},'body.${k}')` - i++ + i++ + } } if (i) fnLiteral += '\n' diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index 12c64ebcc..011bd15a4 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -10,7 +10,7 @@ import { } from './error' import type { AnyElysia, CookieOptions } from './index' import { parseQuery } from './parse-query' -import type { ElysiaTypeCheck } from './schema' +import { getSchemaProperties, type ElysiaTypeCheck } from './schema' import type { TypeCheck } from './type-system' import type { Handler, LifeCycleStore, SchemaValidator } from './types' import { hasSetImmediate, redirect, StatusMap, signCookie } from './utils' @@ -34,10 +34,10 @@ const injectDefaultValues = ( if (schema.$defs?.[schema.$ref]) schema = schema.$defs[schema.$ref] - if (!schema?.properties) return + const properties = getSchemaProperties(schema) + if (!properties) return - for (const [key, keySchema] of Object.entries(schema.properties)) { - // @ts-expect-error private + for (const [key, keySchema] of Object.entries(properties)) { obj[key] ??= keySchema.default } } @@ -411,19 +411,21 @@ export const createDynamicHandler = (app: AnyElysia) => { if (schema.$defs?.[schema.$ref]) schema = schema.$defs[schema.$ref] - const properties = schema.properties - - for (const property of Object.keys(properties)) { - const value = properties[property] - if ( - (value.type === 'array' || - value.items?.type === 'string') && - typeof context.query[property] === 'string' && - context.query[property] - ) { - // @ts-expect-error - context.query[property] = - context.query[property].split(',') + const properties = getSchemaProperties(schema) + + if (properties) { + for (const property of Object.keys(properties)) { + const value = properties[property] + if ( + (value.type === 'array' || + value.items?.type === 'string') && + typeof context.query[property] === 'string' && + context.query[property] + ) { + // @ts-expect-error + context.query[property] = + context.query[property].split(',') + } } } } @@ -637,11 +639,11 @@ export const createDynamicHandler = (app: AnyElysia) => { secret ) } else { - const properties = validator?.cookie?.schema?.properties + const properties = getSchemaProperties(validator?.cookie?.schema) if (secret) for (const name of cookieMeta.sign) { - if (!(name in properties)) continue + if (!properties || !(name in properties)) continue if (context.set.cookie[name]?.value) { context.set.cookie[name].value = diff --git a/src/schema.ts b/src/schema.ts index 82904d308..6c5150752 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -248,8 +248,8 @@ export const hasElysiaMeta = (meta: string, _schema: TAnySchema): boolean => { export const hasProperty = ( expectedProperty: string, _schema: TAnySchema | TypeCheck | ElysiaTypeCheck -) => { - if (!_schema) return +): boolean => { + if (!_schema) return false // @ts-expect-error private property const schema = _schema.schema ?? _schema @@ -259,6 +259,19 @@ export const hasProperty = ( .References() .some((schema: TAnySchema) => hasProperty(expectedProperty, schema)) + if (schema.anyOf) + return schema.anyOf.some((s: TSchema) => + hasProperty(expectedProperty, s) + ) + if (schema.allOf) + return schema.allOf.some((s: TSchema) => + hasProperty(expectedProperty, s) + ) + if (schema.oneOf) + return schema.oneOf.some((s: TSchema) => + hasProperty(expectedProperty, s) + ) + if (schema.type === 'object') { const properties = schema.properties as Record @@ -745,7 +758,7 @@ export const getSchemaValidator = < hasAdditionalProperties(schema)) }, get hasDefault() { - if ('~hasDefault' in this) return this['~hasDefault'] + if ('~hasDefault' in this) return this['~hasDefault']! return (this['~hasDefault'] = hasProperty( 'default', @@ -1055,6 +1068,28 @@ export const getSchemaValidator = < export const isUnion = (schema: TSchema) => schema[Kind] === 'Union' || (!schema.schema && !!schema.anyOf) +// Returns all properties as a flat map, handling Union/Intersect +// See: https://github.com/sinclairzx81/typebox/blob/0.34.3/src/type/indexed/indexed.ts#L152-L162 +export const getSchemaProperties = ( + schema: TAnySchema | undefined +): Record | undefined => { + if (!schema) return undefined + + if (schema.properties) return schema.properties + + const members = schema.allOf ?? schema.anyOf ?? schema.oneOf + if (members) { + const result: Record = {} + for (const member of members) { + const props = getSchemaProperties(member) + if (props) Object.assign(result, props) + } + return Object.keys(result).length > 0 ? result : undefined + } + + return undefined +} + export const mergeObjectSchemas = ( schemas: TSchema[] ): { diff --git a/test/aot/has-type.test.ts b/test/aot/has-type.test.ts index 6fdf3c6e6..abee4a9aa 100644 --- a/test/aot/has-type.test.ts +++ b/test/aot/has-type.test.ts @@ -151,4 +151,55 @@ describe('Has Transform', () => { expect(hasType('Files', schema)).toBe(true) }) + + // Intersect schema tests + it('find on direct Intersect', () => { + const schema = t.Intersect([ + t.Object({ + id: t.Number() + }), + t.Object({ + file: t.File() + }) + ]) + + expect(hasType('File', schema)).toBe(true) + }) + + it('do not find on Intersect without File', () => { + const schema = t.Intersect([ + t.Object({ + id: t.Number() + }), + t.Object({ + name: t.String() + }) + ]) + + expect(hasType('File', schema)).toBe(false) + }) + + it('find on nested Union in Intersect', () => { + const schema = t.Intersect([ + t.Object({ + id: t.Number() + }), + t.Union([t.Object({ file: t.File() }), t.Null()]) + ]) + + expect(hasType('File', schema)).toBe(true) + }) + + it('find File in Intersect referenced via Module.Import()', () => { + const schema = t + .Module({ + Data: t.Intersect([ + t.Object({ id: t.Number() }), + t.Object({ file: t.File() }) + ]) + }) + .Import('Data') + + expect(hasType('File', schema)).toBe(true) + }) }) diff --git a/test/core/dynamic.test.ts b/test/core/dynamic.test.ts index 8cf395680..a89615533 100644 --- a/test/core/dynamic.test.ts +++ b/test/core/dynamic.test.ts @@ -758,4 +758,28 @@ describe('Dynamic Mode', () => { // names: ['rapi', 'anis'] // }) // }) + + // Union schema test - verifies getSchemaProperties handles Union without crashing + it('handle Union query schema', async () => { + const app = new Elysia({ aot: false }).get( + '/', + ({ query }) => query, + { + query: t.Union([ + t.Object({ + search: t.String() + }), + t.Object({ + id: t.Numeric() + }) + ]) + } + ) + + const response = await app + .handle(req('/?search=test')) + .then((x) => x.json()) + + expect(response.search).toBe('test') + }) }) diff --git a/test/schema/schema-utils.test.ts b/test/schema/schema-utils.test.ts new file mode 100644 index 000000000..b6a9924b6 --- /dev/null +++ b/test/schema/schema-utils.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'bun:test' + +import { t } from '../../src' +import { hasProperty, getSchemaProperties } from '../../src/schema' + +describe('getSchemaProperties', () => { + it('returns properties for Object schema', () => { + const schema = t.Object({ + name: t.String(), + age: t.Number() + }) + + const props = getSchemaProperties(schema) + expect(props).toBeDefined() + expect(Object.keys(props!)).toEqual(['name', 'age']) + }) + + it('returns undefined for non-object schema', () => { + expect(getSchemaProperties(t.String())).toBeUndefined() + expect(getSchemaProperties(t.Number())).toBeUndefined() + expect(getSchemaProperties(t.Array(t.String()))).toBeUndefined() + }) + + it('returns undefined for undefined/null', () => { + expect(getSchemaProperties(undefined)).toBeUndefined() + expect(getSchemaProperties(null as any)).toBeUndefined() + }) + + it('returns combined properties for Union schema', () => { + const schema = t.Union([ + t.Object({ name: t.String() }), + t.Object({ age: t.Number() }) + ]) + + const props = getSchemaProperties(schema) + expect(props).toBeDefined() + expect(Object.keys(props!).sort()).toEqual(['age', 'name']) + }) + + it('returns combined properties for Intersect schema', () => { + const schema = t.Intersect([ + t.Object({ name: t.String() }), + t.Object({ age: t.Number() }) + ]) + + const props = getSchemaProperties(schema) + expect(props).toBeDefined() + expect(Object.keys(props!).sort()).toEqual(['age', 'name']) + }) + + it('returns Object properties when property value is Union', () => { + const schema = t.Object({ + data: t.Union([t.String(), t.Number()]) + }) + + const props = getSchemaProperties(schema) + expect(props).toBeDefined() + expect(Object.keys(props!)).toEqual(['data']) + }) + + it('handles nested Union/Intersect', () => { + const schema = t.Union([ + t.Intersect([ + t.Object({ a: t.String() }), + t.Object({ b: t.Number() }) + ]), + t.Object({ c: t.Boolean() }) + ]) + + const props = getSchemaProperties(schema) + expect(props).toBeDefined() + expect(Object.keys(props!).sort()).toEqual(['a', 'b', 'c']) + }) + + it('handles nested Intersect/Union', () => { + const schema = t.Intersect([ + t.Union([t.Object({ a: t.String() }), t.Object({ b: t.Number() })]), + t.Object({ c: t.Boolean() }) + ]) + + const props = getSchemaProperties(schema) + expect(props).toBeDefined() + expect(Object.keys(props!).sort()).toEqual(['a', 'b', 'c']) + }) + + it('returns undefined for empty Union or Intersect', () => { + expect(getSchemaProperties({ anyOf: [] } as any)).toBeUndefined() + expect(getSchemaProperties({ allOf: [] } as any)).toBeUndefined() + }) + + it('handles Union with non-object members', () => { + const schema = t.Union([t.Object({ name: t.String() }), t.String()]) + + const props = getSchemaProperties(schema) + expect(props).toBeDefined() + expect(Object.keys(props!)).toEqual(['name']) + }) +}) + +describe('hasProperty', () => { + it('finds property in Object schema', () => { + const schema = t.Object({ + name: t.String({ default: 'test' }) + }) + + expect(hasProperty('default', schema)).toBe(true) + expect(hasProperty('minimum', schema)).toBe(false) + }) + + it('finds property in Union schema', () => { + const schema = t.Union([ + t.Object({ name: t.String({ default: 'test' }) }), + t.Object({ name: t.String() }) + ]) + + expect(hasProperty('default', schema)).toBe(true) + }) + + it('finds property in Intersect schema', () => { + const schema = t.Intersect([ + t.Object({ name: t.String({ default: 'test' }) }), + t.Object({ age: t.Number() }) + ]) + + expect(hasProperty('default', schema)).toBe(true) + }) + + it('returns false when property not in any Union member', () => { + const schema = t.Union([ + t.Object({ name: t.String() }), + t.Object({ age: t.Number() }) + ]) + + expect(hasProperty('default', schema)).toBe(false) + }) + + it('finds property in nested Union within Object', () => { + const schema = t.Object({ + data: t.Union([t.String({ default: 'hello' }), t.Number()]) + }) + + expect(hasProperty('default', schema)).toBe(true) + }) + + it('finds property in oneOf schema', () => { + const schema = { + oneOf: [ + t.Object({ name: t.String({ default: 'test' }) }), + t.Object({ name: t.String() }) + ] + } + + expect(hasProperty('default', schema as any)).toBe(true) + }) + + it('returns false for undefined schema', () => { + expect(hasProperty('default', undefined as any)).toBe(false) + }) + + it('handles deeply nested structures', () => { + const schema = t.Union([ + t.Intersect([ + t.Object({ + config: t.Object({ + value: t.String({ default: 'nested' }) + }) + }) + ]) + ]) + + expect(hasProperty('default', schema)).toBe(true) + }) +}) diff --git a/test/type-system/files.test.ts b/test/type-system/files.test.ts index 875a037cc..fc3beea2a 100644 --- a/test/type-system/files.test.ts +++ b/test/type-system/files.test.ts @@ -62,4 +62,62 @@ describe('Files', () => { expect(response.status).toBe(422) } }) + + // Union schema tests - testing that getSchemaProperties handles Union correctly + it('handle file in Union schema', async () => { + const app = new Elysia().post('/', ({ body }) => 'ok', { + body: t.Union([ + t.Object({ + avatar: t.File(), + type: t.Literal('image') + }), + t.Object({ + document: t.File(), + type: t.Literal('doc') + }) + ]) + }) + + const body = new FormData() + body.append('avatar', Bun.file('test/images/millenium.jpg')) + body.append('type', 'image') + + const response = await app.handle( + new Request('http://localhost/', { + method: 'POST', + body + }) + ) + + expect(response.status).toBe(200) + }) + + it('handle multiple files in Union schema', async () => { + const app = new Elysia().post('/', ({ body }) => 'ok', { + body: t.Union([ + t.Object({ + images: t.Files(), + category: t.Literal('gallery') + }), + t.Object({ + documents: t.Files(), + category: t.Literal('archive') + }) + ]) + }) + + const body = new FormData() + body.append('images', Bun.file('test/images/millenium.jpg')) + body.append('images', Bun.file('test/images/kozeki-ui.webp')) + body.append('category', 'gallery') + + const response = await app.handle( + new Request('http://localhost/', { + method: 'POST', + body + }) + ) + + expect(response.status).toBe(200) + }) }) diff --git a/test/validator/query.test.ts b/test/validator/query.test.ts index 818f4d6a4..ec3ad42de 100644 --- a/test/validator/query.test.ts +++ b/test/validator/query.test.ts @@ -1126,4 +1126,43 @@ describe('Query Validator', () => { expect(invalid1.status).toBe(422) expect(invalid2.status).toBe(422) }) + + // Union schema tests + it('handle query array in Union schema', async () => { + const app = new Elysia({ aot: false }).get('/', ({ query }) => query, { + query: t.Union([ + t.Object({ + ids: t.Array(t.String()) + }), + t.Object({ + id: t.String() + }) + ]) + }) + + const response = await app + .handle(req('/?ids=1&ids=2')) + .then((x) => x.json()) + + expect(response.ids).toEqual(['1', '2']) + }) + + it('handle numeric coercion in Union schema', async () => { + const app = new Elysia({ aot: false }).get('/', ({ query }) => query, { + query: t.Union([ + t.Object({ + page: t.Numeric() + }), + t.Object({ + cursor: t.String() + }) + ]) + }) + + const response = await app + .handle(req('/?page=5')) + .then((x) => x.json()) + + expect(response.page).toBe(5) + }) })