diff --git a/package-lock.json b/package-lock.json index be81955..ed05079 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/http", - "version": "5.45.0", + "version": "5.47.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/http", - "version": "5.45.0", + "version": "5.47.0", "license": "MIT", "devDependencies": { "@athenna/artisan": "^5.11.0", @@ -44,7 +44,8 @@ "ora": "^8.2.0", "prettier": "^2.8.8", "vite": "^6.4.1", - "vite-plugin-restart": "^0.4.2" + "vite-plugin-restart": "^0.4.2", + "zod": "^4.3.6" }, "engines": { "node": ">=20.0.0" @@ -15255,6 +15256,15 @@ "engines": { "node": ">= 0.6" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a5c1d53..780e55d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/http", - "version": "5.45.0", + "version": "5.47.0", "description": "The Athenna Http server. Built on top of fastify.", "license": "MIT", "author": "João Lenon ", @@ -110,7 +110,8 @@ "ora": "^8.2.0", "prettier": "^2.8.8", "vite": "^6.4.1", - "vite-plugin-restart": "^0.4.2" + "vite-plugin-restart": "^0.4.2", + "zod": "^4.3.6" }, "c8": { "all": true, diff --git a/src/exceptions/ZodValidationException.ts b/src/exceptions/ZodValidationException.ts new file mode 100644 index 0000000..2756351 --- /dev/null +++ b/src/exceptions/ZodValidationException.ts @@ -0,0 +1,23 @@ +/** + * @athenna/http + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { ZodError } from 'zod' +import { HttpException } from '#src/exceptions/HttpException' + +export class ZodValidationException extends HttpException { + public constructor(error: ZodError) { + const name = 'ValidationException' + const code = 'E_VALIDATION_ERROR' + const status = 422 + const message = 'Validation error happened.' + const details = error.issues + + super({ name, message, status, code, details }) + } +} diff --git a/src/index.ts b/src/index.ts index d19e1a8..5f3e766 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ declare module 'fastify' { export * from '#src/types' +export * from '#src/router/RouteSchema' export * from '#src/context/Request' export * from '#src/context/Response' export * from '#src/annotations/Controller' diff --git a/src/router/Route.ts b/src/router/Route.ts index 4b16ae3..76d3dc8 100644 --- a/src/router/Route.ts +++ b/src/router/Route.ts @@ -17,10 +17,14 @@ import type { InterceptorRouteType } from '#src/types' -import type { HTTPMethods, FastifySchema, RouteOptions } from 'fastify' +import type { HTTPMethods, RouteOptions } from 'fastify' import { Is, Options, Macroable, Route as RouteHelper } from '@athenna/common' import { UndefinedMethodException } from '#src/exceptions/UndefinedMethodException' import { NotFoundValidatorException } from '#src/exceptions/NotFoundValidatorException' +import { + type RouteSchemaOptions, + normalizeRouteSchema +} from '#src/router/RouteSchema' import { NotFoundMiddlewareException } from '#src/exceptions/NotFoundMiddlewareException' export class Route extends Macroable { @@ -341,8 +345,20 @@ export class Route extends Macroable { * }) * ``` */ - public schema(options: FastifySchema): Route { - this.route.fastify.schema = options + public schema(options: RouteSchemaOptions): Route { + const { schema, zod } = normalizeRouteSchema(options) + + this.route.fastify.schema = schema + + if (zod) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.route.fastify.config.zod = zod + } else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete this.route.fastify.config.zod + } return this } diff --git a/src/router/RouteResource.ts b/src/router/RouteResource.ts index bed4de0..bc510a3 100644 --- a/src/router/RouteResource.ts +++ b/src/router/RouteResource.ts @@ -16,6 +16,7 @@ import type { import type { HTTPMethods } from 'fastify' import { Route } from '#src/router/Route' +import type { RouteSchemaOptions } from '#src/router/RouteSchema' import { Is, String, Macroable, Options } from '@athenna/common' export class RouteResource extends Macroable { @@ -37,7 +38,7 @@ export class RouteResource extends Macroable { public constructor(resource: string, controller: any) { super() - this.resource = resource + this.resource = resource.replace(/^\/|\/$/g, '') this.controller = controller this.buildRoutes() @@ -89,7 +90,7 @@ export class RouteResource extends Macroable { return this } - + if (options.except.length) { this.filter(options.except, true).forEach(route => { route.middleware(middleware, options.prepend) @@ -97,7 +98,7 @@ export class RouteResource extends Macroable { return this } - + this.routes.forEach(route => route.middleware(middleware, options.prepend)) return this @@ -132,7 +133,7 @@ export class RouteResource extends Macroable { return this } - + if (options.except.length) { this.filter(options.except, true).forEach(route => { route.interceptor(interceptor, options.prepend) @@ -140,8 +141,10 @@ export class RouteResource extends Macroable { return this } - - this.routes.forEach(route => route.interceptor(interceptor, options.prepend)) + + this.routes.forEach(route => + route.interceptor(interceptor, options.prepend) + ) return this } @@ -175,7 +178,7 @@ export class RouteResource extends Macroable { return this } - + if (options.except.length) { this.filter(options.except, true).forEach(route => { route.terminator(terminator, options.prepend) @@ -183,7 +186,7 @@ export class RouteResource extends Macroable { return this } - + this.routes.forEach(route => route.terminator(terminator, options.prepend)) return this @@ -254,6 +257,31 @@ export class RouteResource extends Macroable { return this } + /** + * Set up schema options for specific route resource methods. + * + * @example + * ```ts + * Route.resource('/test', 'TestController').schema({ + * index: { response: { 200: { type: 'object' } } }, + * store: { body: { type: 'object' } } + * }) + * ``` + */ + public schema( + options: Partial> + ): RouteResource { + Object.entries(options).forEach(([name, schema]) => { + if (!schema) { + return + } + + this.filter([name]).forEach(route => route.schema(schema)) + }) + + return this + } + /** * Filter routes by name. */ diff --git a/src/router/RouteSchema.ts b/src/router/RouteSchema.ts new file mode 100644 index 0000000..a7a802b --- /dev/null +++ b/src/router/RouteSchema.ts @@ -0,0 +1,237 @@ +/** + * @athenna/http + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { ZodAny } from 'zod' +import { Is } from '@athenna/common' +import type { FastifyReply, FastifyRequest, FastifySchema } from 'fastify' +import { ZodValidationException } from '#src/exceptions/ZodValidationException' + +type ZodRequestSchema = Partial< + Record<'body' | 'headers' | 'params' | 'querystring', ZodAny> +> + +type ZodResponseSchema = Record + +export type RouteSchemaOptions = FastifySchema & { + body?: FastifySchema['body'] | ZodAny + headers?: FastifySchema['headers'] | ZodAny + params?: FastifySchema['params'] | ZodAny + querystring?: FastifySchema['querystring'] | ZodAny + response?: FastifySchema['response'] | ZodResponseSchema +} + +export type RouteZodSchemas = { + request: ZodRequestSchema + response: ZodResponseSchema +} + +export function normalizeRouteSchema(options: RouteSchemaOptions): { + schema: FastifySchema + zod: RouteZodSchemas | null +} { + const request: ZodRequestSchema = {} + const response: ZodResponseSchema = {} + const schema: FastifySchema = { ...options } + + const requestKeys = ['body', 'headers', 'params', 'querystring'] as const + + requestKeys.forEach(key => { + if (!isZodSchema(options[key])) { + return + } + + request[key] = options[key] + schema[key] = toJsonSchema(options[key], 'input') + }) + + if (options.response && Is.Object(options.response)) { + schema.response = { ...options.response } + + Object.entries(options.response).forEach(([statusCode, value]) => { + if (!isZodSchema(value)) { + return + } + + response[statusCode] = value + schema.response[statusCode] = toJsonSchema(value, 'output') + }) + } + + const hasZodSchemas = + Object.keys(request).length > 0 || Object.keys(response).length > 0 + + return { + schema, + zod: hasZodSchemas ? { request, response } : null + } +} + +export async function parseRequestWithZod( + req: FastifyRequest, + schemas: RouteZodSchemas +) { + const requestSchemas = schemas.request + + if (requestSchemas.body) { + req.body = await parseSchema(requestSchemas.body, req.body) + } + + if (requestSchemas.headers) { + req.headers = await parseSchema(requestSchemas.headers, req.headers) + } + + if (requestSchemas.params) { + req.params = await parseSchema( + requestSchemas.params, + coerceDataForValidation(requestSchemas.params, req.params) + ) + } + + if (requestSchemas.querystring) { + req.query = await parseSchema( + requestSchemas.querystring, + coerceDataForValidation(requestSchemas.querystring, req.query) + ) + } +} + +export async function parseResponseWithZod( + reply: FastifyReply, + payload: any, + schemas: RouteZodSchemas +) { + const schema = getResponseSchema(reply.statusCode, schemas.response) + + if (!schema) { + return payload + } + + return parseSchema(schema, payload) +} + +function getResponseSchema( + statusCode: number, + schemas: ZodResponseSchema +): ZodAny | null { + return ( + schemas[statusCode] || + schemas[String(statusCode)] || + schemas[`${String(statusCode)[0]}xx`] || + schemas.default || + null + ) +} + +async function parseSchema(schema: ZodAny, data: any) { + const result = await schema.safeParseAsync(data) + + if (!result.success) { + throw new ZodValidationException(result.error) + } + + return result.data +} + +function toJsonSchema(schema: ZodAny, io: 'input' | 'output') { + const jsonSchemaMethod = + (schema as any)['~standard']?.jsonSchema?.[io] || + (schema as any).toJSONSchema + + if (!jsonSchemaMethod) { + return {} + } + + const jsonSchema = jsonSchemaMethod({ + target: 'draft-07', + libraryOptions: { unrepresentable: 'any' } + }) + + delete jsonSchema.$schema + + return jsonSchema +} + +function coerceDataForValidation(schema: ZodAny, data: any) { + return coerceDataByJsonSchema(toJsonSchema(schema, 'input'), data) +} + +function coerceDataByJsonSchema(schema: any, data: any): any { + if (Is.Undefined(data) || Is.Null(data) || !schema) { + return data + } + + if (schema.anyOf) { + return coerceWithAlternatives(schema.anyOf, data) + } + + if (schema.oneOf) { + return coerceWithAlternatives(schema.oneOf, data) + } + + if (schema.type === 'object' && Is.Object(data)) { + const coerced = { ...data } + const properties = schema.properties || {} + + Object.entries(properties).forEach(([key, childSchema]) => { + if (!Object.hasOwn(coerced, key)) { + return + } + + coerced[key] = coerceDataByJsonSchema(childSchema, coerced[key]) + }) + + return coerced + } + + if (schema.type === 'array' && Is.Array(data) && schema.items) { + return data.map(item => coerceDataByJsonSchema(schema.items, item)) + } + + if (schema.type === 'number' || schema.type === 'integer') { + return coerceNumber(data, schema.type === 'integer') + } + + return data +} + +function coerceWithAlternatives(schemas: any[], data: any) { + let coerced = data + + schemas.forEach(schema => { + coerced = coerceDataByJsonSchema(schema, coerced) + }) + + return coerced +} + +function coerceNumber(value: any, integerOnly: boolean) { + if (!Is.String(value) || value.trim() === '') { + return value + } + + const parsed = integerOnly ? Number.parseInt(value, 10) : Number(value) + + if (Number.isNaN(parsed)) { + return value + } + + if (integerOnly && !Number.isInteger(parsed)) { + return value + } + + return parsed +} + +function isZodSchema(value: any): value is ZodAny { + return ( + Is.Defined(value) && + Is.Function(value.parse) && + Is.Function(value.safeParseAsync) + ) +} diff --git a/src/server/ServerImpl.ts b/src/server/ServerImpl.ts index c6066ab..ecf5d51 100644 --- a/src/server/ServerImpl.ts +++ b/src/server/ServerImpl.ts @@ -29,6 +29,13 @@ import type { SwaggerDocument } from '#src/types' +import { + type RouteSchemaOptions, + normalizeRouteSchema, + parseRequestWithZod, + parseResponseWithZod +} from '#src/router/RouteSchema' + import type { AddressInfo } from 'node:net' import type { FastifyVite } from '@athenna/vite' import { Options, Macroable, Is } from '@athenna/common' @@ -276,10 +283,16 @@ export class ServerImpl extends Macroable { } const { middlewares, interceptors, terminators } = options.middlewares + const fastifyOptions = this.getFastifyOptionsWithOpenApiSchema(options) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const zodSchemas = fastifyOptions?.config?.zod const route: RouteOptions = { onSend: [], + preValidation: [], preHandler: [], + preSerialization: [], onResponse: [], url: options.url, method: options.methods, @@ -298,6 +311,14 @@ export class ServerImpl extends Macroable { route.onResponse = terminators.map(t => FastifyHandler.terminate(t)) } + if (zodSchemas) { + route.preValidation = [async req => parseRequestWithZod(req, zodSchemas)] + route.preSerialization = [ + async (_, reply, payload) => + parseResponseWithZod(reply, payload, zodSchemas) + ] + } + if (options.data && Is.Array(route.preHandler)) { route.preHandler?.unshift((req, _, done) => { req.data = { @@ -309,7 +330,18 @@ export class ServerImpl extends Macroable { }) } - this.fastify.route({ ...route, ...options.fastify }) + if (zodSchemas) { + fastifyOptions.preValidation = [ + ...this.toRouteHooks(route.preValidation), + ...this.toRouteHooks(fastifyOptions.preValidation) + ] + fastifyOptions.preSerialization = [ + ...this.toRouteHooks(route.preSerialization), + ...this.toRouteHooks(fastifyOptions.preSerialization) + ] + } + + this.fastify.route({ ...route, ...fastifyOptions }) } /** @@ -360,4 +392,121 @@ export class ServerImpl extends Macroable { public options(options: Omit): void { this.route({ ...options, methods: ['OPTIONS'] }) } + + private toRouteHooks(hooks?: RouteOptions['preValidation']) { + if (!hooks) { + return [] + } + + return Array.isArray(hooks) ? hooks : [hooks] + } + + private getFastifyOptionsWithOpenApiSchema(options: RouteJson) { + const automaticSchema = this.getOpenApiRouteSchema(options) + const fastifyOptions = { ...options.fastify } + + if (!automaticSchema) { + return fastifyOptions + } + + const normalizedSchema = normalizeRouteSchema(automaticSchema) + const currentConfig = { ...(fastifyOptions.config || {}) } + + fastifyOptions.schema = this.mergeFastifySchemas( + normalizedSchema.schema, + fastifyOptions.schema + ) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const currentZod = currentConfig.zod + const mergedZod = this.mergeZodSchemas(normalizedSchema.zod, currentZod) + + if (mergedZod) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + currentConfig.zod = mergedZod + } + + fastifyOptions.config = currentConfig + + return fastifyOptions + } + + private getOpenApiRouteSchema(options: RouteJson): RouteSchemaOptions { + const paths = Config.get('openapi.paths', {}) + const methods = options.methods || [] + + if (!Is.Object(paths) || !options.url || !methods.length) { + return null + } + + const candidates = this.getOpenApiPathCandidates(options.url) + + for (const candidate of candidates) { + const pathConfig = paths[candidate] + + if (!Is.Object(pathConfig)) { + continue + } + + for (const method of methods) { + const methodConfig = pathConfig[method.toLowerCase()] + + if (Is.Object(methodConfig)) { + return methodConfig + } + } + } + + return null + } + + private getOpenApiPathCandidates(url: string) { + const normalized = this.normalizePath(url) + const openApi = normalized.replace(/:([A-Za-z0-9_]+)/g, '{$1}') + + return Array.from(new Set([normalized, openApi])) + } + + private normalizePath(url: string) { + if (url === '/') { + return url + } + + return `/${url.replace(/^\//, '').replace(/\/$/, '')}` + } + + private mergeFastifySchemas(base: any, override: any) { + const merged = { + ...base, + ...override + } + + if (base?.response || override?.response) { + merged.response = { + ...(base?.response || {}), + ...(override?.response || {}) + } + } + + return merged + } + + private mergeZodSchemas(base: any, override: any) { + if (!base && !override) { + return null + } + + return { + request: { + ...(base?.request || {}), + ...(override?.request || {}) + }, + response: { + ...(base?.response || {}), + ...(override?.response || {}) + } + } + } } diff --git a/tests/unit/context/ResponseTest.ts b/tests/unit/context/ResponseTest.ts index ab74f85..1f78711 100644 --- a/tests/unit/context/ResponseTest.ts +++ b/tests/unit/context/ResponseTest.ts @@ -296,7 +296,8 @@ export default class ResponseTest { response.download('app.ts', 'a.ts').then(() => { assert.deepEqual(response.headers, { - 'content-length': '0', + 'content-length': '76', + 'content-type': 'application/json; charset=utf-8', 'content-disposition': 'attachment; filename="app.ts"' }) }) diff --git a/tests/unit/router/RouteResourceTest.ts b/tests/unit/router/RouteResourceTest.ts index 141d9cc..49178b0 100644 --- a/tests/unit/router/RouteResourceTest.ts +++ b/tests/unit/router/RouteResourceTest.ts @@ -11,9 +11,11 @@ import { MyValidator } from '#tests/fixtures/validators/MyValidator' import { MyMiddleware } from '#tests/fixtures/middlewares/MyMiddleware' import { MyTerminator } from '#tests/fixtures/middlewares/MyTerminator' import { MyInterceptor } from '#tests/fixtures/middlewares/MyInterceptor' -import { Test, AfterEach, BeforeEach, type Context } from '@athenna/test' +import { Config } from '@athenna/config' +import { Test, AfterEach, BeforeEach, type Context, Cleanup } from '@athenna/test' import { HelloController } from '#tests/fixtures/controllers/HelloController' -import { Route, Server, HttpRouteProvider, HttpServerProvider } from '#src' +import { Route, Server, HttpKernel, HttpRouteProvider, HttpServerProvider } from '#src' +import z from 'zod' export default class RouteResourceTest { @BeforeEach() @@ -158,6 +160,37 @@ export default class RouteResourceTest { assert.equal(response.headers['x-ratelimit-reset'], '60') } + @Test() + public async shouldBeAbleToSetSchemaForSpecificRouteResourceMethods({ assert }: Context) { + await Server.plugin(import('@fastify/swagger'), { swagger: {} }) + + Route.resource('test', new HelloController()).schema({ + index: { + summary: 'List tests', + response: { + 200: { + properties: { hello: { type: 'string' } } + } + } + }, + store: { + summary: 'Create test', + body: { + type: 'object', + properties: { name: { type: 'string' } } + } + } + }) + + Route.register() + + const swagger = await Server.getSwagger() + + assert.equal(swagger.paths['/test'].get.summary, 'List tests') + assert.equal(swagger.paths['/test'].post.summary, 'Create test') + assert.isUndefined(swagger.paths['/test/{id}'].get.summary) + } + @Test() public async shouldBeAbleToRegisterAMiddlewareClosureInRouteResourceUsingRouter({ assert }: Context) { ioc.bind('App/Http/Controllers/HelloController', HelloController) @@ -399,4 +432,76 @@ export default class RouteResourceTest { assert.throws(() => Route.resource('test', 'HelloController').terminator('not-found')) } + + @Test() + @Cleanup(() => Config.set('openapi.paths', {})) + public async shouldAutomaticallyApplySchemasFromOpenApiConfigInResources({ assert }: Context) { + Config.set('openapi.paths', { + '/test': { + get: { + response: { + 200: z.object({ + hello: z.string().default('aaaaa'), + testing: z.string().default('testing') + }) + } + } + } + }) + + Route.resource('test', new HelloController()).only(['index']) + + Route.register() + + const response = await Server.request({ + path: '/test', + method: 'get' + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { hello: 'world', testing: 'testing' }) + } + + @Test() + @Cleanup(() => Config.set('openapi.paths', {})) + public async shouldAutomaticallyThrowValidationExceptionWhenSchemaIsInvalidInResources({ assert }: Context) { + await new HttpKernel().registerExceptionHandler() + + Config.set('openapi.paths', { + '/test': { + get: { + response: { + 200: z.object({ + hello: z.number().default(10) + }) + } + } + } + }) + + Route.resource('test', new HelloController()).only(['index']) + + Route.register() + + const response = await Server.request({ + path: '/test', + method: 'get' + }) + + assert.equal(response.statusCode, 422) + assert.containSubset(response.json(), { + name: 'ValidationException', + message: 'Validation error happened.', + code: 'E_VALIDATION_ERROR', + statusCode: 422, + details: [ + { + expected: 'number', + code: 'invalid_type', + path: ['hello'], + message: 'Invalid input: expected number, received string' + } + ] + }) + } } diff --git a/tests/unit/router/RouteTest.ts b/tests/unit/router/RouteTest.ts index 660667d..3881a4e 100644 --- a/tests/unit/router/RouteTest.ts +++ b/tests/unit/router/RouteTest.ts @@ -7,10 +7,12 @@ * file that was distributed with this source code. */ +import { Config } from '@athenna/config' import { MyMiddleware } from '#tests/fixtures/middlewares/MyMiddleware' -import { Test, AfterEach, BeforeEach, type Context } from '@athenna/test' +import { Test, AfterEach, BeforeEach, type Context, Cleanup } from '@athenna/test' import { HelloController } from '#tests/fixtures/controllers/HelloController' import { Route, Server, HttpRouteProvider, HttpServerProvider } from '#src' +import { z } from 'zod' export default class RouteTest { @BeforeEach() @@ -77,6 +79,135 @@ export default class RouteTest { assert.deepEqual(response.json(), { hello: 'world' }) } + @Test() + public async shouldBeAbleToUseZodSchemasInRouteSchema({ assert }: Context) { + Route.post('test/:id', async ctx => { + await ctx.response.status(201).send({ + id: String(ctx.request.param('id')), + page: String(ctx.request.query('page')), + name: ctx.request.input('name') + }) + }).schema({ + params: z.object({ id: z.coerce.number() }), + querystring: z.object({ page: z.coerce.number() }), + body: z.object({ name: z.string() }), + response: { + 201: z.object({ + id: z.coerce.number(), + page: z.coerce.number(), + name: z.string() + }) + } + }) + + Route.register() + + const response = await Server.request({ + path: '/test/10?page=2', + method: 'post', + payload: { name: 'lenon' } + }) + + assert.equal(response.statusCode, 201) + assert.deepEqual(response.json(), { id: 10, page: 2, name: 'lenon' }) + + const swagger = await Server.getSwagger() + + assert.containSubset(swagger.paths['/test/{id}'], { + post: { + responses: { + '201': { + schema: { + type: 'object', + properties: { + id: { type: 'number' }, + page: { type: 'number' }, + name: { type: 'string' } + } + } + } + }, + parameters: [ + { in: 'path', name: 'id', type: 'number' }, + { in: 'query', name: 'page', type: 'number' }, + { + in: 'body', + schema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + ] + } + }) + } + + @Test() + public async shouldAutomaticallyCoerceZodQuerystringAndParams({ assert }: Context) { + Route.get('users/:id', async ctx => { + await ctx.response.send({ + id: ctx.request.param('id'), + limit: ctx.request.query('limit') + }) + }).schema({ + params: z.object({ id: z.number() }), + querystring: z.object({ limit: z.number() }), + response: { + 200: z.object({ + id: z.number(), + limit: z.number() + }) + } + }) + + Route.register() + + const response = await Server.request({ + path: '/users/10?limit=2', + method: 'get' + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { id: 10, limit: 2 }) + } + + @Test() + @Cleanup(() => Config.set('openapi.paths', {})) + public async shouldAutomaticallyApplySchemasFromOpenApiConfig({ assert }: Context) { + Config.set('openapi.paths', { + '/users/{id}': { + get: { + params: z.object({ id: z.number() }), + querystring: z.object({ limit: z.number() }), + response: { + 200: z.object({ + id: z.number(), + limit: z.number() + }) + } + } + } + }) + + Route.get('users/:id', async ctx => { + await ctx.response.send({ + id: ctx.request.param('id'), + limit: ctx.request.query('limit') + }) + }) + + Route.register() + + const response = await Server.request({ + path: '/users/10?limit=2', + method: 'get' + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { id: 10, limit: 2 }) + } + @Test() public async shouldBeAbleToHideARouteFromTheSwaggerDocumentation({ assert }: Context) { Route.get('test', new HelloController().index)