mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
perf(api): gate response schema revalidation to non-production (#2134)
This commit is contained in:
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'});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user