diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9b496044..340fe359 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,9 +29,9 @@ jobs: bun-version: latest registry-url: "https://registry.npmjs.org" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: - node-version: '20.x' + node-version: '24.x' registry-url: 'https://registry.npmjs.org' - name: Install packages @@ -47,7 +47,5 @@ jobs: run: bun run test:cf - name: 'Publish' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - npm publish --provenance --access=public + NODE_AUTH_TOKEN="" npm publish --provenance --access=public diff --git a/CHANGELOG.md b/CHANGELOG.md index 467ea7b6..a34570be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# 1.4.19 - 13 Dec 2025 +Security: +- reject invalid cookie signature when using cookie rotation + +Improvement +- [#1609](https://github.com/elysiajs/elysia/issues/1609) calling afterResponse with aot: false +- [#1607](https://github.com/elysiajs/elysia/issues/1607), [#1606](https://github.com/elysiajs/elysia/issues/1606), [#1139](https://github.com/elysiajs/elysia/issues/1138) data coercion on nested form data +- [#1604](https://github.com/elysiajs/elysia/issues/1604) save lazy compilation of `Elysia.fetch` for up to 45x performance improvement +- [#1588](https://github.com/elysiajs/elysia/pull/1588), [#1587](https://github.com/elysiajs/elysia/pull/1587) add seen weakset during mergeDeep + +Bug fix: +- [#1614](https://github.com/elysiajs/elysia/issues/1614) Cloudflare Worker with dynamic path +- add set immediate fallback +- [#1597](https://github.com/elysiajs/elysia/issues/1597) safeParse, parse to static type +- [#1596](https://github.com/elysiajs/elysia/issues/1596) handle context pass to function with sub context +- [#1591](https://github.com/elysiajs/elysia/pull/1591), [#1590](https://github.com/elysiajs/elysia/pull/1591) merge set.headers without duplicating Response +- [#1546](https://github.com/elysiajs/elysia/issues/1546) override group prefix type +- [#1526](https://github.com/elysiajs/elysia/issues/1526) default error handler doesn't work in Cloudflare Workers +- handle group with empty prefix in type-level + # 1.4.18 - 4 Dec 2025 Security: - use `JSON.stringify` over custom escape implementation diff --git a/bun.lock b/bun.lock index 36477a5b..10083783 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "memoirist": "^0.4.0", }, "devDependencies": { + "@elysiajs/cors": "^1.4.0", "@elysiajs/openapi": "^1.4.1", "@types/bun": "^1.2.12", "@types/cookie": "1.0.0", @@ -55,6 +56,8 @@ "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], + "@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="], + "@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="], "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], diff --git a/example/a.ts b/example/a.ts index f16f2ac4..670b0bf7 100644 --- a/example/a.ts +++ b/example/a.ts @@ -1,22 +1,11 @@ -import { Elysia, t } from '../src' -import * as z from 'zod' -import { post, req } from '../test/utils' +import { Elysia } from '../src' -const app = new Elysia({ - cookie: { - domain: "\\` + console.log(c.q='pwn2') }) //" - } -}) - .get('/', ({ cookie: { session } }) => 'awd') - -console.log(app.routes[0].compile().toString()) +// This uses aot: true by default in 1.4 (broken on Bun) +const app = new Elysia({ systemRouter: true }) + .get("/", "Hello Elysia") + .get("/json", () => ({ message: "Hello World", timestamp: Date.now() })) -const root = await app.handle( - new Request('http://localhost/', { - headers: { - Cookie: 'session=1234' - } - }) -) - -console.log(await root.text()) +Bun.serve({ + port: 3000, + fetch: app.fetch +}) diff --git a/src/adapter/bun/index.ts b/src/adapter/bun/index.ts index faff18f4..82e3e92d 100644 --- a/src/adapter/bun/index.ts +++ b/src/adapter/bun/index.ts @@ -393,6 +393,8 @@ export const BunAdapter: ElysiaAdapter = { // @ts-expect-error private app.promisedModules.then(async () => { + if (typeof app.config.aot) app.compile() + app.server?.reload({ ...serve, fetch: app.fetch, @@ -439,11 +441,13 @@ export const BunAdapter: ElysiaAdapter = { normalize: app.config.normalize }) - const validateMessage = messageValidator ? - messageValidator.provider === 'standard' - ? (data: unknown) => messageValidator.schema['~standard'].validate(data).issues + const validateMessage = messageValidator + ? messageValidator.provider === 'standard' + ? (data: unknown) => + messageValidator.schema['~standard'].validate(data) + .issues : (data: unknown) => messageValidator.Check(data) === false - : undefined + : undefined const responseValidator = getSchemaValidator(response as any, { // @ts-expect-error private property @@ -569,7 +573,10 @@ export const BunAdapter: ElysiaAdapter = { ) => { const message = await parseMessage(ws, _message) - if (validateMessage && validateMessage(message)) { + if ( + validateMessage && + validateMessage(message) + ) { const validationError = new ValidationError( 'message', messageValidator!, diff --git a/src/adapter/cloudflare-worker/index.ts b/src/adapter/cloudflare-worker/index.ts index 8ce597d6..1d16d2d0 100644 --- a/src/adapter/cloudflare-worker/index.ts +++ b/src/adapter/cloudflare-worker/index.ts @@ -1,5 +1,6 @@ import { ElysiaAdapter } from '../..' import { WebStandardAdapter } from '../web-standard/index' +import { composeErrorHandler } from '../../compose' export function isCloudflareWorker() { try { @@ -62,15 +63,15 @@ export const CloudflareAdapter: ElysiaAdapter = { } }, beforeCompile(app) { + // @ts-ignore + app.handleError = composeErrorHandler(app) for (const route of app.routes) route.compile() }, - listen(app) { - return (options, callback) => { + listen() { + return () => { console.warn( 'Cloudflare Worker does not support listen method. Please export default Elysia instance instead.' ) - - app.compile() } } } diff --git a/src/compose.ts b/src/compose.ts index f4173bd8..2c50053c 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -19,6 +19,7 @@ import { import { ELYSIA_REQUEST_ID, getLoosePath, + hasSetImmediate, lifeCycleToFn, randomId, redirect, @@ -444,6 +445,10 @@ const coerceTransformDecodeError = ( `throw error.error ?? new ValidationError('${type}',validator.${type},${value},${allowUnsafeValidationDetails})}` + `}` +const setImmediateFn = hasSetImmediate + ? 'setImmediate' + : 'Promise.resolve().then' + export const composeHandler = ({ app, path, @@ -804,7 +809,7 @@ export const composeHandler = ({ let afterResponse = '' afterResponse += - `\nsetImmediate(async()=>{` + + `\n${setImmediateFn}(async()=>{` + `if(c.responseValue){` + `if(c.responseValue instanceof ElysiaCustomStatusResponse) c.set.status=c.responseValue.code\n` + (hasStream @@ -1474,11 +1479,17 @@ 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 + 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') + const objectSchema = + candidate.schema.anyOf.find( + (s: any) => + s.type === 'object' || + (Kind in s && s[Kind] === 'Object') + ) if (objectSchema) { properties = objectSchema.properties } @@ -1486,9 +1497,10 @@ export const composeHandler = ({ if (!properties) continue - const iterator = Object.entries( - properties - ) as [string, TSchema][] + const iterator = Object.entries(properties) as [ + string, + TSchema + ][] let validator = isFirst ? '\n' : ' else ' validator += `if(fileUnions[${fileUnions.length}].Check(c.body)){` @@ -1773,7 +1785,7 @@ export const composeHandler = ({ (hasTrace || hooks.afterResponse?.length ? `afterHandlerStreamListener=stream[2]\n` : '') + - `setImmediate(async ()=>{` + + `${setImmediateFn}(async ()=>{` + `if(listener)for await(const v of listener){}\n` handleReporter.resolve() fnLiteral += `})` + (maybeAsync ? '' : `})()`) + `}else{` @@ -2212,7 +2224,8 @@ export const composeHandler = ({ }) console.log('---') - process.exit(1) + if (typeof process?.exit === 'function') process.exit(1) + return () => new Response('Internal Server Error', { status: 500 }) } } @@ -2287,7 +2300,6 @@ export const createHoc = (app: AnyElysia, fnName = 'map') => { if (!hoc.length) return 'return ' + fnName const adapter = app['~adapter'].composeGeneralHandler - let handler = fnName for (let i = 0; i < hoc.length; i++) @@ -2337,7 +2349,7 @@ export const composeGeneralHandler = (app: AnyElysia) => { const prefix = app.event.afterResponse.some(isAsync) ? 'async' : '' afterResponse += - `\nsetImmediate(${prefix}()=>{` + + `\n${setImmediateFn}(${prefix}()=>{` + `if(c.responseValue instanceof ElysiaCustomStatusResponse) c.set.status=c.responseValue.code\n` for (let i = 0; i < app.event.afterResponse.length; i++) { @@ -2571,7 +2583,7 @@ export const composeErrorHandler = (app: AnyElysia) => { let afterResponse = '' const prefix = hooks.afterResponse?.some(isAsync) ? 'async' : '' - afterResponse += `\nsetImmediate(${prefix}()=>{` + afterResponse += `\n${setImmediateFn}(${prefix}()=>{` const reporter = createReport({ context: 'context', diff --git a/src/cookies.ts b/src/cookies.ts index 278f5c13..fec45f6f 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -410,7 +410,7 @@ export const parseCookie = async ( value = temp } else { - let decoded = true + let decoded = false for (let i = 0; i < secrets.length; i++) { const temp = await unsignCookie(value as string, secrets[i]) diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index 8297a33c..cf29b922 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -13,7 +13,7 @@ import { parseQuery } from './parse-query' import type { ElysiaTypeCheck } from './schema' import type { TypeCheck } from './type-system' import type { Handler, LifeCycleStore, SchemaValidator } from './types' -import { redirect, StatusMap, signCookie } from './utils' +import { hasSetImmediate, redirect, StatusMap, signCookie } from './utils' // JIT Handler export type DynamicHandler = { @@ -79,6 +79,8 @@ export const createDynamicHandler = (app: AnyElysia) => { response: unknown } + let hooks: DynamicHandler['hooks'] + try { if (app.event.request) for (let i = 0; i < app.event.request.length; i++) { @@ -109,7 +111,8 @@ export const createDynamicHandler = (app: AnyElysia) => { throw new NotFoundError() } - const { handle, hooks, validator, content, route } = handler.store + const { handle, validator, content, route } = handler.store + hooks = handler.store.hooks let body: string | Record | undefined if (request.method !== 'GET' && request.method !== 'HEAD') { @@ -661,11 +664,22 @@ export const createDynamicHandler = (app: AnyElysia) => { // @ts-expect-error private return app.handleError(context, reportedError) } finally { - if (app.event.afterResponse) - setImmediate(async () => { - for (const afterResponse of app.event.afterResponse!) - await afterResponse.fn(context as any) - }) + const afterResponses = hooks! + ? hooks.afterResponse + : app.event.afterResponse + + if (afterResponses) { + if (hasSetImmediate) + setImmediate(async () => { + for (const afterResponse of afterResponses) + await afterResponse.fn(context as any) + }) + else + Promise.resolve().then(async () => { + for (const afterResponse of afterResponses) + await afterResponse.fn(context as any) + }) + } } } } diff --git a/src/index.ts b/src/index.ts index f0ff83b6..2acceb20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,7 +48,7 @@ import { getCookieValidator, ElysiaTypeCheck, hasType, - resolveSchema, + resolveSchema } from './schema' import { composeHandler, @@ -164,7 +164,12 @@ import type { InlineHandlerNonMacro, Router } from './types' -import { coercePrimitiveRoot, coerceFormData, queryCoercions, stringToStructureCoercions } from './replace-schema' +import { + coercePrimitiveRoot, + coerceFormData, + queryCoercions, + stringToStructureCoercions +} from './replace-schema' export type AnyElysia = Elysia @@ -381,7 +386,6 @@ export default class Elysia< this.config = { aot: env.ELYSIA_AOT !== 'false', nativeStaticResponse: true, - systemRouter: true, encodeSchema: true, normalize: true, ...config, @@ -589,9 +593,16 @@ export default class Elysia< models, normalize, additionalCoerce: (() => { - const resolved = resolveSchema(cloned.body, models, modules) + const resolved = resolveSchema( + cloned.body, + models, + modules + ) // Only check for Files if resolved schema is a TypeBox schema (has Kind symbol) - return (resolved && Kind in resolved && (hasType('File', resolved) || hasType('Files', resolved))) + return resolved && + Kind in resolved && + (hasType('File', resolved) || + hasType('Files', resolved)) ? coerceFormData() : coercePrimitiveRoot() })(), @@ -657,9 +668,16 @@ export default class Elysia< models, normalize, additionalCoerce: (() => { - const resolved = resolveSchema(cloned.body, models, modules) + const resolved = resolveSchema( + cloned.body, + models, + modules + ) // Only check for Files if resolved schema is a TypeBox schema (has Kind symbol) - return (resolved && Kind in resolved && (hasType('File', resolved) || hasType('Files', resolved))) + return resolved && + Kind in resolved && + (hasType('File', resolved) || + hasType('Files', resolved)) ? coerceFormData() : coercePrimitiveRoot() })(), @@ -921,7 +939,11 @@ export default class Elysia< addResponsePath(path) + // For pre-compilation stage, eg. Cloudflare Worker + let _compiled: ComposedHandler const compile = () => { + if (_compiled) return _compiled + const compiled = composeHandler({ app: this, path, @@ -938,7 +960,7 @@ export default class Elysia< }) if (this.router.history[index]) - this.router.history[index].composed = compiled + _compiled = this.router.history[index].composed = compiled return compiled } @@ -960,9 +982,12 @@ export default class Elysia< const mainHandler = shouldPrecompile ? compile() : (ctx: Context) => - ((route[index].composed = compile!()) as ComposedHandler)( - ctx - ) + _compiled + ? _compiled(ctx) + : ( + (route[index].composed = + compile!()) as ComposedHandler + )(ctx) if (oldIndex !== undefined) this.router.history[oldIndex] = Object.assign( @@ -3822,23 +3847,15 @@ export default class Elysia< prefix: Prefix, run: ( group: Elysia< - JoinPath, + Prefix extends '' ? BasePath : JoinPath, Singleton, Definitions, { schema: MergeSchema< - UnwrapRoute< - {}, - Definitions['typebox'], - JoinPath - >, + UnwrapRoute<{}, Definitions['typebox']>, Metadata['schema'] > - standaloneSchema: UnwrapRoute< - {}, - Definitions['typebox'], - JoinPath - > & + standaloneSchema: UnwrapRoute<{}, Definitions['typebox']> & Metadata['standaloneSchema'] macro: Metadata['macro'] macroFn: Metadata['macroFn'] @@ -8000,10 +8017,16 @@ export default class Elysia< this['~adapter'].beforeCompile?.(this) if (this['~adapter'].isWebStandard) { - this.fetch = this.config.aot + this._handle = this.config.aot ? composeGeneralHandler(this) : createDynamicHandler(this) + Object.defineProperty(this, 'fetch', { + value: this._handle, + configurable: true, + writable: true + }) + if (typeof this.server?.reload === 'function') this.server.reload({ ...(this.server || {}), @@ -8028,10 +8051,18 @@ export default class Elysia< * * Beside benchmark purpose, please use 'handle' instead. */ - fetch = (request: Request): MaybePromise => { - return (this.fetch = this.config.aot + get fetch() { + const fetch = this.config.aot ? composeGeneralHandler(this) - : createDynamicHandler(this))(request) + : createDynamicHandler(this) + + Object.defineProperty(this, 'fetch', { + value: fetch, + configurable: true, + writable: true + }) + + return fetch } /** @@ -8153,13 +8184,8 @@ export { type TraceStream } from './trace' -export { - getSchemaValidator, - getResponseSchemaValidator, -} from './schema' -export { - replaceSchemaTypeFromManyOptions as replaceSchemaType -} from './replace-schema' +export { getSchemaValidator, getResponseSchemaValidator } from './schema' +export { replaceSchemaTypeFromManyOptions as replaceSchemaType } from './replace-schema' export { mergeHook, diff --git a/src/schema.ts b/src/schema.ts index 73e714e2..cec272ef 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -27,7 +27,8 @@ import type { InputSchema, MaybeArray, StandaloneInputSchema, - StandardSchemaV1LikeValidate + StandardSchemaV1LikeValidate, +UnwrapSchema } from './types' import type { StandardSchemaV1Like } from './types' @@ -40,10 +41,10 @@ export interface ElysiaTypeCheck provider: 'typebox' | 'standard' schema: T config: Object - Clean?(v: unknown): T - parse(v: unknown): T + Clean?(v: unknown): UnwrapSchema + parse(v: unknown): UnwrapSchema safeParse(v: unknown): - | { success: true; data: T; error: null } + | { success: true; data: UnwrapSchema; error: null } | { success: false data: null diff --git a/src/sucrose.ts b/src/sucrose.ts index 03eb87e5..1bc2d851 100644 --- a/src/sucrose.ts +++ b/src/sucrose.ts @@ -551,8 +551,36 @@ export const isContextPassToFunction = ( ) => { // ! Function is passed to another function, assume as all is accessed try { - const captureFunction = new RegExp(`\\w\\((.*?)?${context}`, 'gs') - captureFunction.test(body) + const captureFunction = new RegExp( + `\\w\\((?:.*?)?${context}(?:.*?)?\\)`, + 'gs' + ) + const exactParameter = new RegExp(`${context}(,|\\))`, 'gs') + + const length = body.length + let fn + + fn = captureFunction.exec(body) + '' + while ( + captureFunction.lastIndex !== 0 && + captureFunction.lastIndex < length + (fn ? fn.length : 0) + ) { + if (fn && exactParameter.test(fn)) { + inference.query = true + inference.headers = true + inference.body = true + inference.cookie = true + inference.set = true + inference.server = true + inference.url = true + inference.route = true + inference.path = true + + return true + } + + fn = captureFunction.exec(body) + '' + } /* Since JavaScript engine already format the code (removing whitespace, newline, etc.), @@ -700,7 +728,7 @@ export const sucrose = ( code.charCodeAt(0) === 123 && code.charCodeAt(body.length - 1) === 125 ) - code = code.slice(1, -1) + code = code.slice(1, -1).trim() if (!isContextPassToFunction(mainParameter, code, fnInference)) inferBodyReference(code, aliases, fnInference) diff --git a/src/utils.ts b/src/utils.ts index c564990e..572fca70 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -399,6 +399,7 @@ export const lifeCycleToArray = (a: LifeCycleStore) => { const isBun = typeof Bun !== 'undefined' const hasBunHash = isBun && typeof Bun.hash === 'function' +export const hasSetImmediate = typeof setImmediate === 'function' // https://stackoverflow.com/a/52171480 export const checksum = (s: string) => { diff --git a/test/cloudflare/src/index.ts b/test/cloudflare/src/index.ts index be609343..70b9cac7 100644 --- a/test/cloudflare/src/index.ts +++ b/test/cloudflare/src/index.ts @@ -1,13 +1,9 @@ import { Elysia, t } from 'elysia' import { CloudflareAdapter } from 'elysia/adapter/cloudflare-worker' -export default new Elysia({ - adapter: CloudflareAdapter -}) +const sub = new Elysia().get('/test', () => 'hello') + +export default new Elysia({ adapter: CloudflareAdapter }) .get('/', () => 'Elysia on Cloudflare Worker!') - .post('/mirror', ({ body }) => body, { - body: t.Object({ - hello: t.String() - }) - }) + .use(sub) .compile() diff --git a/test/core/dynamic.test.ts b/test/core/dynamic.test.ts index b74f2266..8cf39568 100644 --- a/test/core/dynamic.test.ts +++ b/test/core/dynamic.test.ts @@ -694,47 +694,68 @@ describe('Dynamic Mode', () => { }) }) - it('handle query array reference in multiple reference format', async () => { - const IdsModel = new Elysia().model({ - name: t.Object({ - name: t.Array(t.String()) - }) - }) - - const app = new Elysia({ aot: false }) - .use(IdsModel) - .get('/', ({ query }) => query, { - name: 'ids' - }) - - const data = await app - .handle(req('/?names=rapi&names=anis')) - .then((x) => x.json()) - - expect(data).toEqual({ - names: ['rapi', 'anis'] - }) - }) - - it('handle query array reference in multiple reference format', async () => { - const IdsModel = new Elysia().model({ - name: t.Object({ - name: t.Array(t.String()) - }) - }) + it('call local afterResponse on aot: false', async () => { + let called = false const app = new Elysia({ aot: false }) - .use(IdsModel) - .get('/', ({ query }) => query, { - name: 'ids' - }) - - const data = await app - .handle(req('/?names=rapi&names=anis')) - .then((x) => x.json()) - - expect(data).toEqual({ - names: ['rapi', 'anis'] - }) - }) + .guard( + { + afterResponse: () => { + called = true + } + }, + (app) => app.get('/test', () => 'afterResponse') + ) + .get('/', () => 'hi') + + const value = await app.handle(req('/test')).then((x) => x.text()) + await Bun.sleep(6.7) + + expect(value).toBe('afterResponse') + expect(called).toBeTrue() + }) + + // it('handle query array reference in multiple reference format', async () => { + // const IdsModel = new Elysia().model({ + // name: t.Object({ + // name: t.Array(t.String()) + // }) + // }) + + // const app = new Elysia({ aot: false }) + // .use(IdsModel) + // .get('/', ({ query }) => query, { + // query: 'name' + // }) + + // const data = await app + // .handle(req('/?names=rapi&names=anis')) + // .then((x) => x.json()) + + // expect(data).toEqual({ + // names: ['rapi', 'anis'] + // }) + // }) + + // it('handle query array reference in multiple reference format', async () => { + // const IdsModel = new Elysia().model({ + // name: t.Object({ + // name: t.Array(t.String()) + // }) + // }) + + // const app = new Elysia({ aot: false }) + // .use(IdsModel) + // .get('/', ({ query }) => query, { + // query: 'name' + // }) + + // const data = await app + // .handle(req('/?names=rapi&names=anis')) + // .then((x) => x.json()) + + // expect(data).toEqual({ + // names: ['rapi', 'anis'] + // }) + // }) }) diff --git a/test/sucrose/sucrose.test.ts b/test/sucrose/sucrose.test.ts index cc6d5b78..3d2cd34f 100644 --- a/test/sucrose/sucrose.test.ts +++ b/test/sucrose/sucrose.test.ts @@ -307,4 +307,25 @@ describe('sucrose', () => { route: true }) }) + + it('handle context pass to function with sub context', () => { + expect( + sucrose({ + handler: (context) => { + console.log('path >>> ', context.path) + console.log(context) + } + }) + ).toEqual({ + query: true, + headers: true, + body: true, + cookie: true, + set: true, + server: true, + path: true, + url: true, + route: true + }) + }) }) diff --git a/test/type-system/formdata.test.ts b/test/type-system/formdata.test.ts index 232c1047..be5d0c63 100644 --- a/test/type-system/formdata.test.ts +++ b/test/type-system/formdata.test.ts @@ -1,910 +1,906 @@ -import { describe, expect, it } from "bun:test"; -import { z } from "zod"; -import { Elysia, fileType, t, type ValidationError } from "../../src"; +import { describe, expect, it } from 'bun:test' +import { z } from 'zod' +import { Elysia, fileType, t, type ValidationError } from '../../src' const variantObject = t.Object({ price: t.Number({ minimum: 0 }), - weight: t.Number({ minimum: 0 }), -}); + weight: t.Number({ minimum: 0 }) +}) const metadataObject = { category: t.String(), tags: t.Array(t.String()), - inStock: t.Boolean(), -}; + inStock: t.Boolean() +} const postProductModel = t.Object({ name: t.String(), variants: t.Array(variantObject), metadata: t.Object(metadataObject), - image: t.File({ type: "image" }), -}); -type postProductModel = typeof postProductModel.static; + image: t.File({ type: 'image' }) +}) +type postProductModel = typeof postProductModel.static const patchProductModel = t.Object({ name: t.Optional(t.String()), variants: t.Optional(t.Array(variantObject)), metadata: t.Optional(t.Object(metadataObject)), - image: t.Optional(t.File({ type: "image" })), -}); -type patchProductModel = typeof patchProductModel.static; + image: t.Optional(t.File({ type: 'image' })) +}) +type patchProductModel = typeof patchProductModel.static const postProductModelComplex = t.Object({ name: t.String(), variants: t.ArrayString(variantObject), metadata: t.ObjectString(metadataObject), - image: t.File({ type: "image" }), -}); -type postProductModelComplex = typeof postProductModelComplex.static; + image: t.File({ type: 'image' }) +}) +type postProductModelComplex = typeof postProductModelComplex.static const patchProductModelComplex = t.Object({ name: t.Optional(t.String()), variants: t.Optional(t.ArrayString(variantObject)), metadata: t.Optional(t.ObjectString(metadataObject)), - image: t.Optional(t.File({ type: "image" })), -}); -type patchProductModelComplex = typeof patchProductModelComplex.static; - -describe.each([ - { aot: true }, - { aot: false }, -])("Nested FormData with file(s) support (aot: $aot)", ({ aot }) => { - const app = new Elysia({ aot }) - .post("/product", async ({ body, status }) => status("Created", body), { - body: postProductModel, - }) - .patch( - "/product/:id", - ({ body, params }) => ({ - id: params.id, - ...body, - }), + image: t.Optional(t.File({ type: 'image' })) +}) +type patchProductModelComplex = typeof patchProductModelComplex.static + +const app = new Elysia() + .post('/product', async ({ body, status }) => status('Created', body), { + body: postProductModel + }) + .patch( + '/product/:id', + ({ body, params }) => ({ + id: params.id, + ...body + }), + { + body: patchProductModel + } + ) + .post( + '/product-complex', + async ({ body, status }) => status('Created', body), + { + body: postProductModelComplex + } + ) + .patch( + '/product-complex/:id', + ({ body, params }) => ({ + id: params.id, + ...body + }), + { + body: patchProductModelComplex + } + ) + +describe('Nested FormData with mandatory bunFile (post operation)', async () => { + const bunFilePath1 = `${import.meta.dir}/../images/aris-yuzu.jpg` + const bunFile = Bun.file(bunFilePath1) as File + + const newProduct: postProductModel = { + name: 'Test Product', + variants: [ { - body: patchProductModel, + price: 10, + weight: 100 }, - ) - .post( - "/product-complex", - async ({ body, status }) => status("Created", body), { - body: postProductModelComplex, - }, + price: 2.7, + weight: 32 + } + ], + metadata: { + category: 'Electronics', + tags: ['new', 'featured', 'sale'], + inStock: true + }, + image: bunFile + } + + it('should create a product', async () => { + const stringifiedVariants = JSON.stringify(newProduct.variants) + const stringifiedMetadata = JSON.stringify(newProduct.metadata) + + const body = new FormData() + body.append('name', newProduct.name) + body.append('variants', stringifiedVariants) + body.append('metadata', stringifiedMetadata) + body.append('image', bunFile) + + const response = await app.handle( + new Request('http://localhost/product', { + method: 'POST', + body + }) ) - .patch( - "/product-complex/:id", - ({ body, params }) => ({ - id: params.id, - ...body, - }), + expect(response.status).toBe(201) + const data = await response.json() + expect(data).toEqual(newProduct) + }) + + it('should return validation error on nested ArrayString', async () => { + const stringifiedVariants = JSON.stringify([ { - body: patchProductModelComplex, - }, - ); - describe("Nested FormData with mandatory bunFile (post operation)", async () => { - const bunFilePath1 = `${import.meta.dir}/../images/aris-yuzu.jpg`; - const bunFile = Bun.file(bunFilePath1) as File; - - const newProduct: postProductModel = { - name: "Test Product", - variants: [ - { - price: 10, - weight: 100, - }, - { - price: 2.7, - weight: 32, - }, - ], - metadata: { - category: "Electronics", - tags: ["new", "featured", "sale"], - inStock: true, - }, - image: bunFile, - }; - - it("should create a product", async () => { - const stringifiedVariants = JSON.stringify(newProduct.variants); - const stringifiedMetadata = JSON.stringify(newProduct.metadata); - - const body = new FormData(); - body.append("name", newProduct.name); - body.append("variants", stringifiedVariants); - body.append("metadata", stringifiedMetadata); - body.append("image", bunFile); - - const response = await app.handle( - new Request("http://localhost/product", { - method: "POST", - body, - }), - ); - expect(response.status).toBe(201); - const data = await response.json(); - expect(data).toEqual(newProduct); - }); - - it("should return validation error on nested ArrayString", async () => { - const stringifiedVariants = JSON.stringify([ - { - price: 23, - waighTypo: "", - }, - ]); - const stringifiedMetadata = JSON.stringify(newProduct.metadata); - - const body = new FormData(); - body.append("name", newProduct.name); - body.append("variants", stringifiedVariants); - body.append("metadata", stringifiedMetadata); - body.append("image", bunFile); - - const response = await app.handle( - new Request("http://localhost/product", { - method: "POST", - body, - }), - ); - const data = (await response.json()) as ValidationError; - expect(response.status).toBe(422); - expect(data.type).toBe("validation"); - }); - - it("should return validation error on nested ObjectString", async () => { - const stringifiedVariants = JSON.stringify(newProduct.variants); - const stringifiedMetadata = JSON.stringify({ - categoryTypo: "Electronics", - tags: ["new", "featured", "sale"], - inStock: true, - }); - - const body = new FormData(); - body.append("name", newProduct.name); - body.append("variants", stringifiedVariants); - body.append("metadata", stringifiedMetadata); - body.append("image", bunFile); - - const response = await app.handle( - new Request("http://localhost/product", { - method: "POST", - body, - }), - ); - const data = (await response.json()) as ValidationError; - expect(response.status).toBe(422); - expect(data.type).toBe("validation"); - }); - }); - - describe("Nested FormData with optionnal file (patch operation)", async () => { - const bunFilePath2 = `${import.meta.dir}/../images/aris-yuzu.jpg`; - const bunFile = Bun.file(bunFilePath2) as File; - - it("PATCH with bunFile and omitted optional t.ObjectString", async () => { - const body = new FormData(); - body.append("name", "Updated Product"); - body.append("image", bunFile); - // metadata and variants fields are omitted (should be OK since they're optional) - - const response = await app.handle( - new Request("http://localhost/product/123", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as patchProductModel; - expect(data).not.toBeNull(); - expect(data?.name).toBe("Updated Product"); - expect(data?.metadata).toBeUndefined(); - expect(data?.variants).toBeUndefined(); - }); - - it("PATCH with file and valid t.ObjectString and t.ArrayString data", async () => { - const body = new FormData(); - body.append("name", "Updated Product"); - body.append("image", bunFile); - body.append( - "metadata", - JSON.stringify({ - category: "Electronics", - tags: ["sale", "new"], - inStock: true, - }), - ); - body.append( - "variants", - JSON.stringify([ - { - price: 15, - weight: 200, - }, - ]), - ); - - const response = await app.handle( - new Request("http://localhost/product/123", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as patchProductModel; - expect(data).not.toBeNull(); - expect(data?.name).toBe("Updated Product"); - expect(data?.metadata).toEqual({ - category: "Electronics", - tags: ["sale", "new"], - inStock: true, - }); - expect(data?.variants).toEqual([ + price: 23, + waighTypo: '' + } + ]) + const stringifiedMetadata = JSON.stringify(newProduct.metadata) + + const body = new FormData() + body.append('name', newProduct.name) + body.append('variants', stringifiedVariants) + body.append('metadata', stringifiedMetadata) + body.append('image', bunFile) + + const response = await app.handle( + new Request('http://localhost/product', { + method: 'POST', + body + }) + ) + const data = (await response.json()) as ValidationError + expect(response.status).toBe(422) + expect(data.type).toBe('validation') + }) + + it('should return validation error on nested ObjectString', async () => { + const stringifiedVariants = JSON.stringify(newProduct.variants) + const stringifiedMetadata = JSON.stringify({ + categoryTypo: 'Electronics', + tags: ['new', 'featured', 'sale'], + inStock: true + }) + + const body = new FormData() + body.append('name', newProduct.name) + body.append('variants', stringifiedVariants) + body.append('metadata', stringifiedMetadata) + body.append('image', bunFile) + + const response = await app.handle( + new Request('http://localhost/product', { + method: 'POST', + body + }) + ) + const data = (await response.json()) as ValidationError + expect(response.status).toBe(422) + expect(data.type).toBe('validation') + }) +}) + +describe('Nested FormData with optionnal file (patch operation)', async () => { + const bunFilePath2 = `${import.meta.dir}/../images/aris-yuzu.jpg` + const bunFile = Bun.file(bunFilePath2) as File + + it('PATCH with bunFile and omitted optional t.ObjectString', async () => { + const body = new FormData() + body.append('name', 'Updated Product') + body.append('image', bunFile) + // metadata and variants fields are omitted (should be OK since they're optional) + + const response = await app.handle( + new Request('http://localhost/product/123', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(200) + const data = (await response.json()) as patchProductModel + expect(data).not.toBeNull() + expect(data?.name).toBe('Updated Product') + expect(data?.metadata).toBeUndefined() + expect(data?.variants).toBeUndefined() + }) + + it('PATCH with file and valid t.ObjectString and t.ArrayString data', async () => { + const body = new FormData() + body.append('name', 'Updated Product') + body.append('image', bunFile) + body.append( + 'metadata', + JSON.stringify({ + category: 'Electronics', + tags: ['sale', 'new'], + inStock: true + }) + ) + body.append( + 'variants', + JSON.stringify([ { price: 15, - weight: 200, - }, - ]); - }); - - it("PATCH without file and omitted optional t.ObjectString and optional t.ArrayString", async () => { - const response = await app.handle( - new Request("http://localhost/product/123", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "Updated Product", - }), - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as patchProductModel; - expect(data).not.toBeNull(); - expect(data?.name).toBe("Updated Product"); - expect(data?.image).toBeUndefined(); - expect(data?.metadata).toBeUndefined(); - expect(data?.variants).toBeUndefined(); - }); - - it("PATCH should return validation error on invalid ObjectString", async () => { - const body = new FormData(); - body.append("name", "Updated Product"); - body.append("image", bunFile); - body.append( - "metadata", - JSON.stringify({ - categoryTypo: "Electronics", // Wrong property name - tags: ["sale"], - inStock: true, - }), - ); - - const response = await app.handle( - new Request("http://localhost/product/123", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(422); - const data = (await response.json()) as ValidationError; - expect(data.type).toBe("validation"); - }); - - it("PATCH should return validation error on invalid ArrayString", async () => { - const body = new FormData(); - body.append("name", "Updated Product"); - body.append("image", bunFile); - body.append( - "variants", - JSON.stringify([ - { - priceTypo: 15, // Wrong property name - weight: 200, - }, - ]), - ); - - const response = await app.handle( - new Request("http://localhost/product/123", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(422); - const data = (await response.json()) as ValidationError; - expect(data.type).toBe("validation"); - }); - }); - - describe("Nested FormData with t.ArrayString and t.ObjectString (POST operation)", async () => { - const bunFilePath3 = `${import.meta.dir}/../images/aris-yuzu.jpg`; - const bunFile = Bun.file(bunFilePath3) as File; - - const newProductComplex: postProductModelComplex = { - name: "Test Product Complex", - variants: [ - { - price: 10, - weight: 100, + weight: 200 + } + ]) + ) + + const response = await app.handle( + new Request('http://localhost/product/123', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(200) + const data = (await response.json()) as patchProductModel + expect(data).not.toBeNull() + expect(data?.name).toBe('Updated Product') + expect(data?.metadata).toEqual({ + category: 'Electronics', + tags: ['sale', 'new'], + inStock: true + }) + expect(data?.variants).toEqual([ + { + price: 15, + weight: 200 + } + ]) + }) + + it('PATCH without file and omitted optional t.ObjectString and optional t.ArrayString', async () => { + const response = await app.handle( + new Request('http://localhost/product/123', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Updated Product' + }) + }) + ) + + expect(response.status).toBe(200) + const data = (await response.json()) as patchProductModel + expect(data).not.toBeNull() + expect(data?.name).toBe('Updated Product') + expect(data?.image).toBeUndefined() + expect(data?.metadata).toBeUndefined() + expect(data?.variants).toBeUndefined() + }) + + it('PATCH should return validation error on invalid ObjectString', async () => { + const body = new FormData() + body.append('name', 'Updated Product') + body.append('image', bunFile) + body.append( + 'metadata', + JSON.stringify({ + categoryTypo: 'Electronics', // Wrong property name + tags: ['sale'], + inStock: true + }) + ) + + const response = await app.handle( + new Request('http://localhost/product/123', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(422) + const data = (await response.json()) as ValidationError + expect(data.type).toBe('validation') + }) + + it('PATCH should return validation error on invalid ArrayString', async () => { + const body = new FormData() + body.append('name', 'Updated Product') + body.append('image', bunFile) + body.append( + 'variants', + JSON.stringify([ { - price: 2.7, - weight: 32, - }, - ], - metadata: { - category: "Electronics", - tags: ["new", "featured", "sale"], - inStock: true, + priceTypo: 15, // Wrong property name + weight: 200 + } + ]) + ) + + const response = await app.handle( + new Request('http://localhost/product/123', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(422) + const data = (await response.json()) as ValidationError + expect(data.type).toBe('validation') + }) +}) + +describe('Nested FormData with t.ArrayString and t.ObjectString (POST operation)', async () => { + const bunFilePath3 = `${import.meta.dir}/../images/aris-yuzu.jpg` + const bunFile = Bun.file(bunFilePath3) as File + + const newProductComplex: postProductModelComplex = { + name: 'Test Product Complex', + variants: [ + { + price: 10, + weight: 100 }, - image: bunFile, - }; - - it("should create a product with t.ArrayString and t.ObjectString", async () => { - const stringifiedVariants = JSON.stringify(newProductComplex.variants); - const stringifiedMetadata = JSON.stringify(newProductComplex.metadata); - - const body = new FormData(); - body.append("name", newProductComplex.name); - body.append("variants", stringifiedVariants); - body.append("metadata", stringifiedMetadata); - body.append("image", bunFile); - - const response = await app.handle( - new Request("http://localhost/product-complex", { - method: "POST", - body, - }), - ); - expect(response.status).toBe(201); - const data = await response.json(); - expect(data).toEqual(newProductComplex); - }); - - it("should return validation error on invalid t.ArrayString nested structure", async () => { - const stringifiedVariants = JSON.stringify([ - { - price: 23, - weightTypo: 100, // Wrong property name - }, - ]); - const stringifiedMetadata = JSON.stringify(newProductComplex.metadata); - - const body = new FormData(); - body.append("name", newProductComplex.name); - body.append("variants", stringifiedVariants); - body.append("metadata", stringifiedMetadata); - body.append("image", bunFile); - - const response = await app.handle( - new Request("http://localhost/product-complex", { - method: "POST", - body, - }), - ); - const data = (await response.json()) as ValidationError; - expect(response.status).toBe(422); - expect(data.type).toBe("validation"); - }); - - it("should return validation error on invalid t.ObjectString nested structure", async () => { - const stringifiedVariants = JSON.stringify(newProductComplex.variants); - const stringifiedMetadata = JSON.stringify({ - categoryTypo: "Electronics", // Wrong property name - tags: ["new", "featured", "sale"], - inStock: true, - }); - - const body = new FormData(); - body.append("name", newProductComplex.name); - body.append("variants", stringifiedVariants); - body.append("metadata", stringifiedMetadata); - body.append("image", bunFile); - - const response = await app.handle( - new Request("http://localhost/product-complex", { - method: "POST", - body, - }), - ); - const data = (await response.json()) as ValidationError; - expect(response.status).toBe(422); - expect(data.type).toBe("validation"); - }); - - it("should return validation error when variants is not a valid JSON string", async () => { - const stringifiedMetadata = JSON.stringify(newProductComplex.metadata); - - const body = new FormData(); - body.append("name", newProductComplex.name); - body.append("variants", "not-valid-json"); - body.append("metadata", stringifiedMetadata); - body.append("image", bunFile); - - const response = await app.handle( - new Request("http://localhost/product-complex", { - method: "POST", - body, - }), - ); - const data = (await response.json()) as ValidationError; - expect(response.status).toBe(422); - expect(data.type).toBe("validation"); - }); - - it("should return validation error when metadata is not a valid JSON string", async () => { - const stringifiedVariants = JSON.stringify(newProductComplex.variants); - - const body = new FormData(); - body.append("name", newProductComplex.name); - body.append("variants", stringifiedVariants); - body.append("metadata", "not-valid-json"); - body.append("image", bunFile); - - const response = await app.handle( - new Request("http://localhost/product-complex", { - method: "POST", - body, - }), - ); - const data = (await response.json()) as ValidationError; - expect(response.status).toBe(422); - expect(data.type).toBe("validation"); - }); - }); - - describe("Nested FormData with optional t.ArrayString and t.ObjectString (PATCH operation)", async () => { - const bunFilePath4 = `${import.meta.dir}/../images/aris-yuzu.jpg`; - const bunFile = Bun.file(bunFilePath4) as File; - - it("PATCH with bunFile and omitted optional t.ObjectString and t.ArrayString", async () => { - const body = new FormData(); - body.append("name", "Updated Product Complex"); - body.append("image", bunFile); - // metadata and variants fields are omitted (should be OK since they're optional) - - const response = await app.handle( - new Request("http://localhost/product-complex/456", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as patchProductModelComplex; - expect(data).not.toBeNull(); - expect(data?.name).toBe("Updated Product Complex"); - expect(data?.metadata).toBeUndefined(); - expect(data?.variants).toBeUndefined(); - }); - - it("PATCH with file and valid t.ObjectString and t.ArrayString data", async () => { - const body = new FormData(); - body.append("name", "Updated Product Complex"); - body.append("image", bunFile); - body.append( - "metadata", - JSON.stringify({ - category: "Electronics", - tags: ["sale", "new"], - inStock: true, - }), - ); - body.append( - "variants", - JSON.stringify([ - { - price: 15, - weight: 200, - }, - ]), - ); - - const response = await app.handle( - new Request("http://localhost/product-complex/456", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as patchProductModelComplex; - expect(data).not.toBeNull(); - expect(data?.name).toBe("Updated Product Complex"); - expect(data?.metadata).toEqual({ - category: "Electronics", - tags: ["sale", "new"], - inStock: true, - }); - expect(data?.variants).toEqual([ + { + price: 2.7, + weight: 32 + } + ], + metadata: { + category: 'Electronics', + tags: ['new', 'featured', 'sale'], + inStock: true + }, + image: bunFile + } + + it('should create a product with t.ArrayString and t.ObjectString', async () => { + const stringifiedVariants = JSON.stringify(newProductComplex.variants) + const stringifiedMetadata = JSON.stringify(newProductComplex.metadata) + + const body = new FormData() + body.append('name', newProductComplex.name) + body.append('variants', stringifiedVariants) + body.append('metadata', stringifiedMetadata) + body.append('image', bunFile) + + const response = await app.handle( + new Request('http://localhost/product-complex', { + method: 'POST', + body + }) + ) + expect(response.status).toBe(201) + const data = await response.json() + expect(data).toEqual(newProductComplex) + }) + + it('should return validation error on invalid t.ArrayString nested structure', async () => { + const stringifiedVariants = JSON.stringify([ + { + price: 23, + weightTypo: 100 // Wrong property name + } + ]) + const stringifiedMetadata = JSON.stringify(newProductComplex.metadata) + + const body = new FormData() + body.append('name', newProductComplex.name) + body.append('variants', stringifiedVariants) + body.append('metadata', stringifiedMetadata) + body.append('image', bunFile) + + const response = await app.handle( + new Request('http://localhost/product-complex', { + method: 'POST', + body + }) + ) + const data = (await response.json()) as ValidationError + expect(response.status).toBe(422) + expect(data.type).toBe('validation') + }) + + it('should return validation error on invalid t.ObjectString nested structure', async () => { + const stringifiedVariants = JSON.stringify(newProductComplex.variants) + const stringifiedMetadata = JSON.stringify({ + categoryTypo: 'Electronics', // Wrong property name + tags: ['new', 'featured', 'sale'], + inStock: true + }) + + const body = new FormData() + body.append('name', newProductComplex.name) + body.append('variants', stringifiedVariants) + body.append('metadata', stringifiedMetadata) + body.append('image', bunFile) + + const response = await app.handle( + new Request('http://localhost/product-complex', { + method: 'POST', + body + }) + ) + const data = (await response.json()) as ValidationError + expect(response.status).toBe(422) + expect(data.type).toBe('validation') + }) + + it('should return validation error when variants is not a valid JSON string', async () => { + const stringifiedMetadata = JSON.stringify(newProductComplex.metadata) + + const body = new FormData() + body.append('name', newProductComplex.name) + body.append('variants', 'not-valid-json') + body.append('metadata', stringifiedMetadata) + body.append('image', bunFile) + + const response = await app.handle( + new Request('http://localhost/product-complex', { + method: 'POST', + body + }) + ) + const data = (await response.json()) as ValidationError + expect(response.status).toBe(422) + expect(data.type).toBe('validation') + }) + + it('should return validation error when metadata is not a valid JSON string', async () => { + const stringifiedVariants = JSON.stringify(newProductComplex.variants) + + const body = new FormData() + body.append('name', newProductComplex.name) + body.append('variants', stringifiedVariants) + body.append('metadata', 'not-valid-json') + body.append('image', bunFile) + + const response = await app.handle( + new Request('http://localhost/product-complex', { + method: 'POST', + body + }) + ) + const data = (await response.json()) as ValidationError + expect(response.status).toBe(422) + expect(data.type).toBe('validation') + }) +}) + +describe('Nested FormData with optional t.ArrayString and t.ObjectString (PATCH operation)', async () => { + const bunFilePath4 = `${import.meta.dir}/../images/aris-yuzu.jpg` + const bunFile = Bun.file(bunFilePath4) as File + + it('PATCH with bunFile and omitted optional t.ObjectString and t.ArrayString', async () => { + const body = new FormData() + body.append('name', 'Updated Product Complex') + body.append('image', bunFile) + // metadata and variants fields are omitted (should be OK since they're optional) + + const response = await app.handle( + new Request('http://localhost/product-complex/456', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(200) + const data = (await response.json()) as patchProductModelComplex + expect(data).not.toBeNull() + expect(data?.name).toBe('Updated Product Complex') + expect(data?.metadata).toBeUndefined() + expect(data?.variants).toBeUndefined() + }) + + it('PATCH with file and valid t.ObjectString and t.ArrayString data', async () => { + const body = new FormData() + body.append('name', 'Updated Product Complex') + body.append('image', bunFile) + body.append( + 'metadata', + JSON.stringify({ + category: 'Electronics', + tags: ['sale', 'new'], + inStock: true + }) + ) + body.append( + 'variants', + JSON.stringify([ { price: 15, - weight: 200, + weight: 200 + } + ]) + ) + + const response = await app.handle( + new Request('http://localhost/product-complex/456', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(200) + const data = (await response.json()) as patchProductModelComplex + expect(data).not.toBeNull() + expect(data?.name).toBe('Updated Product Complex') + expect(data?.metadata).toEqual({ + category: 'Electronics', + tags: ['sale', 'new'], + inStock: true + }) + expect(data?.variants).toEqual([ + { + price: 15, + weight: 200 + } + ]) + }) + + it('PATCH without file and omitted optional fields', async () => { + const response = await app.handle( + new Request('http://localhost/product-complex/456', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' }, - ]); - }); - - it("PATCH without file and omitted optional fields", async () => { - const response = await app.handle( - new Request("http://localhost/product-complex/456", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "Updated Product Complex", - }), - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as patchProductModelComplex; - expect(data).not.toBeNull(); - expect(data?.name).toBe("Updated Product Complex"); - expect(data?.image).toBeUndefined(); - expect(data?.metadata).toBeUndefined(); - expect(data?.variants).toBeUndefined(); - }); - - it("PATCH should return validation error on invalid t.ObjectString", async () => { - const body = new FormData(); - body.append("name", "Updated Product Complex"); - body.append("image", bunFile); - body.append( - "metadata", - JSON.stringify({ - categoryTypo: "Electronics", // Wrong property name - tags: ["sale"], - inStock: true, - }), - ); - - const response = await app.handle( - new Request("http://localhost/product-complex/456", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(422); - const data = (await response.json()) as ValidationError; - expect(data.type).toBe("validation"); - }); - - it("PATCH should return validation error on invalid t.ArrayString", async () => { - const body = new FormData(); - body.append("name", "Updated Product Complex"); - body.append("image", bunFile); - body.append( - "variants", - JSON.stringify([ - { - priceTypo: 15, // Wrong property name - weight: 200, - }, - ]), - ); - - const response = await app.handle( - new Request("http://localhost/product-complex/456", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(422); - const data = (await response.json()) as ValidationError; - expect(data.type).toBe("validation"); - }); - - it("PATCH should return validation error when metadata is not valid JSON", async () => { - const body = new FormData(); - body.append("name", "Updated Product Complex"); - body.append("image", bunFile); - body.append("metadata", "invalid-json"); - - const response = await app.handle( - new Request("http://localhost/product-complex/456", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(422); - const data = (await response.json()) as ValidationError; - expect(data.type).toBe("validation"); - }); - - it("PATCH should return validation error when variants is not valid JSON", async () => { - const body = new FormData(); - body.append("name", "Updated Product Complex"); - body.append("image", bunFile); - body.append("variants", "invalid-json"); - - const response = await app.handle( - new Request("http://localhost/product-complex/456", { - method: "PATCH", - body, - }), - ); - - expect(response.status).toBe(422); - const data = (await response.json()) as ValidationError; - expect(data.type).toBe("validation"); - }); - }); - - describe("Model reference with File and nested Object", () => { - const bunFilePath5 = `${import.meta.dir}/../images/aris-yuzu.jpg`; - const bunFile = Bun.file(bunFilePath5) as File; - - it("should coerce nested Object to ObjectString when using model reference", async () => { - const app = new Elysia() - .model( - "userWithAvatar", - t.Object({ - name: t.String(), - avatar: t.File(), - metadata: t.Object({ - age: t.Number(), - }), - }), - ) - .post("/user", ({ body }) => body, { - body: "userWithAvatar", - }); - - const formData = new FormData(); - formData.append("name", "John"); - formData.append("avatar", bunFile); - formData.append("metadata", JSON.stringify({ age: 25 })); - - const response = await app.handle( - new Request("http://localhost/user", { - method: "POST", - body: formData, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as any; - expect(data.name).toBe("John"); - expect(data.metadata).toEqual({ age: 25 }); - }); - }); - - describe.skip("Zod (for standard schema) with File and nested Object", () => { - const bunFilePath6 = `${import.meta.dir}/../images/aris-yuzu.jpg`; - const bunFile = Bun.file(bunFilePath6) as File; - - it("should handle Zod schema with File and nested object (without manual coercion)", async () => { - const app = new Elysia({ aot }).post("/upload", ({ body }) => body, { - body: z.object({ - name: z.string(), - file: z.file().refine((file) => fileType(file, "image/jpeg")), - metadata: z.object({ - age: z.number(), - }), - }), - }); - - const formData = new FormData(); - formData.append("name", "John"); - formData.append("file", bunFile); - formData.append("metadata", JSON.stringify({ age: 25 })); - - const response = await app.handle( - new Request("http://localhost/upload", { - method: "POST", - body: formData, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as any; - expect(data.name).toBe("John"); - expect(data.metadata).toEqual({ age: 25 }); - }); - - it("should handle array JSON strings in FormData", async () => { - const app = new Elysia({ aot }).post("/upload", ({ body }) => body, { - body: z.object({ - file: z.file().refine((file) => fileType(file, "image/jpeg")), - tags: z.array(z.string()), - }), - }); - - const formData = new FormData(); - formData.append("file", bunFile); - formData.append("tags", JSON.stringify(["tag1", "tag2", "tag3"])); - - const response = await app.handle( - new Request("http://localhost/upload", { - method: "POST", - body: formData, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as any; - expect(data.tags).toEqual(["tag1", "tag2", "tag3"]); - }); - - it("should keep invalid JSON as string", async () => { - const app = new Elysia({ aot }).post("/upload", ({ body }) => body, { - body: z.object({ - file: z.file().refine((file) => fileType(file, "image/jpeg")), - description: z.string(), - }), - }); - - const formData = new FormData(); - formData.append("file", bunFile); - formData.append("description", "{invalid json}"); - - const response = await app.handle( - new Request("http://localhost/upload", { - method: "POST", - body: formData, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as any; - expect(data.description).toBe("{invalid json}"); - }); - - it("should keep plain strings that are not JSON", async () => { - const app = new Elysia({ aot }).post("/upload", ({ body }) => body, { - body: z.object({ - file: z.file().refine((file) => fileType(file, "image/jpeg")), - comment: z.string(), - }), - }); - - const formData = new FormData(); - formData.append("file", bunFile); - formData.append("comment", "This is a plain comment"); - - const response = await app.handle( - new Request("http://localhost/upload", { - method: "POST", - body: formData, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as any; - expect(data.comment).toBe("This is a plain comment"); - }); - - it("should handle nested objects in JSON", async () => { - const app = new Elysia({ aot }).post("/upload", ({ body }) => body, { - body: z.object({ - file: z.file().refine((file) => fileType(file, "image/jpeg")), - profile: z.object({ - user: z.object({ - name: z.string(), - age: z.number(), - }), - settings: z.object({ - notifications: z.boolean(), - }), - }), - }), - }); - - const formData = new FormData(); - formData.append("file", bunFile); - formData.append( - "profile", - JSON.stringify({ - user: { name: "Alice", age: 30 }, - settings: { notifications: true }, - }), - ); - - const response = await app.handle( - new Request("http://localhost/upload", { - method: "POST", - body: formData, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as any; - expect(data.profile).toEqual({ - user: { name: "Alice", age: 30 }, - settings: { notifications: true }, - }); - }); - - it("should handle Zod schema with optional fields", async () => { - const app = new Elysia({ aot }).post("/upload", ({ body }) => body, { - body: z.object({ - file: z.file().refine((file) => fileType(file, "image/jpeg")), - name: z.string(), - description: z.string().optional(), - metadata: z - .object({ - category: z.string(), - tags: z.array(z.string()).optional(), - featured: z.boolean().optional(), - }) - .optional(), - }), - }); - - const formData = new FormData(); - formData.append("file", bunFile); - formData.append("name", "Test Product"); - // Omit optional fields - - const response = await app.handle( - new Request("http://localhost/upload", { - method: "POST", - body: formData, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as any; - expect(data.name).toBe("Test Product"); - expect(data.description).toBeUndefined(); - expect(data.metadata).toBeUndefined(); - }); - - it("should handle Zod schema with optional fields provided", async () => { - const app = new Elysia({ aot }).post("/upload", ({ body }) => body, { - body: z.object({ - file: z.file().refine((file) => fileType(file, "image/jpeg")), - name: z.string(), - description: z.string().optional(), - metadata: z - .object({ - category: z.string(), - tags: z.array(z.string()).optional(), - featured: z.boolean().optional(), - }) - .optional(), - }), - }); - - const formData = new FormData(); - formData.append("file", bunFile); - formData.append("name", "Test Product"); - formData.append("description", "A test description"); - formData.append( - "metadata", - JSON.stringify({ - category: "electronics", - tags: ["phone", "mobile"], - featured: true, - }), - ); - - const response = await app.handle( - new Request("http://localhost/upload", { - method: "POST", - body: formData, - }), - ); - - expect(response.status).toBe(200); - const data = (await response.json()) as any; - expect(data.name).toBe("Test Product"); - expect(data.description).toBe("A test description"); - expect(data.metadata).toEqual({ - category: "electronics", - tags: ["phone", "mobile"], - featured: true, - }); - }); - }); -}); + body: JSON.stringify({ + name: 'Updated Product Complex' + }) + }) + ) + + expect(response.status).toBe(200) + const data = (await response.json()) as patchProductModelComplex + expect(data).not.toBeNull() + expect(data?.name).toBe('Updated Product Complex') + expect(data?.image).toBeUndefined() + expect(data?.metadata).toBeUndefined() + expect(data?.variants).toBeUndefined() + }) + + it('PATCH should return validation error on invalid t.ObjectString', async () => { + const body = new FormData() + body.append('name', 'Updated Product Complex') + body.append('image', bunFile) + body.append( + 'metadata', + JSON.stringify({ + categoryTypo: 'Electronics', // Wrong property name + tags: ['sale'], + inStock: true + }) + ) + + const response = await app.handle( + new Request('http://localhost/product-complex/456', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(422) + const data = (await response.json()) as ValidationError + expect(data.type).toBe('validation') + }) + + it('PATCH should return validation error on invalid t.ArrayString', async () => { + const body = new FormData() + body.append('name', 'Updated Product Complex') + body.append('image', bunFile) + body.append( + 'variants', + JSON.stringify([ + { + priceTypo: 15, // Wrong property name + weight: 200 + } + ]) + ) + + const response = await app.handle( + new Request('http://localhost/product-complex/456', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(422) + const data = (await response.json()) as ValidationError + expect(data.type).toBe('validation') + }) + + it('PATCH should return validation error when metadata is not valid JSON', async () => { + const body = new FormData() + body.append('name', 'Updated Product Complex') + body.append('image', bunFile) + body.append('metadata', 'invalid-json') + + const response = await app.handle( + new Request('http://localhost/product-complex/456', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(422) + const data = (await response.json()) as ValidationError + expect(data.type).toBe('validation') + }) + + it('PATCH should return validation error when variants is not valid JSON', async () => { + const body = new FormData() + body.append('name', 'Updated Product Complex') + body.append('image', bunFile) + body.append('variants', 'invalid-json') + + const response = await app.handle( + new Request('http://localhost/product-complex/456', { + method: 'PATCH', + body + }) + ) + + expect(response.status).toBe(422) + const data = (await response.json()) as ValidationError + expect(data.type).toBe('validation') + }) +}) + +describe('Model reference with File and nested Object', () => { + const bunFilePath5 = `${import.meta.dir}/../images/aris-yuzu.jpg` + const bunFile = Bun.file(bunFilePath5) as File + + it('should coerce nested Object to ObjectString when using model reference', async () => { + const app = new Elysia() + .model( + 'userWithAvatar', + t.Object({ + name: t.String(), + avatar: t.File(), + metadata: t.Object({ + age: t.Number() + }) + }) + ) + .post('/user', ({ body }) => body, { + body: 'userWithAvatar' + }) + + const formData = new FormData() + formData.append('name', 'John') + formData.append('avatar', bunFile) + formData.append('metadata', JSON.stringify({ age: 25 })) + + const response = await app.handle( + new Request('http://localhost/user', { + method: 'POST', + body: formData + }) + ) + + expect(response.status).toBe(200) + const data = (await response.json()) as any + expect(data.name).toBe('John') + expect(data.metadata).toEqual({ age: 25 }) + }) +}) + +// describe.skip('Zod (for standard schema) with File and nested Object', () => { +// const bunFilePath6 = `test/images/aris-yuzu.jpg` +// const bunFile = Bun.file(bunFilePath6) as File + +// it('should handle Zod schema with File and nested object (without manual coercion)', async () => { +// const app = new Elysia().post('/upload', ({ body }) => body, { +// body: z.object({ +// name: z.string(), +// file: z.file().refine((file) => fileType(file, 'image/jpeg')), +// metadata: z.object({ +// age: z.number() +// }) +// }) +// }) + +// const formData = new FormData() +// formData.append('name', 'John') +// formData.append('file', bunFile) +// formData.append('metadata', JSON.stringify({ age: 25 })) + +// const response = await app.handle( +// new Request('http://localhost/upload', { +// method: 'POST', +// body: formData +// }) +// ) + +// expect(response.status).toBe(200) +// const data = (await response.json()) as any +// expect(data.name).toBe('John') +// expect(data.metadata).toEqual({ age: 25 }) +// }) + +// it('should handle array JSON strings in FormData', async () => { +// const app = new Elysia().post('/upload', ({ body }) => body, { +// body: z.object({ +// file: z.file().refine((file) => fileType(file, 'image/jpeg')), +// tags: z.array(z.string()) +// }) +// }) + +// const formData = new FormData() +// formData.append('file', bunFile) +// formData.append('tags', JSON.stringify(['tag1', 'tag2', 'tag3'])) + +// const response = await app.handle( +// new Request('http://localhost/upload', { +// method: 'POST', +// body: formData +// }) +// ) + +// expect(response.status).toBe(200) +// const data = (await response.json()) as any +// expect(data.tags).toEqual(['tag1', 'tag2', 'tag3']) +// }) + +// it('should keep invalid JSON as string', async () => { +// const app = new Elysia().post('/upload', ({ body }) => body, { +// body: z.object({ +// file: z.file().refine((file) => fileType(file, 'image/jpeg')), +// description: z.string() +// }) +// }) + +// const formData = new FormData() +// formData.append('file', bunFile) +// formData.append('description', '{invalid json}') + +// const response = await app.handle( +// new Request('http://localhost/upload', { +// method: 'POST', +// body: formData +// }) +// ) + +// expect(response.status).toBe(200) +// const data = (await response.json()) as any +// expect(data.description).toBe('{invalid json}') +// }) + +// it('should keep plain strings that are not JSON', async () => { +// const app = new Elysia().post('/upload', ({ body }) => body, { +// body: z.object({ +// file: z.file().refine((file) => fileType(file, 'image/jpeg')), +// comment: z.string() +// }) +// }) + +// const formData = new FormData() +// formData.append('file', bunFile) +// formData.append('comment', 'This is a plain comment') + +// const response = await app.handle( +// new Request('http://localhost/upload', { +// method: 'POST', +// body: formData +// }) +// ) + +// expect(response.status).toBe(200) +// const data = (await response.json()) as any +// expect(data.comment).toBe('This is a plain comment') +// }) + +// it('should handle nested objects in JSON', async () => { +// const app = new Elysia().post('/upload', ({ body }) => body, { +// body: z.object({ +// file: z.file().refine((file) => fileType(file, 'image/jpeg')), +// profile: z.object({ +// user: z.object({ +// name: z.string(), +// age: z.number() +// }), +// settings: z.object({ +// notifications: z.boolean() +// }) +// }) +// }) +// }) + +// const formData = new FormData() +// formData.append('file', bunFile) +// formData.append( +// 'profile', +// JSON.stringify({ +// user: { name: 'Alice', age: 30 }, +// settings: { notifications: true } +// }) +// ) + +// const response = await app.handle( +// new Request('http://localhost/upload', { +// method: 'POST', +// body: formData +// }) +// ) + +// expect(response.status).toBe(200) +// const data = (await response.json()) as any +// expect(data.profile).toEqual({ +// user: { name: 'Alice', age: 30 }, +// settings: { notifications: true } +// }) +// }) + +// it('should handle Zod schema with optional fields', async () => { +// const app = new Elysia().post('/upload', ({ body }) => body, { +// body: z.object({ +// file: z.file().refine((file) => fileType(file, 'image/jpeg')), +// name: z.string(), +// description: z.string().optional(), +// metadata: z +// .object({ +// category: z.string(), +// tags: z.array(z.string()).optional(), +// featured: z.boolean().optional() +// }) +// .optional() +// }) +// }) + +// const formData = new FormData() +// formData.append('file', bunFile) +// formData.append('name', 'Test Product') +// // Omit optional fields + +// const response = await app.handle( +// new Request('http://localhost/upload', { +// method: 'POST', +// body: formData +// }) +// ) + +// expect(response.status).toBe(200) +// const data = (await response.json()) as any +// expect(data.name).toBe('Test Product') +// expect(data.description).toBeUndefined() +// expect(data.metadata).toBeUndefined() +// }) + +// it('should handle Zod schema with optional fields provided', async () => { +// const app = new Elysia().post('/upload', ({ body }) => body, { +// body: z.object({ +// file: z.file().refine((file) => fileType(file, 'image/jpeg')), +// name: z.string(), +// description: z.string().optional(), +// metadata: z +// .object({ +// category: z.string(), +// tags: z.array(z.string()).optional(), +// featured: z.boolean().optional() +// }) +// .optional() +// }) +// }) + +// const formData = new FormData() +// formData.append('file', bunFile) +// formData.append('name', 'Test Product') +// formData.append('description', 'A test description') +// formData.append( +// 'metadata', +// JSON.stringify({ +// category: 'electronics', +// tags: ['phone', 'mobile'], +// featured: true +// }) +// ) + +// const response = await app.handle( +// new Request('http://localhost/upload', { +// method: 'POST', +// body: formData +// }) +// ) + +// expect(response.status).toBe(200) +// const data = (await response.json()) as any +// expect(data.name).toBe('Test Product') +// expect(data.description).toBe('A test description') +// expect(data.metadata).toEqual({ +// category: 'electronics', +// tags: ['phone', 'mobile'], +// featured: true +// }) +// }) +// }) diff --git a/test/types/index.ts b/test/types/index.ts index 56d97982..3ea20c9a 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -2922,3 +2922,33 @@ type a = keyof {} ? true : false = true } + +// group empty prefix +{ + const app = new Elysia() + .group('', (app) => { + return app.get('/ok', () => 'Hello World') + }) + .listen(3000) + + type Routes = keyof (typeof app)['~Routes'] + + expectTypeOf().toEqualTypeOf<'ok'>() +} + +// override group prefix type +{ + new Elysia().group('/:example', (app) => + app.get( + '/', + ({ params: { example } }) => { + expectTypeOf().toBeNumber() + }, + { + params: t.Object({ + example: t.Numeric() + }) + } + ) + ) +} diff --git a/test/types/utils.ts b/test/types/utils.ts new file mode 100644 index 00000000..bc56bd73 --- /dev/null +++ b/test/types/utils.ts @@ -0,0 +1,17 @@ +import { t, getSchemaValidator } from '../../src' +import { expectTypeOf } from 'expect-type' + +// schema validator +{ + const schema = t.Object({ + id: t.Number(), + name: t.String() + }) + + const validator = getSchemaValidator(schema) + const result = validator.safeParse({ id: 1, name: 'test' }) + + if (result.success) { + expectTypeOf(result.data).toEqualTypeOf<{ id: number; name: string }>() + } +}