Skip to content

Commit 16e409c

Browse files
committed
fix: Replicate AJV options in response validator. Fixes #9.
1 parent 24b5d09 commit 16e409c

File tree

2 files changed

+104
-22
lines changed

2 files changed

+104
-22
lines changed

src/validation.ts

+60-19
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import Ajv from 'ajv'
1+
import { Ajv, type Options } from 'ajv'
2+
import addFormats from 'ajv-formats'
23
import {
4+
FastifyServerOptions,
35
type FastifyInstance,
46
type FastifyReply,
57
type FastifyRequest,
@@ -18,11 +20,56 @@ import {
1820
} from './interfaces.js'
1921
import { get } from './utils.js'
2022

23+
// Fix CJS/ESM interoperability
24+
2125
export interface ValidationResult extends FastifyValidationResult {
2226
dataPath: any
2327
instancePath: string
2428
}
2529

30+
/* c8 ignore next 15 */
31+
/*
32+
The fastify defaults, with the following modifications:
33+
* coerceTypes is set to false
34+
* removeAdditional is set to false
35+
* allErrors is set to true
36+
* uriResolver has been removed
37+
*/
38+
export const defaultAjvOptions: Options = {
39+
coerceTypes: false,
40+
useDefaults: true,
41+
removeAdditional: false,
42+
addUsedSchema: false,
43+
allErrors: true
44+
}
45+
46+
function buildAjv(options?: Options, plugins?: (Function | [Function, unknown])[]): Ajv {
47+
// Create the instance
48+
const compiler: Ajv = new Ajv({
49+
...defaultAjvOptions,
50+
...options
51+
})
52+
53+
// Add plugins
54+
let formatPluginAdded = false
55+
for (const pluginSpec of plugins ?? []) {
56+
const [plugin, pluginOpts]: [Function, unknown] = Array.isArray(pluginSpec) ? pluginSpec : [pluginSpec, undefined]
57+
58+
if (plugin.name === 'formatsPlugin') {
59+
formatPluginAdded = true
60+
}
61+
62+
plugin(compiler, pluginOpts)
63+
}
64+
65+
if (!formatPluginAdded) {
66+
// @ts-expect-error Wrong typing
67+
addFormats(compiler)
68+
}
69+
70+
return compiler
71+
}
72+
2673
export function niceJoin(array: string[], lastSeparator: string = ' and ', separator: string = ', '): string {
2774
switch (array.length) {
2875
case 0:
@@ -89,13 +136,15 @@ export const validationMessagesFormatters: Record<string, ValidationFormatter> =
89136
invalidResponse: code =>
90137
`The response returned from the endpoint violates its specification for the HTTP status ${code}.`,
91138
invalidFormat: format => `must match format "${format}" (format)`
139+
/* c8 ignore next */
92140
}
93141

94142
export function convertValidationErrors(
95143
section: RequestSection,
96144
data: Record<string, unknown>,
97145
validationErrors: ValidationResult[]
98146
): Validations {
147+
/* c8 ignore next 2 */
99148
const errors: Record<string, string> = {}
100149

101150
if (section === 'querystring') {
@@ -182,6 +231,7 @@ export function convertValidationErrors(
182231
}
183232

184233
// No custom message was found, default to input one replacing the starting verb and adding some path info
234+
/* c8 ignore next 3 */
185235
if (!message.length) {
186236
message = `${e.message?.replace(/^should/, 'must')} (${e.keyword})`
187237
}
@@ -272,31 +322,22 @@ export function addResponseValidation(this: FastifyInstance, route: RouteOptions
272322
}
273323

274324
export function compileResponseValidationSchema(this: FastifyInstance, configuration: Configuration): void {
275-
// Fix CJS/ESM interoperability
276-
// @ts-expect-error Fix types
277-
let AjvConstructor = Ajv as Ajv & { default?: Ajv }
278-
279-
if (AjvConstructor.default) {
280-
AjvConstructor = AjvConstructor.default
281-
}
282-
325+
/* c8 ignore next 3 */
283326
const hasCustomizer = typeof configuration.responseValidatorCustomizer === 'function'
284327

328+
// This is hackish, but it is the only way to get the options from fastify at the moment.
329+
const kOptions = Object.getOwnPropertySymbols(this).find(s => s.description === 'fastify.options')!
330+
285331
for (const [instance, validators, schemas] of this[kHttpErrorsEnhancedResponseValidations]) {
286-
// @ts-expect-error Fix types
287-
const compiler: Ajv = new AjvConstructor({
288-
// The fastify defaults, with the exception of removeAdditional and coerceTypes, which have been reversed
289-
removeAdditional: false,
290-
useDefaults: true,
291-
coerceTypes: false,
292-
allErrors: true
293-
})
332+
// Create the compiler using exactly the same options as fastify
333+
const ajvOptions = (instance[kOptions as keyof FastifyInstance] as FastifyServerOptions)?.ajv ?? {}
334+
const compiler = buildAjv(ajvOptions.customOptions, ajvOptions.plugins)
294335

336+
// Add instance schemas
295337
compiler.addSchema(Object.values(instance.getSchemas()))
296-
compiler.addKeyword('example')
297338

339+
// Customize if required to
298340
if (hasCustomizer) {
299-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
300341
configuration.responseValidatorCustomizer!(compiler)
301342
}
302343

test/validation.test.ts

+44-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-floating-promises */
2-
31
import Ajv from 'ajv'
42
import addFormats from 'ajv-formats'
53
import fastify, {
@@ -15,7 +13,18 @@ import { convertValidationErrors, plugin as fastifyErrorProperties, niceJoin } f
1513
import { type ValidationResult } from '../src/validation.js'
1614

1715
async function buildServer(options: FastifyPluginOptions = {}): Promise<FastifyInstance> {
18-
const server = fastify()
16+
const server = fastify({
17+
ajv: {
18+
plugins: [addFormats],
19+
customOptions: {
20+
formats: {
21+
sequence(data: string): boolean {
22+
return data === '123'
23+
}
24+
}
25+
}
26+
}
27+
})
1928

2029
await server.register(fastifyErrorProperties, options)
2130

@@ -37,6 +46,29 @@ async function buildServer(options: FastifyPluginOptions = {}): Promise<FastifyI
3746
}
3847
})
3948

49+
server.get('/formats', {
50+
schema: {
51+
response: {
52+
[OK]: {
53+
type: 'object',
54+
properties: {
55+
email: {
56+
type: 'string',
57+
format: 'email'
58+
},
59+
sequence: {
60+
type: 'string',
61+
format: 'sequence'
62+
}
63+
}
64+
}
65+
}
66+
},
67+
handler(_: FastifyRequest, reply: FastifyReply) {
68+
reply.send({ email: '[email protected]', sequence: '123' })
69+
}
70+
})
71+
4072
server.get('/bad-code', {
4173
schema: {
4274
response: {
@@ -395,6 +427,15 @@ test('Response Validation', async () => {
395427
deepStrictEqual(JSON.parse(response.payload), { a: '1' })
396428
})
397429

430+
await test('should allow custom formats', async t => {
431+
const server = await buildServer()
432+
433+
const response = await server.inject({ method: 'GET', url: '/formats' })
434+
435+
deepStrictEqual(response.statusCode, OK)
436+
deepStrictEqual(JSON.parse(response.payload), { email: '[email protected]', sequence: '123' })
437+
})
438+
398439
await test('should validate the response code', async t => {
399440
const server = await buildServer()
400441

0 commit comments

Comments
 (0)