diff --git a/package-lock.json b/package-lock.json index ed05079..813e615 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/http", - "version": "5.47.0", + "version": "5.48.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/http", - "version": "5.47.0", + "version": "5.48.0", "license": "MIT", "devDependencies": { "@athenna/artisan": "^5.11.0", diff --git a/package.json b/package.json index 780e55d..0aef60d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/http", - "version": "5.47.0", + "version": "5.48.0", "description": "The Athenna Http server. Built on top of fastify.", "license": "MIT", "author": "João Lenon ", diff --git a/src/context/Response.ts b/src/context/Response.ts index 822eb9b..1130164 100644 --- a/src/context/Response.ts +++ b/src/context/Response.ts @@ -13,6 +13,7 @@ import type { FastifyReply } from 'fastify' import type { SendOptions } from '@fastify/static' import type { Request } from '#src/context/Request' import type { FastifyHelmetOptions } from '@fastify/helmet' +import { parseResponseWithZod } from '#src/router/RouteSchema' export class Response extends Macroable { /** @@ -154,6 +155,12 @@ export class Response extends Macroable { * ``` */ public async send(data?: any): Promise { + const zodSchemas = this.getRouteZodSchemas() + + if (zodSchemas) { + data = await parseResponseWithZod(this.response, data, zodSchemas) + } + await this.response.send(data) this.response.body = data @@ -161,6 +168,12 @@ export class Response extends Macroable { return this } + private getRouteZodSchemas() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return this.response.request?.routeOptions?.config?.zod || null + } + /** * @example * ```ts diff --git a/src/exceptions/ResponseValidationException.ts b/src/exceptions/ResponseValidationException.ts new file mode 100644 index 0000000..d98e7f4 --- /dev/null +++ b/src/exceptions/ResponseValidationException.ts @@ -0,0 +1,14 @@ +import type { ZodError } from 'zod' +import { HttpException } from '#src/exceptions/HttpException' + +export class ResponseValidationException extends HttpException { + public constructor(error: ZodError) { + const name = 'ResponseValidationException' + const code = 'E_RESPONSE_VALIDATION_ERROR' + const status = 500 + const message = 'The server failed to generate a valid response.' + const details = error.issues + + super({ name, message, status, code, details }) + } +} diff --git a/src/handlers/HttpExceptionHandler.ts b/src/handlers/HttpExceptionHandler.ts index 35e9c1e..ce1f207 100644 --- a/src/handlers/HttpExceptionHandler.ts +++ b/src/handlers/HttpExceptionHandler.ts @@ -65,7 +65,7 @@ export class HttpExceptionHandler extends ExceptionHandler { delete body.stack } - response.status(body.statusCode).send(body) + await response.status(body.statusCode).send(body) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/src/router/RouteSchema.ts b/src/router/RouteSchema.ts index a7a802b..fe2c2fb 100644 --- a/src/router/RouteSchema.ts +++ b/src/router/RouteSchema.ts @@ -11,6 +11,7 @@ import type { ZodAny } from 'zod' import { Is } from '@athenna/common' import type { FastifyReply, FastifyRequest, FastifySchema } from 'fastify' import { ZodValidationException } from '#src/exceptions/ZodValidationException' +import { ResponseValidationException } from '#src/exceptions/ResponseValidationException' type ZodRequestSchema = Partial< Record<'body' | 'headers' | 'params' | 'querystring', ZodAny> @@ -112,7 +113,9 @@ export async function parseResponseWithZod( return payload } - return parseSchema(schema, payload) + return parseSchema(schema, payload).catch(error => { + throw new ResponseValidationException(error) + }) } function getResponseSchema( diff --git a/src/server/ServerImpl.ts b/src/server/ServerImpl.ts index ecf5d51..a8c6527 100644 --- a/src/server/ServerImpl.ts +++ b/src/server/ServerImpl.ts @@ -30,10 +30,9 @@ import type { } from '#src/types' import { - type RouteSchemaOptions, - normalizeRouteSchema, parseRequestWithZod, - parseResponseWithZod + normalizeRouteSchema, + type RouteSchemaOptions } from '#src/router/RouteSchema' import type { AddressInfo } from 'node:net' @@ -292,7 +291,6 @@ export class ServerImpl extends Macroable { onSend: [], preValidation: [], preHandler: [], - preSerialization: [], onResponse: [], url: options.url, method: options.methods, @@ -313,10 +311,6 @@ export class ServerImpl extends Macroable { 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)) { @@ -335,10 +329,6 @@ export class ServerImpl extends Macroable { ...this.toRouteHooks(route.preValidation), ...this.toRouteHooks(fastifyOptions.preValidation) ] - fastifyOptions.preSerialization = [ - ...this.toRouteHooks(route.preSerialization), - ...this.toRouteHooks(fastifyOptions.preSerialization) - ] } this.fastify.route({ ...route, ...fastifyOptions }) diff --git a/tests/unit/kernels/HttpKernelTest.ts b/tests/unit/kernels/HttpKernelTest.ts index 23adc28..d9b6c5e 100644 --- a/tests/unit/kernels/HttpKernelTest.ts +++ b/tests/unit/kernels/HttpKernelTest.ts @@ -12,6 +12,7 @@ import { Path, Module } from '@athenna/common' import { Log, LoggerProvider } from '@athenna/logger' import { HttpKernel, HttpServerProvider, HttpRouteProvider, Server, Route } from '#src' import { Test, Mock, AfterEach, BeforeEach, type Context, Cleanup } from '@athenna/test' +import { z } from 'zod' export default class HttpKernelTest { @BeforeEach() @@ -443,6 +444,63 @@ export default class HttpKernelTest { assert.deepEqual(response.json(), { handled: true, intercepted: true }) } + @Test() + @Cleanup(() => Config.set('openapi.paths', {})) + public async shouldNotTriggerUnhandledErrorsWhenZodResponseValidationFailsWithGlobalInterceptors({ + assert + }: Context) { + let unhandledRejectionHappened = false + let uncaughtExceptionHappened = false + + const onUnhandledRejection = () => { + unhandledRejectionHappened = true + } + + const onUncaughtException = () => { + uncaughtExceptionHappened = true + } + + process.on('unhandledRejection', onUnhandledRejection) + process.on('uncaughtException', onUncaughtException) + + const kernel = new HttpKernel() + await kernel.registerGlobalMiddlewares() + await kernel.registerExceptionHandler() + + Config.set('openapi.paths', { + '/zod-error': { + get: { + response: { + 200: z.object({ + hello: z.number() + }) + } + } + } + }) + + Route.get('/zod-error', async ctx => { + await ctx.response.send({ hello: 'world' }) + }) + + Route.register() + + const response = await Server.request().get('zod-error') + + await new Promise(resolve => setTimeout(resolve, 20)) + + process.removeListener('unhandledRejection', onUnhandledRejection) + process.removeListener('uncaughtException', onUncaughtException) + + assert.equal(response.statusCode, 500) + assert.containSubset(response.json(), { + code: 'E_RESPONSE_VALIDATION_ERROR', + statusCode: 500 + }) + assert.isFalse(unhandledRejectionHappened) + assert.isFalse(uncaughtExceptionHappened) + } + @Test() public async shouldBeAbleToRegisterTheDefaultExceptionHandlerForTheServerRequestHandlers({ assert }: Context) { Log.when('channelOrVanilla').return({ diff --git a/tests/unit/router/RouteResourceTest.ts b/tests/unit/router/RouteResourceTest.ts index 49178b0..b17ab79 100644 --- a/tests/unit/router/RouteResourceTest.ts +++ b/tests/unit/router/RouteResourceTest.ts @@ -464,7 +464,9 @@ export default class RouteResourceTest { @Test() @Cleanup(() => Config.set('openapi.paths', {})) - public async shouldAutomaticallyThrowValidationExceptionWhenSchemaIsInvalidInResources({ assert }: Context) { + public async shouldAutomaticallyThrowInternalServerExceptionWhenResponseSchemaIsInvalidInResources({ + assert + }: Context) { await new HttpKernel().registerExceptionHandler() Config.set('openapi.paths', { @@ -488,20 +490,11 @@ export default class RouteResourceTest { method: 'get' }) - assert.equal(response.statusCode, 422) + assert.equal(response.statusCode, 500) 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' - } - ] + code: 'E_RESPONSE_VALIDATION_ERROR', + statusCode: 500 }) + assert.isUndefined(response.json().details) } }