diff --git a/CHANGELOG.md b/CHANGELOG.md index eef9b71a..613205e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,49 @@ +# 0.2.0-rc.1 - 24 Jan 2023 +Improvement: +- Map OpenAPI's schema detail on response +- Fix Type instantiation is excessively deep and possibly infinite +- Improve TypeScript inference time by removing recursive type in generic +- Inferred body is never instead of unknown + +# 0.2.0-rc.0 - 23 Jan 2023 +Feature: +- Add support for reference model via `.setModel` +- Add support for OpenAPI's `definitions` field + +# 0.2.0-beta.2 - 22 Jan 2023 +Feature: +- Add support for custom openapi field using `schema.detail` +- Add support for custom code for `response` + +Improvement: +- Unioned status type for response +- Optimize TypeScript inference performance + +# 0.2.0-beta.1 - 22 Jan 2023 +Breaking Change: +- `onParse` now accepts `(context: PreContext, contentType: string)` instead of `(request: Request, contentType: string)` + - To migrate, add `.request` to context to access `Request` + +Feature: +- `onRequest` and `onParse` now can access `PreContext` +- Support `application/x-www-form-urlencoded` by default + +Improvement: +- body parser now parse `content-type` with extra attribute eg. `application/json;charset=utf-8` + +# 0.2.0-beta.0 - 17 Jan 2023 +Feature: +- Support for Async / lazy-load plugin + +Improvement: +- Decode URI parameter path parameter +- Handle union type correctly + +# 0.1.3 - 12 Jan 2023 +Improvement: +- Validate `Response` object +- Union type inference on response + # 0.1.2 - 31 Dec 2022 Bug fix: - onRequest doesn't run in `group` and `guard` diff --git a/bun.lockb b/bun.lockb index 0fde6338..0fe53c1d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/example/a.ts b/example/a.ts new file mode 100644 index 00000000..fc8f81f8 --- /dev/null +++ b/example/a.ts @@ -0,0 +1,37 @@ +import { Elysia, SCHEMA, t } from '../src' + +const app = new Elysia() + .get('/', () => 'Elysia') + .post('/', () => 'Elysia') + .get('/id/:id', () => 1) + .post('/mirror', ({ body }) => body, { + schema: { + query: t.Object({ + n: t.String() + }), + body: t.Object( + { + username: t.String(), + password: t.String() + }, + { + description: 'An expected body' + } + ), + detail: { + summary: 'Sign in the user', + tags: ['authentication'] + } + } + }) + .get('/sign-in', ({ body }) => 'ok') + .get('/products/nendoroid/skadi', ({ body }) => 1) + .get('/id2/:id', ({ params }) => 1, { + schema: { + detail: { + summary: 'a' + } + } + }) + +type App = typeof app['store'][typeof SCHEMA]['/mirror']['POST']['body'] diff --git a/example/body.ts b/example/body.ts index a142ecb1..6751efc2 100644 --- a/example/body.ts +++ b/example/body.ts @@ -2,7 +2,7 @@ import { Elysia, t } from '../src' const app = new Elysia() // Add custom body parser - .onParse(async (request, contentType) => { + .onParse(async ({ request }, contentType) => { switch (contentType) { case 'application/Elysia': return request.text() @@ -25,7 +25,10 @@ const app = new Elysia() body: t.Object({ id: t.Number(), username: t.String() - }) + }), + detail: { + summary: 'A' + } } }) .post('/mirror', ({ body }) => body) diff --git a/example/lazy-module.ts b/example/lazy-module.ts new file mode 100644 index 00000000..b71855c8 --- /dev/null +++ b/example/lazy-module.ts @@ -0,0 +1,19 @@ +import { Elysia, SCHEMA } from '../src' + +const plugin = (app: Elysia) => app.get('/plugin', () => 'Plugin') +const asyncPlugin = async (app: Elysia) => app.get('/async', () => 'A') + +const app = new Elysia() + .decorate('a', () => 'hello') + .use(plugin) + .use(asyncPlugin) + .use(import('./lazy')) + .use((app) => app.get('/inline', () => 'inline')) + .get('/', ({ a }) => a()) + .listen(3000) + +await app.modules + +console.log( + `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` +) diff --git a/example/lazy/index.ts b/example/lazy/index.ts new file mode 100644 index 00000000..ab17b3b7 --- /dev/null +++ b/example/lazy/index.ts @@ -0,0 +1,5 @@ +import Elysia from "../../src"; + +export const lazy = (app: Elysia) => app.get('/lazy', () => 'Hi') + +export default lazy diff --git a/example/schema.ts b/example/schema.ts index 4b451a8c..8966d6ca 100644 --- a/example/schema.ts +++ b/example/schema.ts @@ -1,14 +1,18 @@ -import { Elysia, t } from '../src' +import { Elysia, t, SCHEMA, DEFS } from '../src' const app = new Elysia() - // Strictly validate response - .get('/', () => 'hi', { - schema: { + .setModel({ + a: t.Object({ response: t.String() - } + }), + b: t.Object({ + response: t.Number() + }) }) + // Strictly validate response + .get('/', () => 'hi') // Strictly validate body and response - .post('/', ({ body }) => body.id, { + .post('/', ({ body, query }) => body.id, { schema: { body: t.Object({ id: t.Number(), @@ -16,8 +20,7 @@ const app = new Elysia() profile: t.Object({ name: t.String() }) - }), - response: t.Number() + }) } }) // Strictly validate query, params, and body @@ -28,7 +31,13 @@ const app = new Elysia() }), params: t.Object({ id: t.String() - }) + }), + response: { + 200: t.String(), + 300: t.Object({ + error: t.String() + }) + } } }) .guard( @@ -53,7 +62,7 @@ const app = new Elysia() schema: { params: t.Object({ id: t.Number() - }), + }) }, transform: ({ params }) => { params.id = +params.id @@ -62,3 +71,8 @@ const app = new Elysia() ) ) .listen(8080) + +type A = typeof app['store'][typeof SCHEMA]['/']['POST']['query'] +type B = typeof app['store'][typeof DEFS] + +// const a = app.getModel('b') diff --git a/package.json b/package.json index d2a02a92..d4f45acf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "elysia", "description": "Fast, and friendly Bun web framework", - "version": "0.1.2", + "version": "0.2.0", "author": { "name": "saltyAom", "url": "https://github.com/SaltyAom", @@ -35,15 +35,16 @@ "release": "npm run build && npm run test && npm publish" }, "dependencies": { - "@sinclair/typebox": "0.25.10" + "@sinclair/typebox": "0.25.21", + "openapi-types": "^12.1.0" }, "devDependencies": { - "@types/node": "^18.11.10", - "@typescript-eslint/eslint-plugin": "^5.45.0", - "@typescript-eslint/parser": "^5.45.0", - "bun-types": "^0.3.0", - "eslint": "^8.29.0", + "@types/node": "^18.11.18", + "@typescript-eslint/eslint-plugin": "^5.48.2", + "@typescript-eslint/parser": "^5.48.2", + "bun-types": "^0.5.0", + "eslint": "^8.32.0", "rimraf": "^3.0.2", - "typescript": "^4.9.3" + "typescript": "^4.9.4" } } \ No newline at end of file diff --git a/src/context.ts b/src/context.ts index ecd11777..27cca929 100644 --- a/src/context.ts +++ b/src/context.ts @@ -21,7 +21,7 @@ export interface Context< } // Use to mimic request before mapping route -export type PreContext = Omit< - Context<{}, Store>, - 'query' | 'params' | 'body' -> +export type PreContext< + Route extends TypedRoute = TypedRoute, + Store extends Elysia['store'] = Elysia['store'] +> = Omit, 'query' | 'params' | 'body'> diff --git a/src/index.ts b/src/index.ts index a499f70a..e9414ffa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,10 @@ import { mergeHook, mergeDeep, createValidationError, + getSchemaValidator, SCHEMA, - getSchemaValidator + DEFS, + getResponseSchemaValidator } from './utils' import { registerSchemaPath } from './schema' import { mapErrorCode, mapErrorStatus } from './error' @@ -42,8 +44,11 @@ import type { MergeSchema, ListenCallback, NoReturnHandler, - ElysiaRoute + ElysiaRoute, + MaybePromise, + IsNever } from './types' +import { type TSchema } from '@sinclair/typebox' /** * ### Elysia Server @@ -63,7 +68,8 @@ export default class Elysia { config: ElysiaConfig store: Instance['store'] = { - [SCHEMA]: {} + [SCHEMA]: {}, + [DEFS]: {} } // Will be applied to Context protected decorators: Record | null = null @@ -84,9 +90,11 @@ export default class Elysia { private router = new Router() // This router is fallback for catch all route - private fallbackRoute: Partial>> = {} + private fallbackRoute: Partial> = {} protected routes: InternalRoute[] = [] + private lazyLoadModules: Promise[] = [] + constructor(config: Partial = {}) { this.config = { strictPath: false, @@ -95,7 +103,7 @@ export default class Elysia { } private _addHandler< - Schema extends TypedSchema = TypedSchema, + Schema extends TypedSchema, Path extends string = string >( method: HTTPMethod, @@ -112,29 +120,36 @@ export default class Elysia { hooks: mergeHook(clone(this.event), hook as RegisteredHook) }) + const defs = this.store[DEFS] + const body = getSchemaValidator( - hook?.schema?.body ?? this.$schema?.body + hook?.schema?.body ?? this.$schema?.body, + defs ) const header = getSchemaValidator( hook?.schema?.headers ?? this.$schema?.headers, + defs, true ) const params = getSchemaValidator( - hook?.schema?.params ?? this.$schema?.params + hook?.schema?.params ?? this.$schema?.params, + defs ) const query = getSchemaValidator( - hook?.schema?.query ?? this.$schema?.query + hook?.schema?.query ?? this.$schema?.query, + defs ) - const response = getSchemaValidator( - hook?.schema?.response ?? this.$schema?.response + const response = getResponseSchemaValidator( + hook?.schema?.response ?? this.$schema?.response, + defs ) registerSchemaPath({ - // @ts-ignore schema: this.store[SCHEMA], hook, method, - path + path, + models: this.store[DEFS] }) const validator = @@ -204,7 +219,9 @@ export default class Elysia { * }) * ``` */ - onRequest(handler: BeforeRequestHandler) { + onRequest( + handler: BeforeRequestHandler + ) { this.event.request.push(handler) return this @@ -229,7 +246,7 @@ export default class Elysia { * }) * ``` */ - onParse(parser: BodyParser) { + onParse(parser: BodyParser) { this.event.parse.splice(this.event.parse.length - 1, 0, parser) return this @@ -562,21 +579,57 @@ export default class Elysia { * ``` */ use< - NewElysia extends Elysia = Elysia, - Params extends Elysia = Elysia + NewElysia extends MaybePromise> = Elysia, + Params extends Elysia = Elysia, + LazyLoadElysia extends never | ElysiaInstance = never >( - plugin: ( - app: Params extends Elysia - ? IsAny extends true - ? this - : Params - : Params - ) => NewElysia - ): NewElysia extends Elysia + plugin: + | MaybePromise< + ( + app: Params extends Elysia + ? IsAny extends true + ? this + : Params + : Params + ) => MaybePromise + > + | Promise<{ + default: ( + elysia: Elysia + ) => MaybePromise> + }> + ): IsNever extends false + ? Elysia + : NewElysia extends Elysia + ? IsNever extends true + ? Elysia + : Elysia + : NewElysia extends Promise> ? Elysia : this { - // ? Type enforce on function already - return plugin(this as unknown as any) as unknown as any + if (plugin instanceof Promise) { + this.lazyLoadModules.push( + plugin.then((plugin) => { + if (typeof plugin === 'function') + return plugin(this as unknown as any) as unknown as any + + return plugin.default( + this as unknown as any + ) as unknown as any + }) + ) + + return this as unknown as any + } + + const instance = plugin(this as unknown as any) as unknown as any + if (instance instanceof Promise) { + this.lazyLoadModules.push(instance) + + return this as unknown as any + } + + return instance } /** @@ -598,7 +651,11 @@ export default class Elysia { * ``` */ get< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -630,7 +687,11 @@ export default class Elysia { * ``` */ post< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -662,7 +723,11 @@ export default class Elysia { * ``` */ put< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -694,7 +759,11 @@ export default class Elysia { * ``` */ patch< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -726,7 +795,11 @@ export default class Elysia { * ``` */ delete< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -758,7 +831,11 @@ export default class Elysia { * ``` */ options< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -785,7 +862,11 @@ export default class Elysia { * ``` */ all< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -817,7 +898,11 @@ export default class Elysia { * ``` */ head< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -849,7 +934,11 @@ export default class Elysia { * ``` */ trace< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -881,7 +970,11 @@ export default class Elysia { * ``` */ connect< - Schema extends TypedSchema = {}, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Path extends string = string, Response = unknown >( @@ -913,8 +1006,12 @@ export default class Elysia { * ``` */ route< + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, Method extends HTTPMethod = HTTPMethod, - Schema extends TypedSchema = {}, Path extends string = string, Response = unknown >( @@ -956,7 +1053,9 @@ export default class Elysia { schema: Instance['schema'] }> >(name: Key, value: Value): NewInstance { - ;(this.store as Record)[name] = value + if (!(name in this.store)) { + ;(this.store as Record)[name] = value + } return this as unknown as NewInstance } @@ -983,7 +1082,7 @@ export default class Elysia { }> >(name: Name, value: Value): NewInstance { if (!this.decorators) this.decorators = {} - this.decorators[name] = value + if (!(name in this.decorators)) this.decorators[name] = value return this as unknown as NewInstance } @@ -1063,19 +1162,26 @@ export default class Elysia { * ``` */ schema< - Schema extends TypedSchema = TypedSchema, + Schema extends TypedSchema< + Exclude + > = TypedSchema< + Exclude + >, NewInstance = Elysia<{ request: Instance['request'] store: Instance['store'] schema: MergeSchema }> >(schema: Schema): NewInstance { + const defs = this.store[DEFS] + this.$schema = { - body: getSchemaValidator(schema?.body), - headers: getSchemaValidator(schema?.headers), - params: getSchemaValidator(schema?.params), - query: getSchemaValidator(schema?.query), - response: getSchemaValidator(schema?.response) + body: getSchemaValidator(schema.body, defs), + headers: getSchemaValidator(schema?.headers, defs, true), + params: getSchemaValidator(schema?.params, defs), + query: getSchemaValidator(schema?.query, defs), + // @ts-ignore + response: getSchemaValidator(schema?.response, defs) } return this as unknown as NewInstance @@ -1087,15 +1193,29 @@ export default class Elysia { headers: {} } + let context: Context + if (this.decorators) { + context = clone(this.decorators) as any as Context + + context.request = request + context.set = set + context.store = this.store + } else { + // @ts-ignore + context = { + set, + store: this.store, + request + } + } + try { for (let i = 0; i < this.event.request.length; i++) { const onRequest = this.event.request[i] - let response = onRequest({ - request, - store: this.store, - set - }) + let response = onRequest(context) if (response instanceof Promise) response = await response + + response = mapEarlyResponse(response, set) if (response) return response } @@ -1110,13 +1230,18 @@ export default class Elysia { this.fallbackRoute[request.method as HTTPMethod] if (!handler) throw new Error('NOT_FOUND') + const hooks = handler.hooks + let body: string | Record | undefined if (request.method !== 'GET') { - const contentType = request.headers.get('content-type') + let contentType = request.headers.get('content-type') if (contentType) { + const index = contentType.indexOf(';') + if (index !== -1) contentType = contentType.slice(0, index) + for (let i = 0; i < this.event.parse.length; i++) { - let temp = this.event.parse[i](request, contentType) + let temp = this.event.parse[i](context, contentType) if (temp instanceof Promise) temp = await temp if (temp) { @@ -1126,7 +1251,7 @@ export default class Elysia { } // body might be empty string thus can't use !body - if (body === undefined) + if (body === undefined) { switch (contentType) { case 'application/json': body = await request.json() @@ -1135,31 +1260,18 @@ export default class Elysia { case 'text/plain': body = await request.text() break + + case 'application/x-www-form-urlencoded': + body = mapQuery(await request.text(), null) + break } + } } } - const hooks = handler.hooks - let context: Context - - if (this.decorators) { - context = clone(this.decorators) as any as Context - - context.request = request - context.body = body - context.set = set - context.store = this.store - context.params = route?.params || {} - context.query = mapQuery(request.url, index) - } else - context = { - request, - body, - set, - store: this.store, - params: route?.params || {}, - query: mapQuery(request.url, index) - } + context.body = body + context.params = route?.params || {} + context.query = mapQuery(request.url, index) for (let i = 0; i < hooks.transform.length; i++) { const operation = hooks.transform[i](context) @@ -1328,10 +1440,14 @@ export default class Elysia { } for (let i = 0; i < this.event.start.length; i++) - this.event.start[i](this) + this.event.start[i](this as any) if (callback) callback(this.server!) + Promise.all(this.lazyLoadModules).then(() => { + Bun.gc(true) + }) + return this } @@ -1359,37 +1475,44 @@ export default class Elysia { this.server.stop() for (let i = 0; i < this.event.stop.length; i++) - await this.event.stop[i](this) + await this.event.stop[i](this as any) + } + + /** + * Wait until all lazy loaded modules all load is fully + */ + get modules() { + return Promise.all(this.lazyLoadModules) + } + + setModel>( + record: Recorder + ): Elysia<{ + store: Instance['store'] & { + [Defs in typeof DEFS]: Recorder + } + request: Instance['request'] + schema: Instance['schema'] + }> { + Object.entries(record).forEach(([key, value]) => { + // @ts-ignore + if (!(key in this.store[DEFS])) this.store[DEFS][key] = value + }) + + return this as unknown as any } } -// // Hot reload -// if (typeof Bun !== 'undefined') { -// // @ts-ignore -// if (globalThis[key]) -// // @ts-ignore -// globalThis[key].reload({ -// port, -// fetch: fe -// }) -// else { -// // @ts-ignore -// globalThis[key] = Bun.serve({ -// port, -// fetch: fe -// }) -// } -// } - -export { Elysia } +export { Elysia, Router } export { Type as t } from '@sinclair/typebox' + export { SCHEMA, + DEFS, getPath, createValidationError, getSchemaValidator } from './utils' -export { Router } from './router' export type { Context, PreContext } from './context' export type { @@ -1417,5 +1540,16 @@ export type { UnwrapSchema, LifeCycleStore, VoidLifeCycle, - SchemaValidator + SchemaValidator, + ElysiaRoute, + ExtractPath, + IsPathParameter, + IsAny, + IsNever, + UnknownFallback, + WithArray, + ObjectValues, + PickInOrder, + MaybePromise, + MergeIfNotNull } from './types' diff --git a/src/router.ts b/src/router.ts index 51a94be6..c8c8833c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -25,10 +25,15 @@ * @see https://github.com/medleyjs/router */ +import type { ComposedHandler } from '../src' import { getPath } from './utils' export interface FindResult { - store: Record + store: Partial<{ + [k in string]: ComposedHandler + }> & Partial<{ + wildcardStore?: Map | null + }> params: Record } @@ -215,6 +220,7 @@ export class Router { if (node.wildcardStore === null) node.wildcardStore = Object.create(null) + // @ts-ignore return node.wildcardStore! } @@ -298,10 +304,12 @@ function matchRoute( if (slashIndex === -1 || slashIndex >= urlLength) { if (node.parametricChild.store) { const params: Record = {} // This is much faster than using a computed property - params[node.parametricChild.paramName] = url.slice( - pathPartEndIndex, - urlLength - ) + + let paramData = url.slice(pathPartEndIndex, urlLength) + if (paramData.includes('%')) + paramData = decodeURI(paramData) + + params[node.parametricChild.paramName] = paramData return { store: node.parametricChild.store, params @@ -316,10 +324,11 @@ function matchRoute( ) if (route) { - route.params[node.parametricChild.paramName] = url.slice( - pathPartEndIndex, - slashIndex - ) + let paramData = url.slice(pathPartEndIndex, slashIndex) + if (paramData.includes('%')) + paramData = decodeURI(paramData) + + route.params[node.parametricChild.paramName] = paramData return route } diff --git a/src/schema.ts b/src/schema.ts index 9bd75ae5..bdd9963e 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,4 +1,5 @@ -import type { TSchema } from '@sinclair/typebox' +import { Kind, TSchema } from '@sinclair/typebox' +import type { OpenAPIV2 } from 'openapi-types' import type { HTTPMethod, LocalHook } from './types' @@ -8,25 +9,41 @@ export const toOpenAPIPath = (path: string) => .map((x) => (x.startsWith(':') ? `{${x.slice(1, x.length)}}` : x)) .join('/') -export const mapProperties = (name: string, schema: TSchema | undefined) => - Object.entries(schema?.properties ?? []).map(([key, value]) => ({ +export const mapProperties = ( + name: string, + schema: TSchema | string | undefined, + models: Record +) => { + if (schema === undefined) return [] + + if (typeof schema === 'string') + if (schema in models) schema = models[schema] + else throw new Error(`Can't find model ${schema}`) + + return Object.entries(schema?.properties ?? []).map(([key, value]) => ({ + // @ts-ignore + ...value, in: name, name: key, // @ts-ignore type: value?.type, + // @ts-ignore required: schema!.required?.includes(key) ?? false })) +} export const registerSchemaPath = ({ schema, path, method, - hook + hook, + models }: { - schema: Record + schema: OpenAPIV2.PathsObject path: string method: HTTPMethod - hook?: LocalHook + hook?: LocalHook + models: Record }) => { path = toOpenAPIPath(path) @@ -34,12 +51,93 @@ export const registerSchemaPath = ({ const paramsSchema = hook?.schema?.params const headerSchema = hook?.schema?.headers const querySchema = hook?.schema?.query - const responseSchema = hook?.schema?.response + let responseSchema = hook?.schema?.response + + if (typeof responseSchema === 'object') { + if (Kind in responseSchema) { + const { type, properties, required, ...rest } = + responseSchema as typeof responseSchema & { + type: string + properties: Object + required: string[] + } + + responseSchema = { + '200': { + ...rest, + schema: { + type, + properties, + required + } + } + } + } else { + Object.entries(responseSchema as Record).forEach( + ([key, value]) => { + if (typeof value === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type, properties, required, ...rest } = models[ + value + ] as TSchema & { + type: string + properties: Object + required: string[] + } + + // @ts-ignore + responseSchema[key] = { + ...rest, + schema: { + $ref: `#/definitions/${value}` + } + } + } else { + const { type, properties, required, ...rest } = + value as typeof value & { + type: string + properties: Object + required: string[] + } + + // @ts-ignore + responseSchema[key] = { + ...rest, + schema: { + type, + properties, + required + } + } + } + } + ) + } + } else if (typeof responseSchema === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type, properties, required, ...rest } = models[ + responseSchema + ] as TSchema & { + type: string + properties: Object + required: string[] + } + + responseSchema = { + // @ts-ignore + '200': { + ...rest, + schema: { + $ref: `#/definitions/${responseSchema}` + } + } + } + } const parameters = [ - ...mapProperties('header', headerSchema), - ...mapProperties('path', paramsSchema), - ...mapProperties('query', querySchema) + ...mapProperties('header', headerSchema, models), + ...mapProperties('path', paramsSchema, models), + ...mapProperties('query', querySchema, models) ] if (bodySchema) @@ -48,7 +146,12 @@ export const registerSchemaPath = ({ name: 'body', required: true, // @ts-ignore - schema: bodySchema + schema: + typeof bodySchema === 'string' + ? { + $ref: `#/definitions/${bodySchema}` + } + : bodySchema }) schema[path] = { @@ -59,14 +162,10 @@ export const registerSchemaPath = ({ : {}), ...(responseSchema ? { - responses: { - '200': { - description: 'Default response', - schema: responseSchema - } - } + responses: responseSchema } - : {}) + : {}), + ...hook?.schema?.detail } } } diff --git a/src/types.ts b/src/types.ts index 2f03167e..708189d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,31 +2,36 @@ import type { Elysia } from '.' import type { Serve, Server } from 'bun' import type { Context, PreContext } from './context' -import type { Static, TSchema } from '@sinclair/typebox' +import type { Static, TObject, TSchema } from '@sinclair/typebox' import type { TypeCheck } from '@sinclair/typebox/compiler' -import type { SCHEMA } from './utils' +import type { SCHEMA, DEFS } from './utils' +import type { OpenAPIV2 } from 'openapi-types' export type WithArray = T | T[] +export type ObjectValues = T[keyof T] export interface ElysiaInstance< Instance extends { store?: Record & - Record< - typeof SCHEMA, - Record>> - > - + Record> & + Record request?: Record schema?: TypedSchema } = { - store: Record + store: Record & + Record & + Record request: {} schema: {} } > { - request: Instance['request'] - store: Instance['store'] - schema: Instance['schema'] + request: Instance['request'] extends undefined + ? Record + : Instance['request'] + store: Instance['store'] extends undefined ? {} : Instance['store'] + schema: Instance['schema'] extends undefined + ? TypedSchema + : Instance['schema'] } export type Handler< @@ -35,9 +40,15 @@ export type Handler< CatchResponse = Route['response'] > = ( context: Context & Instance['request'] -) => CatchResponse extends Route['response'] - ? CatchResponse | Promise | Response - : Route['response'] | Promise | Response +) => // Catch function +Route['response'] extends (models: Record) => TSchema + ? undefined extends ReturnType + ? MaybePromise | Response + : MaybePromise> | Response + : // Catch non-function + undefined extends Route['response'] + ? MaybePromise | Response + : MaybePromise | Response export type NoReturnHandler< Route extends TypedRoute = TypedRoute, @@ -64,15 +75,18 @@ export type VoidLifeCycle = | ((app: Elysia) => void) | ((app: Elysia) => Promise) -export type BodyParser = ( - request: Request, +export type BodyParser< + Route extends TypedRoute = TypedRoute, + Instance extends ElysiaInstance = ElysiaInstance +> = ( + context: PreContext & Instance['request'], contentType: string ) => any | Promise export interface LifeCycle { start: VoidLifeCycle - request: BeforeRequestHandler - parse: BodyParser + request: BeforeRequestHandler + parse: BodyParser transform: NoReturnHandler beforeHandle: Handler afterHandle: AfterRequestHandler @@ -92,8 +106,8 @@ export interface LifeCycleStore< Instance extends ElysiaInstance = ElysiaInstance > { start: VoidLifeCycle[] - request: BeforeRequestHandler[] - parse: BodyParser[] + request: BeforeRequestHandler[] + parse: BodyParser[] transform: NoReturnHandler[] beforeHandle: Handler[] afterHandle: AfterRequestHandler[] @@ -102,10 +116,9 @@ export interface LifeCycleStore< } export type BeforeRequestHandler< - Store extends ElysiaInstance['store'] = ElysiaInstance['store'] -> = ( - context: PreContext -) => void | Promise | Response | Promise + Route extends TypedRoute = TypedRoute, + Instance extends ElysiaInstance = ElysiaInstance +> = (context: PreContext & Instance['request']) => any export interface RegisteredHook< Instance extends ElysiaInstance = ElysiaInstance @@ -117,45 +130,70 @@ export interface RegisteredHook< error: ErrorHandler[] } -export interface TypedSchema< - Schema extends { - body: TSchema - headers: TSchema - query: TSchema - params: TSchema - response: TSchema - } = { - body: TSchema - headers: TSchema - query: TSchema - params: TSchema - response: TSchema - } -> { - body?: Schema['body'] - headers?: Schema['headers'] - query?: Schema['query'] - params?: Schema['params'] - response?: Schema['response'] +export interface TypedSchema { + body?: TSchema | ModelName + headers?: TObject | ModelName + query?: TObject | ModelName + params?: TObject | ModelName + response?: + | TSchema + | Record + | ModelName + | Record } export type UnwrapSchema< - Schema extends TSchema | undefined, + Schema extends TSchema | undefined | string, + Instance extends ElysiaInstance = ElysiaInstance, Fallback = unknown -> = Schema extends NonNullable ? Static> : Fallback - -export type TypedSchemaToRoute = { - body: UnwrapSchema - headers: UnwrapSchema extends Record - ? UnwrapSchema +> = Schema extends string + ? Instance['store'][typeof DEFS] extends { + [name in Schema]: infer NamedSchema extends TSchema + } + ? Static + : Fallback + : Schema extends TSchema + ? Static> + : Fallback + +export type TypedSchemaToRoute< + Schema extends TypedSchema, + Instance extends ElysiaInstance +> = { + body: UnwrapSchema + headers: UnwrapSchema< + Schema['headers'], + Instance + > extends infer Result extends Record + ? Result : undefined - query: UnwrapSchema extends Record - ? UnwrapSchema + query: UnwrapSchema< + Schema['query'], + Instance + > extends infer Result extends Record + ? Result : undefined - params: UnwrapSchema extends Record - ? UnwrapSchema + params: UnwrapSchema< + Schema['params'], + Instance + > extends infer Result extends Record + ? Result : undefined - response: UnwrapSchema + response: Schema['response'] extends TSchema | string + ? UnwrapSchema + : Schema['response'] extends { + [k in string]: TSchema | string + } + ? UnwrapSchema, Instance> + : unknown +} + +export type AnyTypedSchema = { + body: unknown + headers: Record | undefined + query: Record | undefined + params: Record | undefined + response: TSchema | unknown | undefined } export type SchemaValidator = { @@ -169,15 +207,16 @@ export type SchemaValidator = { export type HookHandler< Schema extends TypedSchema = TypedSchema, Instance extends ElysiaInstance = ElysiaInstance, - Path extends string = string + Path extends string = string, + Typed extends AnyTypedSchema = TypedSchemaToRoute > = Handler< - TypedSchemaToRoute['params'] extends {} - ? Omit, 'response'> & { - response: void | TypedSchemaToRoute['response'] + Typed['params'] extends {} + ? Omit & { + response: void | Typed['response'] } : Omit< - Omit, 'response'> & { - response: void | TypedSchemaToRoute['response'] + Omit & { + response: void | Typed['response'] }, 'params' > & { @@ -198,68 +237,120 @@ export type MergeSchema = { } export interface LocalHook< - Schema extends TypedSchema = TypedSchema, - Instance extends ElysiaInstance = ElysiaInstance, - Path extends string = string -> { - schema?: Schema - transform?: WithArray< - HookHandler, Instance, Path> - > - beforeHandle?: WithArray< - HookHandler, Instance, Path> + Schema extends TypedSchema = TypedSchema, + Instance extends ElysiaInstance = ElysiaInstance, + Path extends string = string, + FinalSchema extends MergeSchema = MergeSchema< + Schema, + Instance['schema'] > +> { + schema?: Schema & { detail?: Partial } + transform?: WithArray> + beforeHandle?: WithArray> afterHandle?: WithArray> error?: WithArray } export type RouteToSchema< - Schema extends TypedSchema = TypedSchema, - Instance extends ElysiaInstance = ElysiaInstance, - Path extends string = string -> = MergeSchema['params'] extends NonNullable< - Schema['params'] -> - ? TypedSchemaToRoute> - : Omit< - TypedSchemaToRoute>, - 'params' - > & { + Schema extends TypedSchema = TypedSchema, + Instance extends ElysiaInstance = ElysiaInstance, + Path extends string = string, + FinalSchema extends MergeSchema = MergeSchema< + Schema, + Instance['schema'] + > +> = FinalSchema['params'] extends NonNullable + ? TypedSchemaToRoute + : Omit, 'params'> & { params: Record, string> } export type ElysiaRoute< Method extends string = string, - Schema extends TypedSchema = TypedSchema, + Schema extends TypedSchema = TypedSchema, Instance extends ElysiaInstance = ElysiaInstance, Path extends string = string, CatchResponse = unknown > = Elysia<{ request: Instance['request'] - store: Instance['store'] & - Record< - typeof SCHEMA, - Record< - Path, - Record< - Method, - RouteToSchema & { - response: CatchResponse extends RouteToSchema< - Schema, - Instance, - Path - >['response'] - ? CatchResponse - : RouteToSchema['response'] - } - > - > - > + store: Instance['store'] & { + [SCHEMA]: { + [path in Path]: { + [method in Method]: TypedRouteToEden< + Schema, + Instance, + Path + > extends infer FinalSchema extends AnyTypedSchema + ? Omit & { + response: undefined extends FinalSchema['response'] + ? { + '200': CatchResponse + } + : FinalSchema['response'] + } + : never + } + } + } schema: Instance['schema'] }> +export type TypedRouteToEden< + Schema extends TypedSchema = TypedSchema, + Instance extends ElysiaInstance = ElysiaInstance, + Path extends string = string, + FinalSchema extends MergeSchema = MergeSchema< + Schema, + Instance['schema'] + > +> = FinalSchema['params'] extends NonNullable + ? TypedSchemaToEden + : Omit, 'params'> & { + params: Record, string> + } + +export type TypedSchemaToEden< + Schema extends TypedSchema, + Instance extends ElysiaInstance +> = { + body: UnwrapSchema + headers: UnwrapSchema< + Schema['headers'], + Instance + > extends infer Result extends Record + ? Result + : undefined + query: UnwrapSchema< + Schema['query'], + Instance + > extends infer Result extends Record + ? Result + : undefined + params: UnwrapSchema< + Schema['params'], + Instance + > extends infer Result extends Record + ? Result + : undefined + response: Schema['response'] extends TSchema | string + ? { + '200': UnwrapSchema + } + : Schema['response'] extends { + [x in string]: TSchema | string + } + ? { + [key in keyof Schema['response']]: UnwrapSchema< + Schema['response'][key], + Instance + > + } + : unknown +} + export type LocalHandler< - Schema extends TypedSchema = TypedSchema, + Schema extends TypedSchema = TypedSchema, Instance extends ElysiaInstance = ElysiaInstance, Path extends string = string, CatchResponse = unknown @@ -424,3 +515,7 @@ export type IsAny = unknown extends T ? false : true : false + +export type MaybePromise = T | Promise + +export type IsNever = [T] extends [never] ? true : false diff --git a/src/utils.ts b/src/utils.ts index 49a079dd..41ad92c9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,18 +1,20 @@ +import { Kind, TSchema, Type } from '@sinclair/typebox' import { TypeCheck, TypeCompiler, type ValueError } from '@sinclair/typebox/compiler' -import type { TSchema } from '@sinclair/typebox' import type { DeepMergeTwoTypes, LifeCycleStore, LocalHook, - TypedSchema + TypedSchema, + RegisteredHook } from './types' // ? Internal property export const SCHEMA: unique symbol = Symbol('schema') +export const DEFS: unique symbol = Symbol('definitions') export const mergeObjectArray = (a: T | T[], b: T | T[]): T[] => [ ...(Array.isArray(a) ? a : [a]), @@ -22,7 +24,7 @@ export const mergeObjectArray = (a: T | T[], b: T | T[]): T[] => [ export const mergeHook = ( a: LocalHook | LifeCycleStore, b: LocalHook -): LocalHook => { +): RegisteredHook => { const aSchema = 'schema' in a ? (a.schema as TypedSchema) : null const bSchema = b && 'schema' in b ? b.schema : null @@ -37,7 +39,7 @@ export const mergeHook = ( query: bSchema?.query ?? aSchema?.query, response: bSchema?.response ?? aSchema?.response } as TypedSchema) - : null, + : undefined, transform: mergeObjectArray(a.transform ?? [], b?.transform ?? []), beforeHandle: mergeObjectArray( a.beforeHandle ?? [], @@ -67,39 +69,40 @@ export const getPath = (url: string, queryIndex = url.indexOf('?')): string => { export const mapQuery = ( url: string, - queryIndex = url.indexOf('?') + queryIndex: number | null = url.indexOf('?') ): Record => { if (queryIndex === -1) return {} const query: Record = {} - let paths = url.slice(queryIndex) + if (queryIndex) url = url.slice(queryIndex) + else url = ';' + url // eslint-disable-next-line no-constant-condition while (true) { // Skip ?/&, and min length of query is 3, so start looking at 1 + 3 - const sep = paths.indexOf('&', 4) + const sep = url.indexOf('&', 4) if (sep === -1) { - const equal = paths.indexOf('=', 1) + const equal = url.indexOf('=') - let value = paths.slice(equal + 1) + let value = url.slice(equal + 1) const hashIndex = value.indexOf('#') - if (hashIndex !== -1) value = value.substring(0, hashIndex) - if (value.indexOf('%') !== -1) value = decodeURI(value) + if (hashIndex !== -1) value = value.slice(0, hashIndex) + if (value.includes('%')) value = decodeURI(value) - query[paths.slice(1, equal)] = value + query[url.slice(1, equal)] = value break } - const path = paths.slice(0, sep) + const path = url.slice(0, sep) const equal = path.indexOf('=') let value = path.slice(equal + 1) - if (value.indexOf('%') !== -1) value = decodeURI(value) + if (value.includes('%')) value = decodeURI(value) query[path.slice(1, equal)] = value - paths = paths.slice(sep) + url = url.slice(sep) } return query @@ -147,13 +150,54 @@ export const createValidationError = ( }) } -export const getSchemaValidator = < - Schema extends TSchema | undefined = undefined ->( - schema: Schema, +export const getSchemaValidator = ( + s: TSchema | string | undefined, + models: Record, additionalProperties = false ) => { - if (!schema) return + if (!s) return + if (typeof s === 'string' && !(s in models)) return + + const schema: TSchema = typeof s === 'string' ? models[s] : s + + // @ts-ignore + if (schema.type === 'object' && 'additionalProperties' in schema === false) + schema.additionalProperties = additionalProperties + + return TypeCompiler.Compile(schema) +} + +export const getResponseSchemaValidator = ( + s: TypedSchema['response'] | undefined, + models: Record, + additionalProperties = false +) => { + if (!s) return + if (typeof s === 'string' && !(s in models)) return + + const maybeSchemaOrRecord = typeof s === 'string' ? models[s] : s + + const schema: TSchema = + Kind in maybeSchemaOrRecord + ? maybeSchemaOrRecord + : Type.Union( + Object.keys(maybeSchemaOrRecord) + .map((key): TSchema | undefined => { + const maybeNameOrSchema = maybeSchemaOrRecord[key] + + if (typeof maybeNameOrSchema === 'string') { + if (maybeNameOrSchema in models) { + const schema = models[maybeNameOrSchema] + return schema + } + + return undefined + } + + return maybeNameOrSchema + }) + .filter((a) => a) as TSchema[] + ) // @ts-ignore if (schema.type === 'object' && 'additionalProperties' in schema === false) diff --git a/test/models.test.ts b/test/models.test.ts new file mode 100644 index 00000000..738e0e7c --- /dev/null +++ b/test/models.test.ts @@ -0,0 +1,324 @@ +import { Elysia, SCHEMA, DEFS, t } from '../src' + +import { describe, expect, it } from 'bun:test' +import { req } from './utils' + +describe('Models', () => { + it('register models', async () => { + const app = new Elysia() + .setModel({ + string: t.String(), + number: t.Number() + }) + .setModel({ + boolean: t.Boolean() + }) + .get('/', ({ store }) => Object.keys(store[DEFS])) + + const res = await app.handle(req('/')).then((r) => r.json()) + + expect(res).toEqual(['string', 'number', 'boolean']) + }) + + it('map model parameters as OpenAPI schema', async () => { + const app = new Elysia() + .setModel({ + number: t.Number(), + string: t.String(), + boolean: t.Boolean(), + object: t.Object({ + string: t.String(), + boolean: t.Boolean() + }) + }) + .get('/defs', ({ store }) => store[SCHEMA]) + .get('/', () => 1, { + schema: { + query: 'object', + body: 'object', + params: 'object', + response: { + 200: 'boolean', + 300: 'number' + } + } as const + }) + + const res = await app.handle(req('/defs')).then((r) => r.json()) + + expect(res).toEqual({ + '/defs': { + get: {} + }, + '/': { + get: { + parameters: [ + { + in: 'path', + name: 'string', + type: 'string', + required: true + }, + { + in: 'path', + name: 'boolean', + type: 'boolean', + required: true + }, + { + in: 'query', + name: 'string', + type: 'string', + required: true + }, + { + in: 'query', + name: 'boolean', + type: 'boolean', + required: true + }, + { + in: 'body', + name: 'body', + required: true, + schema: { + $ref: '#/definitions/object' + } + } + ], + responses: { + '200': { + schema: { + $ref: '#/definitions/boolean' + } + }, + '300': { + schema: { + $ref: '#/definitions/number' + } + } + } + } + } + }) + }) + + it('map model and inline parameters as OpenAPI schema', async () => { + const app = new Elysia() + .setModel({ + number: t.Number(), + string: t.String(), + boolean: t.Boolean(), + object: t.Object({ + string: t.String(), + boolean: t.Boolean() + }) + }) + .get('/defs', ({ store }) => store[SCHEMA]) + .get('/', () => 1, { + schema: { + query: 'object', + body: t.Object({ + number: t.Number() + }), + params: 'object', + response: { + 200: 'boolean', + 300: 'number' + } + } as const + }) + + const res = await app.handle(req('/defs')).then((r) => r.json()) + + expect(res).toEqual({ + '/defs': { + get: {} + }, + '/': { + get: { + parameters: [ + { + in: 'path', + name: 'string', + type: 'string', + required: true + }, + { + in: 'path', + name: 'boolean', + type: 'boolean', + required: true + }, + { + in: 'query', + name: 'string', + type: 'string', + required: true + }, + { + in: 'query', + name: 'boolean', + type: 'boolean', + required: true + }, + { + in: 'body', + name: 'body', + required: true, + schema: { + type: 'object', + properties: { + number: { + type: 'number' + } + }, + required: ['number'], + additionalProperties: false + } + } + ], + responses: { + '200': { + schema: { + $ref: '#/definitions/boolean' + } + }, + '300': { + schema: { + $ref: '#/definitions/number' + } + } + } + } + } + }) + }) + + it('map model and inline response as OpenAPI schema', async () => { + const app = new Elysia() + .setModel({ + number: t.Number(), + string: t.String(), + boolean: t.Boolean(), + object: t.Object({ + string: t.String(), + boolean: t.Boolean() + }) + }) + .get('/defs', ({ store }) => store[SCHEMA]) + .get('/', () => 1, { + schema: { + response: { + 200: t.String(), + 300: 'number' + } + } as const + }) + + const res = await app.handle(req('/defs')).then((r) => r.json()) + + expect(res).toEqual({ + '/defs': { + get: {} + }, + '/': { + get: { + responses: { + '200': { + schema: { + type: 'string' + } + }, + '300': { + schema: { + $ref: '#/definitions/number' + } + } + } + } + } + }) + }) + + it('map model default response', async () => { + const app = new Elysia() + .setModel({ + number: t.Number(), + string: t.String(), + boolean: t.Boolean(), + object: t.Object({ + string: t.String(), + boolean: t.Boolean() + }) + }) + .get('/defs', ({ store }) => store[SCHEMA]) + .get('/', () => 1, { + schema: { + response: 'number' + } as const + }) + + const res = await app.handle(req('/defs')).then((r) => r.json()) + + expect(res).toEqual({ + '/defs': { + get: {} + }, + '/': { + get: { + responses: { + '200': { + schema: { + $ref: '#/definitions/number' + } + } + } + } + } + }) + }) + + it('validate reference model', async () => { + const app = new Elysia() + .setModel({ + number: t.Number() + }) + .post('/', ({ body: { data } }) => data, { + schema: { + body: t.Object({ + data: t.Union([t.String(), t.Number()]) + }), + response: 'number' + } as const + }) + + const correct = await app.handle( + new Request('http://localhost/', { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + data: 1 + }) + }) + ) + + expect(correct.status).toBe(200) + + const wrong = await app.handle( + new Request('http://localhost/', { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + data: true + }) + }) + ) + + expect(wrong.status).toBe(400) + }) +}) diff --git a/test/modules.test.ts b/test/modules.test.ts new file mode 100644 index 00000000..48ace350 --- /dev/null +++ b/test/modules.test.ts @@ -0,0 +1,117 @@ +import { Elysia, SCHEMA } from '../src' + +import { describe, expect, it } from 'bun:test' +import { req } from './utils' +const asyncPlugin = async (app: Elysia) => app.get('/async', () => 'async') +const lazyPlugin = import('./modules') +const lazyNamed = lazyPlugin.then((x) => x.lazy) + +describe('Modules', () => { + it('inline async', async () => { + const app = new Elysia().use(async (app) => + app.get('/async', () => 'async') + ) + + await app.modules + + const res = await app.handle(req('/async')).then((r) => r.text()) + + expect(res).toBe('async') + }) + + it('async', async () => { + const app = new Elysia().use(asyncPlugin) + + await app.modules + + const res = await app.handle(req('/async')).then((r) => r.text()) + + expect(res).toBe('async') + }) + + it('inline import', async () => { + const app = new Elysia().use(import('./modules')) + + await app.modules + + const res = await app.handle(req('/lazy')).then((r) => r.text()) + + expect(res).toBe('lazy') + }) + + it('import', async () => { + const app = new Elysia().use(lazyPlugin) + + await app.modules + + const res = await app.handle(req('/lazy')).then((r) => r.text()) + + expect(res).toBe('lazy') + }) + + it('import non default', async () => { + const app = new Elysia().use(lazyNamed) + + await app.modules + + const res = await app.handle(req('/lazy')).then((r) => r.text()) + + expect(res).toBe('lazy') + }) + + it('inline import non default', async () => { + const app = new Elysia().use(import('./modules')) + + await app.modules + + const res = await app.handle(req('/lazy')).then((r) => r.text()) + + expect(res).toBe('lazy') + }) + + it('register async and lazy path', async () => { + const app = new Elysia() + .use(import('./modules')) + .use(asyncPlugin) + .get('/', () => 'hi') + .get('/schema', ({ store }) => store[SCHEMA]) + + await app.modules + + const res = await app.handle(req('/schema')).then((r) => r.json()) + + expect(res).toEqual({ + '/async': { + get: {} + }, + '/': { + get: {} + }, + '/schema': { + get: {} + }, + '/lazy': { + get: {} + } + }) + }) + + it('Count lazy module correctly', async () => { + const app = new Elysia() + .use(import('./modules')) + .use(asyncPlugin) + .get('/', () => 'hi') + + const awaited = await app.modules + + expect(awaited.length).toBe(2) + }) + + it('Handle other routes while lazy load', async () => { + const app = new Elysia().use(import('./timeout')).get('/', () => 'hi') + + const res = await app.handle(req('/')).then((r) => r.text()) + + expect(res).toBe('hi') + }) +}) diff --git a/test/modules.ts b/test/modules.ts new file mode 100644 index 00000000..9e784c52 --- /dev/null +++ b/test/modules.ts @@ -0,0 +1,5 @@ +import { Elysia } from '../src' + +export const lazy = async (app: Elysia) => app.get('/lazy', () => 'lazy') + +export default lazy diff --git a/test/body-parser.test.ts b/test/parser.test.ts similarity index 55% rename from test/body-parser.test.ts rename to test/parser.test.ts index e65daf91..5c6d8718 100644 --- a/test/body-parser.test.ts +++ b/test/parser.test.ts @@ -2,13 +2,13 @@ import { Elysia } from '../src' import { describe, expect, it } from 'bun:test' -describe('Body Parser', () => { +describe('Parser', () => { it('handle onParse', async () => { const app = new Elysia() - .onParse((request, contentType) => { + .onParse((context, contentType) => { switch (contentType) { case 'application/Elysia': - return request.text() + return context.request.text() } }) .post('/', ({ body }) => body) @@ -30,10 +30,10 @@ describe('Body Parser', () => { it("handle .on('parse')", async () => { const app = new Elysia() - .on('parse', (request, contentType) => { + .on('parse', (context, contentType) => { switch (contentType) { case 'application/Elysia': - return request.text() + return context.request.text() } }) .post('/', ({ body }) => body) @@ -55,7 +55,7 @@ describe('Body Parser', () => { it('overwrite default parser', async () => { const app = new Elysia() - .onParse((request, contentType) => { + .onParse((context, contentType) => { switch (contentType) { case 'text/plain': return 'Overwrited' @@ -77,4 +77,46 @@ describe('Body Parser', () => { expect(await res.text()).toBe('Overwrited') }) + + it('parse x-www-form-urlencoded', async () => { + const app = new Elysia().post('/', ({ body }) => body).listen(8080) + + const body = { + username: 'salty aom', + password: '12345678' + } + + const res = await app.handle( + new Request('http://localhost/', { + method: 'POST', + body: `username=${body.username}&password=${body.password}`, + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }) + ) + + expect(await res.json()).toEqual(body) + }) + + it('parse with extra content-type attribute', async () => { + const app = new Elysia().post('/', ({ body }) => body).listen(8080) + + const body = { + username: 'salty aom', + password: '12345678' + } + + const res = await app.handle( + new Request('http://localhost/', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'content-type': 'application/json;charset=utf-8' + } + }) + ) + + expect(await res.json()).toEqual(body) + }) }) diff --git a/test/schema.test.ts b/test/schema.test.ts index 009205ba..b8d656bf 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -152,43 +152,32 @@ describe('Schema', () => { expect(await valid.text()).toBe('salt') expect(valid.status).toBe(200) - // const invalidQuery = await app.handle( - // new Request('/user', { - // method: 'POST', - // body: JSON.stringify({ - // id: 6, - // username: '', - // profile: { - // name: 'A' - // } - // }) - // }) - // ) - - // expect(await invalidQuery.text()).toBe( - // "Invalid query, root should have required property 'name'" - // ) - // expect(valid.status).toBe(400) - - // const invalidBody = await app.handle( - // new Request('/user?name=salt', { - // method: 'POST', - // body: JSON.stringify({ - // id: 6, - // username: '', - // profile: {} - // }) - // }) - // ) - - // expect(await invalidQuery.text()).toBe( - // "Invalid query, root should have required property 'name'" - // ) - // expect(invalidBody.status).toBe(400) - - // expect(await invalidBody.text()).toBe( - // "Invalid body, .profile should have required property 'name'" - // ) - // expect(invalidBody.status).toBe(400) + const invalidQuery = await app.handle( + new Request('http://localhost/user', { + method: 'POST', + body: JSON.stringify({ + id: 6, + username: '', + profile: { + name: 'A' + } + }) + }) + ) + + expect(invalidQuery.status).toBe(400) + + const invalidBody = await app.handle( + new Request('http://localhost/user?name=salt', { + method: 'POST', + body: JSON.stringify({ + id: 6, + username: '', + profile: {} + }) + }) + ) + + expect(invalidBody.status).toBe(400) }) }) diff --git a/test/timeout.ts b/test/timeout.ts new file mode 100644 index 00000000..45567269 --- /dev/null +++ b/test/timeout.ts @@ -0,0 +1,9 @@ +import Elysia from '../src' + +const largePlugin = async (app: Elysia) => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + + return app.get('/large', () => 'Hi') +} + +export default largePlugin diff --git a/test/transform.test.ts b/test/transform.test.ts index 212cb735..7d780fcc 100644 --- a/test/transform.test.ts +++ b/test/transform.test.ts @@ -192,4 +192,20 @@ describe('Transform', () => { expect(await res.text()).toBe('number') }) + + it('Map returned value', async () => { + const app = new Elysia() + .onTransform<{ + params: { + id?: number + } + }>((request) => { + if (request.params?.id) request.params.id = +request.params.id + }) + .get('/id/:id', ({ params: { id } }) => typeof id) + const res = await app.handle(req('/id/1')) + + expect(await res.text()).toBe('number') + }) + }) diff --git a/tsconfig.esm.json b/tsconfig.esm.json index 6c047bdf..c98c2e0a 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -100,5 +100,6 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ }, - "include": ["src/**/*"] + "include": ["src/**/*"], + // "exclude": ["node_modules"] } diff --git a/tsconfig.json b/tsconfig.json index 114f0f8f..c0c2b38c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -100,5 +100,6 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ }, + "exclude": ["node_modules"] // "include": ["src/**/*"] }