diff --git a/package-lock.json b/package-lock.json index 813e615..9b8738a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/http", - "version": "5.48.0", + "version": "5.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/http", - "version": "5.48.0", + "version": "5.49.0", "license": "MIT", "devDependencies": { "@athenna/artisan": "^5.11.0", diff --git a/package.json b/package.json index 0aef60d..bc1323a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/http", - "version": "5.48.0", + "version": "5.49.0", "description": "The Athenna Http server. Built on top of fastify.", "license": "MIT", "author": "João Lenon ", diff --git a/src/exceptions/ResponseValidationException.ts b/src/exceptions/ResponseValidationException.ts deleted file mode 100644 index d98e7f4..0000000 --- a/src/exceptions/ResponseValidationException.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/router/Route.ts b/src/router/Route.ts index 76d3dc8..33182e0 100644 --- a/src/router/Route.ts +++ b/src/router/Route.ts @@ -346,7 +346,7 @@ export class Route extends Macroable { * ``` */ public schema(options: RouteSchemaOptions): Route { - const { schema, zod } = normalizeRouteSchema(options) + const { schema, swaggerSchema, zod } = normalizeRouteSchema(options) this.route.fastify.schema = schema @@ -354,10 +354,19 @@ export class Route extends Macroable { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.route.fastify.config.zod = zod + + if (Object.keys(zod.response).length) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.route.fastify.config.swaggerSchema = swaggerSchema + } } else { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore delete this.route.fastify.config.zod + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete this.route.fastify.config.swaggerSchema } return this diff --git a/src/router/RouteSchema.ts b/src/router/RouteSchema.ts index 7da7898..4d926b6 100644 --- a/src/router/RouteSchema.ts +++ b/src/router/RouteSchema.ts @@ -11,7 +11,6 @@ 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> @@ -34,11 +33,13 @@ export type RouteZodSchemas = { export function normalizeRouteSchema(options: RouteSchemaOptions): { schema: FastifySchema + swaggerSchema: FastifySchema zod: RouteZodSchemas | null } { const request: ZodRequestSchema = {} const response: ZodResponseSchema = {} const schema: FastifySchema = { ...options } + const swaggerSchema: FastifySchema = { ...options } const requestKeys = ['body', 'headers', 'params', 'querystring'] as const @@ -49,10 +50,12 @@ export function normalizeRouteSchema(options: RouteSchemaOptions): { request[key] = options[key] schema[key] = toJsonSchema(options[key], 'input') + swaggerSchema[key] = toJsonSchema(options[key], 'input') }) if (options.response && Is.Object(options.response)) { schema.response = { ...options.response } + swaggerSchema.response = { ...options.response } Object.entries(options.response).forEach(([statusCode, value]) => { if (!isZodSchema(value)) { @@ -60,8 +63,13 @@ export function normalizeRouteSchema(options: RouteSchemaOptions): { } response[statusCode] = value - schema.response[statusCode] = toJsonSchema(value, 'output') + swaggerSchema.response[statusCode] = toJsonSchema(value, 'output') + delete schema.response[statusCode] }) + + if (!Object.keys(schema.response).length) { + delete schema.response + } } const hasZodSchemas = @@ -69,6 +77,7 @@ export function normalizeRouteSchema(options: RouteSchemaOptions): { return { schema, + swaggerSchema, zod: hasZodSchemas ? { request, response } : null } } @@ -107,9 +116,9 @@ export async function parseResponseWithZod( return payload } - return parseSchema(schema, payload).catch(error => { - throw new ResponseValidationException(error) - }) + const result = await schema.safeParseAsync(payload) + + return result.success ? result.data : payload } function getResponseSchema( diff --git a/src/server/ServerImpl.ts b/src/server/ServerImpl.ts index a8c6527..2dba5f5 100644 --- a/src/server/ServerImpl.ts +++ b/src/server/ServerImpl.ts @@ -396,17 +396,31 @@ export class ServerImpl extends Macroable { const fastifyOptions = { ...options.fastify } if (!automaticSchema) { + this.configureSwaggerTransform(fastifyOptions) + return fastifyOptions } const normalizedSchema = normalizeRouteSchema(automaticSchema) const currentConfig = { ...(fastifyOptions.config || {}) } + + const currentSwaggerSchema = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + currentConfig.swaggerSchema || fastifyOptions.schema fastifyOptions.schema = this.mergeFastifySchemas( normalizedSchema.schema, fastifyOptions.schema ) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + currentConfig.swaggerSchema = this.mergeFastifySchemas( + normalizedSchema.swaggerSchema, + currentSwaggerSchema + ) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const currentZod = currentConfig.zod @@ -419,10 +433,43 @@ export class ServerImpl extends Macroable { } fastifyOptions.config = currentConfig + this.configureSwaggerTransform(fastifyOptions) return fastifyOptions } + private configureSwaggerTransform(fastifyOptions: any) { + const config = fastifyOptions?.config + + if (!config?.swaggerSchema) { + return + } + + const customTransform = config.swaggerTransform + + if (customTransform === false) { + return + } + + config.swaggerTransform = (args: any) => { + const transformed = Is.Function(customTransform) + ? customTransform(args) + : args + + if (transformed === false) { + return false + } + + return { + ...transformed, + schema: this.mergeFastifySchemas( + transformed?.schema || args.schema, + config.swaggerSchema + ) + } + } + } + private getOpenApiRouteSchema(options: RouteJson): RouteSchemaOptions { const paths = Config.get('openapi.paths', {}) const methods = options.methods || [] diff --git a/tests/unit/kernels/HttpKernelTest.ts b/tests/unit/kernels/HttpKernelTest.ts index d9b6c5e..d3783e3 100644 --- a/tests/unit/kernels/HttpKernelTest.ts +++ b/tests/unit/kernels/HttpKernelTest.ts @@ -446,9 +446,7 @@ export default class HttpKernelTest { @Test() @Cleanup(() => Config.set('openapi.paths', {})) - public async shouldNotTriggerUnhandledErrorsWhenZodResponseValidationFailsWithGlobalInterceptors({ - assert - }: Context) { + public async shouldIgnoreInvalidZodResponseSchemaWithGlobalInterceptors({ assert }: Context) { let unhandledRejectionHappened = false let uncaughtExceptionHappened = false @@ -492,10 +490,10 @@ export default class HttpKernelTest { 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.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + hello: 'world', + intercepted: true }) assert.isFalse(unhandledRejectionHappened) assert.isFalse(uncaughtExceptionHappened) diff --git a/tests/unit/router/RouteResourceTest.ts b/tests/unit/router/RouteResourceTest.ts index b17ab79..c037482 100644 --- a/tests/unit/router/RouteResourceTest.ts +++ b/tests/unit/router/RouteResourceTest.ts @@ -7,15 +7,15 @@ * file that was distributed with this source code. */ +import { z } from 'zod' +import { Config } from '@athenna/config' 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 { Config } from '@athenna/config' +import { Route, Server, HttpRouteProvider, HttpServerProvider } from '#src' import { Test, AfterEach, BeforeEach, type Context, Cleanup } from '@athenna/test' import { HelloController } from '#tests/fixtures/controllers/HelloController' -import { Route, Server, HttpKernel, HttpRouteProvider, HttpServerProvider } from '#src' -import z from 'zod' export default class RouteResourceTest { @BeforeEach() @@ -464,11 +464,7 @@ export default class RouteResourceTest { @Test() @Cleanup(() => Config.set('openapi.paths', {})) - public async shouldAutomaticallyThrowInternalServerExceptionWhenResponseSchemaIsInvalidInResources({ - assert - }: Context) { - await new HttpKernel().registerExceptionHandler() - + public async shouldIgnoreInvalidResponseSchemaInResources({ assert }: Context) { Config.set('openapi.paths', { '/test': { get: { @@ -482,7 +478,6 @@ export default class RouteResourceTest { }) Route.resource('test', new HelloController()).only(['index']) - Route.register() const response = await Server.request({ @@ -490,11 +485,7 @@ export default class RouteResourceTest { method: 'get' }) - assert.equal(response.statusCode, 500) - assert.containSubset(response.json(), { - code: 'E_RESPONSE_VALIDATION_ERROR', - statusCode: 500 - }) - assert.isUndefined(response.json().details) + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { hello: 'world' }) } }