fastify 5.12.3 → 5.12.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/Reference/Reply.md +3 -2
- package/fastify.js +1 -1
- package/lib/content-type-parser.js +7 -1
- package/lib/reply.js +5 -2
- package/lib/validation.js +6 -4
- package/package.json +1 -1
- package/test/content-type-parser-async-context.test.js +76 -0
- package/test/custom-parser.4.test.js +40 -0
- package/test/http2/plain.test.js +45 -0
- package/test/internals/validation.test.js +66 -0
package/docs/Reference/Reply.md
CHANGED
|
@@ -277,8 +277,9 @@ requires heavy resources to be sent after the `data`, for example,
|
|
|
277
277
|
as soon as possible.
|
|
278
278
|
|
|
279
279
|
> ℹ️ Note:
|
|
280
|
-
>
|
|
281
|
-
>
|
|
280
|
+
> For HTTP/1 responses, the header `Transfer-Encoding: chunked` will be added
|
|
281
|
+
> once you use a trailer. HTTP/2 does not use chunked transfer encoding and
|
|
282
|
+
> sends trailers using its native trailing headers support.
|
|
282
283
|
|
|
283
284
|
> ℹ️ Note:
|
|
284
285
|
> Any error passed to `done` callback will be ignored. If you are interested
|
package/fastify.js
CHANGED
|
@@ -140,6 +140,7 @@ ContentTypeParser.prototype.getParser = function (contentType) {
|
|
|
140
140
|
// with that type.
|
|
141
141
|
parser = this.customParsers.get(contentType.mediaType)
|
|
142
142
|
if (parser !== undefined) {
|
|
143
|
+
this.cache.set(ct, parser)
|
|
143
144
|
return parser
|
|
144
145
|
}
|
|
145
146
|
|
|
@@ -203,7 +204,12 @@ ContentTypeParser.prototype.run = function (contentType, handler, request, reply
|
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
const resource = new AsyncResource('content-type-parser:run', request)
|
|
206
|
-
|
|
207
|
+
|
|
208
|
+
// Preserve the request's async context without the per-request function
|
|
209
|
+
// properties and wrappers created by AsyncResource.bind().
|
|
210
|
+
function done (error, body) {
|
|
211
|
+
resource.runInAsyncScope(onDone, undefined, error, body)
|
|
212
|
+
}
|
|
207
213
|
|
|
208
214
|
if (parser.asString === true || parser.asBuffer === true) {
|
|
209
215
|
rawBody(
|
package/lib/reply.js
CHANGED
|
@@ -606,8 +606,11 @@ function onSendEnd (reply, payload) {
|
|
|
606
606
|
header += trailerName
|
|
607
607
|
}
|
|
608
608
|
if (header !== '') {
|
|
609
|
-
//
|
|
610
|
-
|
|
609
|
+
// HTTP/1 must use chunked encoding for trailers. HTTP/2 forbids
|
|
610
|
+
// the transfer-encoding header and handles trailers natively.
|
|
611
|
+
if (!isHttp2Reply(reply)) {
|
|
612
|
+
reply.header('Transfer-Encoding', 'chunked')
|
|
613
|
+
}
|
|
611
614
|
reply.header('Trailer', header.trim())
|
|
612
615
|
} else {
|
|
613
616
|
reply[kReplyTrailers] = null
|
package/lib/validation.js
CHANGED
|
@@ -284,19 +284,21 @@ function compileSchemasForValidation (context, compile, isCustom) {
|
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
function validateParam (validatorFunction, request, paramName) {
|
|
287
|
-
|
|
287
|
+
// The headers getter may allocate a new object, so only read it when needed.
|
|
288
|
+
if (validatorFunction == null) return false
|
|
289
|
+
const value = request[paramName]
|
|
288
290
|
let ret
|
|
289
291
|
|
|
290
292
|
try {
|
|
291
|
-
const data =
|
|
293
|
+
const data = value === undefined ? null : value
|
|
292
294
|
// Ajv can only mutate a root value when its parent is provided.
|
|
293
|
-
if (validatorFunction
|
|
295
|
+
if (validatorFunction.schemaEnv) {
|
|
294
296
|
ret = validatorFunction(data, {
|
|
295
297
|
parentData: request,
|
|
296
298
|
parentDataProperty: paramName
|
|
297
299
|
})
|
|
298
300
|
} else {
|
|
299
|
-
ret = validatorFunction
|
|
301
|
+
ret = validatorFunction(data)
|
|
300
302
|
}
|
|
301
303
|
} catch (err) {
|
|
302
304
|
// If validator throws synchronously, ensure it propagates as an internal error
|
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { AsyncLocalStorage, AsyncResource, createHook, executionAsyncId } = require('node:async_hooks')
|
|
4
|
+
const { setImmediate: immediate } = require('node:timers/promises')
|
|
5
|
+
const { test } = require('node:test')
|
|
6
|
+
const Fastify = require('..')
|
|
7
|
+
|
|
8
|
+
test('content type parser callbacks restore request context and destroy their resources', async t => {
|
|
9
|
+
const storage = new AsyncLocalStorage()
|
|
10
|
+
const external = new AsyncResource('external-parser')
|
|
11
|
+
const resources = new Set()
|
|
12
|
+
const destroyed = new Set()
|
|
13
|
+
const hook = createHook({
|
|
14
|
+
init (id, type) {
|
|
15
|
+
if (type === 'content-type-parser:run') resources.add(id)
|
|
16
|
+
},
|
|
17
|
+
destroy (id) {
|
|
18
|
+
if (resources.has(id)) destroyed.add(id)
|
|
19
|
+
}
|
|
20
|
+
}).enable()
|
|
21
|
+
const app = Fastify()
|
|
22
|
+
t.after(async () => {
|
|
23
|
+
hook.disable()
|
|
24
|
+
external.emitDestroy()
|
|
25
|
+
storage.disable()
|
|
26
|
+
await app.close()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
app.addHook('onRequest', (request, reply, done) => {
|
|
30
|
+
storage.run({ id: request.headers['x-request-id'] }, done)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
app.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
|
|
34
|
+
external.runInAsyncScope(() => {
|
|
35
|
+
t.assert.strictEqual(storage.getStore(), undefined)
|
|
36
|
+
setImmediate(() => {
|
|
37
|
+
if (body === 'error') {
|
|
38
|
+
done(new Error('parser failed'))
|
|
39
|
+
} else {
|
|
40
|
+
done(null, body)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
function assertContext (request) {
|
|
47
|
+
t.assert.deepStrictEqual(storage.getStore(), { id: request.headers['x-request-id'] })
|
|
48
|
+
t.assert.ok(resources.has(executionAsyncId()))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
app.setErrorHandler((error, request, reply) => {
|
|
52
|
+
assertContext(request)
|
|
53
|
+
reply.code(400).send({ error: error.message, id: storage.getStore().id })
|
|
54
|
+
})
|
|
55
|
+
app.post('/', (request, reply) => {
|
|
56
|
+
assertContext(request)
|
|
57
|
+
return { body: request.body, id: storage.getStore().id }
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const responses = await Promise.all(['first', 'second', 'error'].map(id => app.inject({
|
|
61
|
+
method: 'POST',
|
|
62
|
+
url: '/',
|
|
63
|
+
headers: { 'content-type': 'application/custom', 'x-request-id': id },
|
|
64
|
+
payload: id
|
|
65
|
+
})))
|
|
66
|
+
|
|
67
|
+
t.assert.deepStrictEqual(responses.map(response => response.statusCode), [200, 200, 400])
|
|
68
|
+
t.assert.deepStrictEqual(responses.map(response => response.json()), [
|
|
69
|
+
{ body: 'first', id: 'first' },
|
|
70
|
+
{ body: 'second', id: 'second' },
|
|
71
|
+
{ error: 'parser failed', id: 'error' }
|
|
72
|
+
])
|
|
73
|
+
await immediate()
|
|
74
|
+
t.assert.strictEqual(resources.size, 3)
|
|
75
|
+
t.assert.deepStrictEqual(destroyed, resources)
|
|
76
|
+
})
|
|
@@ -6,6 +6,46 @@ const jsonParser = require('fast-json-body')
|
|
|
6
6
|
|
|
7
7
|
process.removeAllListeners('warning')
|
|
8
8
|
|
|
9
|
+
test('parameterized parser matches preserve precedence and encapsulation on repeated requests', async t => {
|
|
10
|
+
const fastify = Fastify()
|
|
11
|
+
t.after(() => fastify.close())
|
|
12
|
+
|
|
13
|
+
fastify.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
|
|
14
|
+
done(null, 'parent')
|
|
15
|
+
})
|
|
16
|
+
fastify.addContentTypeParser('application/custom; version=2', { parseAs: 'string' }, (request, body, done) => {
|
|
17
|
+
done(null, 'version 2')
|
|
18
|
+
})
|
|
19
|
+
fastify.post('/', request => request.body)
|
|
20
|
+
|
|
21
|
+
fastify.register(async child => {
|
|
22
|
+
child.removeContentTypeParser('application/custom')
|
|
23
|
+
child.addContentTypeParser('application/custom', { parseAs: 'string' }, (request, body, done) => {
|
|
24
|
+
done(null, 'child')
|
|
25
|
+
})
|
|
26
|
+
child.post('/child', request => request.body)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < 3; i++) {
|
|
30
|
+
for (const [url, contentType, expected] of [
|
|
31
|
+
['/', 'application/custom; charset=utf-8', 'parent'],
|
|
32
|
+
['/child', 'application/custom; charset=utf-8', 'child'],
|
|
33
|
+
['/', 'application/custom; version=2', 'version 2'],
|
|
34
|
+
['/child', 'application/custom; version=2', 'version 2'],
|
|
35
|
+
['/', 'Application/Custom ; Charset="utf-8"', 'parent']
|
|
36
|
+
]) {
|
|
37
|
+
const response = await fastify.inject({
|
|
38
|
+
method: 'POST',
|
|
39
|
+
url,
|
|
40
|
+
headers: { 'content-type': contentType },
|
|
41
|
+
payload: 'body'
|
|
42
|
+
})
|
|
43
|
+
t.assert.strictEqual(response.statusCode, 200)
|
|
44
|
+
t.assert.strictEqual(response.body, expected)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
9
49
|
test('should prefer string content types over RegExp ones', async (t) => {
|
|
10
50
|
t.plan(6)
|
|
11
51
|
const fastify = Fastify()
|
package/test/http2/plain.test.js
CHANGED
|
@@ -68,6 +68,51 @@ test('http2 plain test', async t => {
|
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
test('http2 response trailers do not use transfer-encoding', async t => {
|
|
72
|
+
const fastify = Fastify({ http2: true })
|
|
73
|
+
|
|
74
|
+
fastify.get('/', async (request, reply) => {
|
|
75
|
+
reply.trailer('x-checksum', async () => 'abc')
|
|
76
|
+
return 'hello'
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await fastify.listen({ port: 0, host: '127.0.0.1' })
|
|
80
|
+
|
|
81
|
+
const client = http2.connect(`http://127.0.0.1:${fastify.server.address().port}`)
|
|
82
|
+
t.after(() => {
|
|
83
|
+
client.close()
|
|
84
|
+
return fastify.close()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const response = await new Promise((resolve, reject) => {
|
|
88
|
+
const request = client.request({
|
|
89
|
+
[http2.constants.HTTP2_HEADER_METHOD]: http2.constants.HTTP2_METHOD_GET,
|
|
90
|
+
[http2.constants.HTTP2_HEADER_PATH]: '/'
|
|
91
|
+
})
|
|
92
|
+
const result = { body: '' }
|
|
93
|
+
|
|
94
|
+
request.setEncoding('utf8')
|
|
95
|
+
request.on('response', headers => {
|
|
96
|
+
result.headers = headers
|
|
97
|
+
})
|
|
98
|
+
request.on('trailers', trailers => {
|
|
99
|
+
result.trailers = trailers
|
|
100
|
+
})
|
|
101
|
+
request.on('data', chunk => {
|
|
102
|
+
result.body += chunk
|
|
103
|
+
})
|
|
104
|
+
request.on('error', reject)
|
|
105
|
+
request.on('end', () => resolve(result))
|
|
106
|
+
request.end()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
t.assert.strictEqual(response.headers[':status'], 200)
|
|
110
|
+
t.assert.strictEqual(response.headers['transfer-encoding'], undefined)
|
|
111
|
+
t.assert.strictEqual(response.headers.trailer, 'x-checksum')
|
|
112
|
+
t.assert.strictEqual(response.body, 'hello')
|
|
113
|
+
t.assert.strictEqual(response.trailers['x-checksum'], 'abc')
|
|
114
|
+
})
|
|
115
|
+
|
|
71
116
|
test('http2 large non-stream replies are sent completely', async t => {
|
|
72
117
|
const modes = ['buffer', 'string']
|
|
73
118
|
|
|
@@ -10,6 +10,72 @@ const { normalizeSchema } = require('../../lib/schemas')
|
|
|
10
10
|
const symbols = require('../../lib/validation').symbols
|
|
11
11
|
const { kSchemaVisited } = require('../../lib/symbols')
|
|
12
12
|
|
|
13
|
+
for (const [part, symbol] of [
|
|
14
|
+
['params', symbols.paramsSchema],
|
|
15
|
+
['body', symbols.bodySchema],
|
|
16
|
+
['query', symbols.querystringSchema],
|
|
17
|
+
['headers', symbols.headersSchema]
|
|
18
|
+
]) {
|
|
19
|
+
for (const async of [false, true]) {
|
|
20
|
+
test(`validate reads only the validated ${part} once (${async ? 'async' : 'sync'})`, async t => {
|
|
21
|
+
const request = {}
|
|
22
|
+
const value = { hello: 'world' }
|
|
23
|
+
let reads = 0
|
|
24
|
+
let calls = 0
|
|
25
|
+
|
|
26
|
+
for (const name of ['params', 'body', 'query', 'headers']) {
|
|
27
|
+
Object.defineProperty(request, name, {
|
|
28
|
+
get () {
|
|
29
|
+
t.assert.strictEqual(name, part, 'unvalidated request parts must not be read')
|
|
30
|
+
reads++
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const context = {
|
|
37
|
+
[symbol]: data => {
|
|
38
|
+
calls++
|
|
39
|
+
t.assert.strictEqual(data, value)
|
|
40
|
+
return async ? Promise.resolve(data) : true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
t.assert.strictEqual(await validation.validate(context, request), false)
|
|
45
|
+
t.assert.strictEqual(reads, 1)
|
|
46
|
+
t.assert.strictEqual(calls, 1)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('validate skips a body without a matching content-type schema', t => {
|
|
52
|
+
const context = { [symbols.bodySchema]: { 'application/json': () => true } }
|
|
53
|
+
const request = {
|
|
54
|
+
mediaType: 'text/plain',
|
|
55
|
+
get body () {
|
|
56
|
+
t.assert.fail('an unvalidated body must not be read')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
t.assert.strictEqual(validation.validate(context, request), false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('validate passes null for an undefined request part and preserves other values', t => {
|
|
64
|
+
for (const value of [undefined, null, false, 0, '']) {
|
|
65
|
+
let calls = 0
|
|
66
|
+
const context = {
|
|
67
|
+
[symbols.bodySchema]: data => {
|
|
68
|
+
calls++
|
|
69
|
+
t.assert.strictEqual(data, value === undefined ? null : value)
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
t.assert.strictEqual(validation.validate(context, { body: value }), false)
|
|
75
|
+
t.assert.strictEqual(calls, 1)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
13
79
|
test('Symbols', t => {
|
|
14
80
|
t.plan(5)
|
|
15
81
|
t.assert.strictEqual(typeof symbols.responseSchema, 'symbol')
|