perf(api): gate response schema revalidation to non-production (#2134)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-30 22:45:29 +02:00
1 parent dd610e4c0f
commit 5bac4fd719
13 files changed
+239 -87

No files matched your search

@@ -3,8 +3,9 @@
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError';
import {createLogger} from '@fluxer/logger/src/Logger';
import type {MiddlewareHandler} from 'hono';
import type {Context, MiddlewareHandler} from 'hono';
import type {ZodType} from 'zod';
import {Config} from '../Config';
import type {HonoEnv} from '../types/HonoEnv';
const responseValidationLogger = createLogger('response_validation');
@@ -28,6 +29,46 @@ class ResponseValidationError extends InternalServerError {
}
}
async function validateAndRewriteResponse(ctx: Context<HonoEnv>, schema: ZodType): Promise<void> {
const response = ctx.res;
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return;
}
if (response.status >= 400) {
return;
}
const clonedResponse = response.clone();
let body: unknown;
try {
body = await clonedResponse.json();
} catch {
return;
}
const result = schema.safeParse(body);
if (!result.success) {
const validationErrors = result.error.issues.map((issue) => ({
path: issue.path.join('.') || 'root',
message: issue.message,
}));
const errorContext = {
method: ctx.req.method,
path: ctx.req.path,
status: response.status,
validationErrors,
body,
};
const responseValidationError = new ResponseValidationError(validationErrors);
responseValidationLogger.error(errorContext, 'Response validation failed');
throw responseValidationError;
}
ctx.res = new Response(stringifyValidatedResponse(result.data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export function ResponseType<T extends ZodType>(
schema: T,
options?: {
@@ -39,49 +80,13 @@ export function ResponseType<T extends ZodType>(
return async (ctx, next) => {
ctx.set('responseSchema' as keyof HonoEnv['Variables'], schema);
await next();
if (skipValidation) {
if (skipValidation || !Config.dev.validateResponses) {
return;
}
const response = ctx.res;
if (allowNoContent && response.status === 204) {
if (allowNoContent && ctx.res.status === 204) {
return;
}
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return;
}
if (response.status >= 400) {
return;
}
const clonedResponse = response.clone();
let body: unknown;
try {
body = await clonedResponse.json();
} catch {
return;
}
const result = schema.safeParse(body);
if (!result.success) {
const validationErrors = result.error.issues.map((issue) => ({
path: issue.path.join('.') || 'root',
message: issue.message,
}));
const errorContext = {
method: ctx.req.method,
path: ctx.req.path,
status: response.status,
validationErrors,
body,
};
const responseValidationError = new ResponseValidationError(validationErrors);
responseValidationLogger.error(errorContext, 'Response validation failed');
throw responseValidationError;
}
ctx.res = new Response(stringifyValidatedResponse(result.data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
await validateAndRewriteResponse(ctx, schema);
};
}
@@ -207,45 +212,9 @@ export function OpenAPI(
ctx.set('openapiMetadata' as keyof HonoEnv['Variables'], fullMetadata);
ctx.set('responseSchema' as keyof HonoEnv['Variables'], schema);
await next();
if (!schema) {
if (!schema || !Config.dev.validateResponses) {
return;
}
const response = ctx.res;
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return;
}
if (response.status >= 400) {
return;
}
const clonedResponse = response.clone();
let body: unknown;
try {
body = await clonedResponse.json();
} catch {
return;
}
const result = schema.safeParse(body);
if (!result.success) {
const validationErrors = result.error.issues.map((issue) => ({
path: issue.path.join('.') || 'root',
message: issue.message,
}));
const errorContext = {
method: ctx.req.method,
path: ctx.req.path,
status: response.status,
validationErrors,
body,
};
const responseValidationError = new ResponseValidationError(validationErrors);
responseValidationLogger.error(errorContext, 'Response validation failed');
throw responseValidationError;
}
ctx.res = new Response(stringifyValidatedResponse(result.data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
await validateAndRewriteResponse(ctx, schema);
};
}
@@ -2,14 +2,23 @@
import {SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
import {afterAll, beforeEach, describe, expect, test} from 'vitest';
import {z} from 'zod';
import {Config} from '../../Config';
import type {HonoEnv} from '../../types/HonoEnv';
import {OpenAPI, ResponseType} from '../ResponseTypeMiddleware';
const SnowflakeResponse = z.object({id: SnowflakeType});
describe('ResponseTypeMiddleware', () => {
beforeEach(() => {
Config.dev.validateResponses = true;
});
afterAll(() => {
Config.dev.validateResponses = true;
});
test('serializes SnowflakeType response transforms as JSON strings', async () => {
const app = new Hono<HonoEnv>();
app.get('/snowflake', ResponseType(SnowflakeResponse), (ctx) => ctx.json({id: '123456789012345678'}));
@@ -39,4 +48,45 @@ describe('ResponseTypeMiddleware', () => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual({id: '123456789012345678'});
});
test('rejects mismatching responses while validation is enabled', async () => {
const app = new Hono<HonoEnv>();
app.get('/snowflake', ResponseType(SnowflakeResponse), (ctx) => ctx.json({id: 'not-a-snowflake'}));
const response = await app.request('/snowflake');
expect(response.status).toBe(500);
});
test('passes the response through untouched while validation is disabled', async () => {
Config.dev.validateResponses = false;
const app = new Hono<HonoEnv>();
app.get('/snowflake', ResponseType(SnowflakeResponse), (ctx) => ctx.json({id: 'not-a-snowflake', extra: 'kept'}));
const response = await app.request('/snowflake');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({id: 'not-a-snowflake', extra: 'kept'});
});
test('passes the OpenAPI response through untouched while validation is disabled', async () => {
Config.dev.validateResponses = false;
const app = new Hono<HonoEnv>();
app.get(
'/snowflake',
OpenAPI({
operationId: 'get_snowflake_unvalidated_test',
summary: 'Get snowflake test',
description: 'Returns an unvalidated payload for response validation gating coverage.',
responseSchema: SnowflakeResponse,
tags: ['Tests'],
}),
(ctx) => ctx.json({id: 'not-a-snowflake', extra: 'kept'}),
);
const response = await app.request('/snowflake');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({id: 'not-a-snowflake', extra: 'kept'});
});
});