fastify 6.0.0-alpha.3 → 6.0.0-alpha.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/SECURITY.md +6 -0
- package/SPONSORS.md +1 -0
- package/docs/Guides/Ecosystem.md +10 -53
- package/docs/Reference/Errors.md +0 -2
- package/docs/Reference/Reply.md +20 -2
- package/docs/Reference/Request.md +8 -0
- package/docs/Reference/Routes.md +6 -2
- package/docs/Reference/Server.md +2 -2
- package/docs/Reference/Validation-and-Serialization.md +33 -0
- package/docs/Reference/Warnings.md +2 -0
- package/fastify.js +1 -1
- package/lib/content-type-parser.js +28 -13
- package/lib/content-type.js +4 -4
- package/lib/errors.js +0 -5
- package/lib/handle-request.js +7 -20
- package/lib/reply.js +11 -4
- package/lib/request.js +9 -5
- package/lib/route.js +6 -2
- package/lib/validation.js +73 -14
- package/lib/warnings.js +10 -1
- package/package.json +1 -1
- package/test/child-logger-factory.test.js +25 -0
- package/test/content-parser.test.js +60 -0
- 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/errors.test.js +1 -11
- package/test/internals/reply-serialize.test.js +20 -0
- package/test/internals/reply.test.js +30 -1
- package/test/internals/request.test.js +50 -0
- package/test/internals/validation.test.js +66 -0
- package/test/reply-media-type.test.js +57 -0
- package/test/rfc-10008.test.js +6 -9
- package/test/schema-feature.test.js +1 -1
- package/test/schema-validation.test.js +251 -1
- package/test/types/errors.tst.ts +0 -1
- package/test/types/fastify.tst.ts +5 -0
- package/test/types/route.tst.ts +21 -0
- package/types/errors.d.ts +0 -1
- package/types/instance.d.ts +4 -4
- package/types/route.d.ts +3 -2
package/lib/request.js
CHANGED
|
@@ -122,8 +122,10 @@ function buildRequestWithTrustProxy (R, trustProxy) {
|
|
|
122
122
|
host: {
|
|
123
123
|
get () {
|
|
124
124
|
const socket = this.raw.socket
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
// The headers getter may allocate a new object, so read it once.
|
|
126
|
+
const headers = this.headers
|
|
127
|
+
if (headers['x-forwarded-host'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
|
|
128
|
+
return getLastEntryInMultiHeaderValue(headers['x-forwarded-host'])
|
|
127
129
|
}
|
|
128
130
|
/**
|
|
129
131
|
* The last fallback supports the following cases:
|
|
@@ -131,14 +133,16 @@ function buildRequestWithTrustProxy (R, trustProxy) {
|
|
|
131
133
|
* 2. HTTP/1.0 without a Host Header
|
|
132
134
|
* 3. Headers schema that may remove the Host Header
|
|
133
135
|
*/
|
|
134
|
-
return
|
|
136
|
+
return headers.host ?? headers[':authority'] ?? ''
|
|
135
137
|
}
|
|
136
138
|
},
|
|
137
139
|
protocol: {
|
|
138
140
|
get () {
|
|
139
141
|
const socket = this.raw.socket
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
// The headers getter may allocate a new object, so read it once.
|
|
143
|
+
const headers = this.headers
|
|
144
|
+
if (headers['x-forwarded-proto'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
|
|
145
|
+
return getLastEntryInMultiHeaderValue(headers['x-forwarded-proto'])
|
|
142
146
|
}
|
|
143
147
|
if (this.socket) {
|
|
144
148
|
return this.socket.encrypted ? 'https' : 'http'
|
package/lib/route.js
CHANGED
|
@@ -466,8 +466,12 @@ function buildRouting (options) {
|
|
|
466
466
|
// the child logger creation altogether
|
|
467
467
|
childLogger = logger
|
|
468
468
|
} else {
|
|
469
|
-
const loggerOpts = {
|
|
470
|
-
|
|
469
|
+
const loggerOpts = {}
|
|
470
|
+
|
|
471
|
+
// An empty level means "inherit". Passing it would make pino treat it
|
|
472
|
+
// as a level change and rebuild every log method of each request child.
|
|
473
|
+
if (context.logLevel) {
|
|
474
|
+
loggerOpts.level = context.logLevel
|
|
471
475
|
}
|
|
472
476
|
|
|
473
477
|
if (context.logSerializers) {
|
package/lib/validation.js
CHANGED
|
@@ -13,7 +13,7 @@ const {
|
|
|
13
13
|
FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX
|
|
14
14
|
} = require('./errors')
|
|
15
15
|
|
|
16
|
-
const { FSTWRN001, FSTSEC002 } = require('./warnings')
|
|
16
|
+
const { FSTWRN001, FSTSEC002, FSTSEC003 } = require('./warnings')
|
|
17
17
|
|
|
18
18
|
function compileSchemasForSerialization (context, compile) {
|
|
19
19
|
if (!context.schema || !context.schema.response) {
|
|
@@ -70,9 +70,14 @@ function compileSchemasForSerialization (context, compile) {
|
|
|
70
70
|
// reachable through `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else`, local
|
|
71
71
|
// `$ref` definitions (`definitions`/`$defs`), and the other standard subschema
|
|
72
72
|
// containers. The input schema is never mutated: a new schema tree is returned.
|
|
73
|
-
|
|
73
|
+
//
|
|
74
|
+
// A `propertyNames` subschema validates each header name itself, so values held
|
|
75
|
+
// in `enum` and `const` (and in every subschema reachable from it) are header
|
|
76
|
+
// names rather than header values. `insidePropertyNames` tracks that context so
|
|
77
|
+
// those `enum`/`const` string values are lowercased there, and only there.
|
|
78
|
+
function lowerCaseHeadersSchema (schema, insidePropertyNames = false) {
|
|
74
79
|
if (Array.isArray(schema)) {
|
|
75
|
-
return schema.map(lowerCaseHeadersSchema)
|
|
80
|
+
return schema.map(item => lowerCaseHeadersSchema(item, insidePropertyNames))
|
|
76
81
|
}
|
|
77
82
|
if (schema === null || typeof schema !== 'object') {
|
|
78
83
|
return schema
|
|
@@ -91,7 +96,7 @@ function lowerCaseHeadersSchema (schema) {
|
|
|
91
96
|
}
|
|
92
97
|
const normalized = {}
|
|
93
98
|
for (const prop of Object.keys(value)) {
|
|
94
|
-
normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop])
|
|
99
|
+
normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop], insidePropertyNames)
|
|
95
100
|
}
|
|
96
101
|
result.properties = normalized
|
|
97
102
|
break
|
|
@@ -112,7 +117,7 @@ function lowerCaseHeadersSchema (schema) {
|
|
|
112
117
|
if (Array.isArray(depValue)) {
|
|
113
118
|
normalized[dep.toLowerCase()] = depValue.map(name => name.toLowerCase())
|
|
114
119
|
} else {
|
|
115
|
-
normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue)
|
|
120
|
+
normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue, insidePropertyNames)
|
|
116
121
|
}
|
|
117
122
|
}
|
|
118
123
|
result.dependencies = normalized
|
|
@@ -125,7 +130,7 @@ function lowerCaseHeadersSchema (schema) {
|
|
|
125
130
|
}
|
|
126
131
|
const normalized = {}
|
|
127
132
|
for (const dep of Object.keys(value)) {
|
|
128
|
-
normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep])
|
|
133
|
+
normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep], insidePropertyNames)
|
|
129
134
|
}
|
|
130
135
|
result.dependentSchemas = normalized
|
|
131
136
|
break
|
|
@@ -158,12 +163,21 @@ function lowerCaseHeadersSchema (schema) {
|
|
|
158
163
|
case 'unevaluatedItems':
|
|
159
164
|
case 'unevaluatedProperties':
|
|
160
165
|
case 'contains':
|
|
161
|
-
case 'propertyNames':
|
|
162
166
|
case 'contentSchema':
|
|
163
|
-
result[key] = lowerCaseHeadersSchema(value)
|
|
167
|
+
result[key] = lowerCaseHeadersSchema(value, insidePropertyNames)
|
|
168
|
+
break
|
|
169
|
+
case 'propertyNames':
|
|
170
|
+
result[key] = lowerCaseHeadersSchema(value, true)
|
|
164
171
|
break
|
|
165
172
|
// Maps of subschemas whose keys are not header names (definition names,
|
|
166
173
|
// regex patterns): keep the keys, normalize the subschema values.
|
|
174
|
+
//
|
|
175
|
+
// `patternProperties` keys are regular expressions matched against the
|
|
176
|
+
// already-lowercased header names and are left as-is: a regular expression
|
|
177
|
+
// cannot be safely lowercased. The same applies to a `propertyNames`
|
|
178
|
+
// `pattern`. A header-name pattern written in canonical case will not match
|
|
179
|
+
// the lowercased request headers, so it must be authored to match
|
|
180
|
+
// lowercase. See docs/Reference/Validation-and-Serialization.md.
|
|
167
181
|
case 'definitions':
|
|
168
182
|
case '$defs':
|
|
169
183
|
case 'patternProperties': {
|
|
@@ -173,13 +187,23 @@ function lowerCaseHeadersSchema (schema) {
|
|
|
173
187
|
}
|
|
174
188
|
const normalized = {}
|
|
175
189
|
for (const k of Object.keys(value)) {
|
|
176
|
-
normalized[k] = lowerCaseHeadersSchema(value[k])
|
|
190
|
+
normalized[k] = lowerCaseHeadersSchema(value[k], insidePropertyNames)
|
|
177
191
|
}
|
|
178
192
|
result[key] = normalized
|
|
179
193
|
break
|
|
180
194
|
}
|
|
181
195
|
default:
|
|
182
|
-
|
|
196
|
+
// Inside a `propertyNames` subschema, `enum` and `const` values are the
|
|
197
|
+
// candidate header names themselves, so they must be lowercased to match
|
|
198
|
+
// the lowercased request headers. Everywhere else these hold header
|
|
199
|
+
// *values* (or arbitrary constant data) and must be left untouched.
|
|
200
|
+
if (insidePropertyNames && key === 'enum' && Array.isArray(value)) {
|
|
201
|
+
result[key] = value.map(item => typeof item === 'string' ? item.toLowerCase() : item)
|
|
202
|
+
} else if (insidePropertyNames && key === 'const' && typeof value === 'string') {
|
|
203
|
+
result[key] = value.toLowerCase()
|
|
204
|
+
} else {
|
|
205
|
+
result[key] = value
|
|
206
|
+
}
|
|
183
207
|
}
|
|
184
208
|
}
|
|
185
209
|
|
|
@@ -217,6 +241,35 @@ function findExternalHeaderRef (schema) {
|
|
|
217
241
|
return undefined
|
|
218
242
|
}
|
|
219
243
|
|
|
244
|
+
// A `$ref` reached inside a `propertyNames` subschema resolves to a definition
|
|
245
|
+
// that is normalized in its own, non-`propertyNames` context, so header names
|
|
246
|
+
// held in its `enum`/`const` keep their original case and a case-insensitive
|
|
247
|
+
// deny-list or allow-list never matches the lowercased request headers. We
|
|
248
|
+
// cannot safely normalize the referenced definition here because it may be
|
|
249
|
+
// shared with value contexts, so we detect the reference and warn instead.
|
|
250
|
+
// Returns the first local `$ref` found inside a `propertyNames` subschema, or
|
|
251
|
+
// undefined.
|
|
252
|
+
function findPropertyNamesRef (schema, insidePropertyNames = false) {
|
|
253
|
+
if (Array.isArray(schema)) {
|
|
254
|
+
for (const item of schema) {
|
|
255
|
+
const ref = findPropertyNamesRef(item, insidePropertyNames)
|
|
256
|
+
if (ref !== undefined) return ref
|
|
257
|
+
}
|
|
258
|
+
return undefined
|
|
259
|
+
}
|
|
260
|
+
if (schema === null || typeof schema !== 'object') {
|
|
261
|
+
return undefined
|
|
262
|
+
}
|
|
263
|
+
if (insidePropertyNames && typeof schema.$ref === 'string' && schema.$ref[0] === '#') {
|
|
264
|
+
return schema.$ref
|
|
265
|
+
}
|
|
266
|
+
for (const key of Object.keys(schema)) {
|
|
267
|
+
const ref = findPropertyNamesRef(schema[key], insidePropertyNames || key === 'propertyNames')
|
|
268
|
+
if (ref !== undefined) return ref
|
|
269
|
+
}
|
|
270
|
+
return undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
220
273
|
function compileSchemasForValidation (context, compile, isCustom) {
|
|
221
274
|
const { schema } = context
|
|
222
275
|
if (!schema) {
|
|
@@ -246,6 +299,10 @@ function compileSchemasForValidation (context, compile, isCustom) {
|
|
|
246
299
|
if (externalRef !== undefined) {
|
|
247
300
|
FSTSEC002(method, url, externalRef)
|
|
248
301
|
}
|
|
302
|
+
const propertyNamesRef = findPropertyNamesRef(headers)
|
|
303
|
+
if (propertyNamesRef !== undefined) {
|
|
304
|
+
FSTSEC003(method, url, propertyNamesRef)
|
|
305
|
+
}
|
|
249
306
|
context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' })
|
|
250
307
|
}
|
|
251
308
|
} else if (Object.hasOwn(schema, 'headers')) {
|
|
@@ -284,19 +341,21 @@ function compileSchemasForValidation (context, compile, isCustom) {
|
|
|
284
341
|
}
|
|
285
342
|
|
|
286
343
|
function validateParam (validatorFunction, request, paramName) {
|
|
287
|
-
|
|
344
|
+
// The headers getter may allocate a new object, so only read it when needed.
|
|
345
|
+
if (validatorFunction == null) return false
|
|
346
|
+
const value = request[paramName]
|
|
288
347
|
let ret
|
|
289
348
|
|
|
290
349
|
try {
|
|
291
|
-
const data =
|
|
350
|
+
const data = value === undefined ? null : value
|
|
292
351
|
// Ajv can only mutate a root value when its parent is provided.
|
|
293
|
-
if (validatorFunction
|
|
352
|
+
if (validatorFunction.schemaEnv) {
|
|
294
353
|
ret = validatorFunction(data, {
|
|
295
354
|
parentData: request,
|
|
296
355
|
parentDataProperty: paramName
|
|
297
356
|
})
|
|
298
357
|
} else {
|
|
299
|
-
ret = validatorFunction
|
|
358
|
+
ret = validatorFunction(data)
|
|
300
359
|
}
|
|
301
360
|
} catch (err) {
|
|
302
361
|
// If validator throws synchronously, ensure it propagates as an internal error
|
package/lib/warnings.js
CHANGED
|
@@ -7,6 +7,7 @@ const { createWarning } = require('process-warning')
|
|
|
7
7
|
* - FSTWRN001
|
|
8
8
|
* - FSTSEC001
|
|
9
9
|
* - FSTSEC002
|
|
10
|
+
* - FSTSEC003
|
|
10
11
|
*
|
|
11
12
|
* Deprecation Codes FSTDEP001 - FSTDEP021 were used by v4 and MUST NOT be reused.
|
|
12
13
|
* - FSTDEP022 - FSTDEP025 are used by v5 and MUST NOT be reused.
|
|
@@ -41,9 +42,17 @@ const FSTSEC002 = createWarning({
|
|
|
41
42
|
unlimited: true
|
|
42
43
|
})
|
|
43
44
|
|
|
45
|
+
const FSTSEC003 = createWarning({
|
|
46
|
+
name: 'FastifySecurity',
|
|
47
|
+
code: 'FSTSEC003',
|
|
48
|
+
message: 'The headers schema for %s: %s uses a $ref (%s) inside propertyNames that is not case-normalized. Header names held in the referenced schema keep their original case and will not match the lowercased request headers, so a case-insensitive deny-list or allow-list on header names may not apply. Inline the propertyNames constraint instead of referencing it with a $ref.',
|
|
49
|
+
unlimited: true
|
|
50
|
+
})
|
|
51
|
+
|
|
44
52
|
module.exports = {
|
|
45
53
|
FSTWRN001,
|
|
46
54
|
FSTWRN003,
|
|
47
55
|
FSTSEC001,
|
|
48
|
-
FSTSEC002
|
|
56
|
+
FSTSEC002,
|
|
57
|
+
FSTSEC003
|
|
49
58
|
}
|
package/package.json
CHANGED
|
@@ -126,3 +126,28 @@ test('should throw error if invalid logger is returned', (t, done) => {
|
|
|
126
126
|
}, { code: 'FST_ERR_LOG_INVALID_LOGGER' })
|
|
127
127
|
})
|
|
128
128
|
})
|
|
129
|
+
|
|
130
|
+
test('request child loggers only receive a level when the route sets one', async t => {
|
|
131
|
+
t.plan(3)
|
|
132
|
+
|
|
133
|
+
const fastify = Fastify({ logger: { level: 'info', stream: { write () {} } } })
|
|
134
|
+
const levels = []
|
|
135
|
+
fastify.setChildLoggerFactory(function (logger, bindings, opts) {
|
|
136
|
+
// an empty level would make pino reset the level of every request child
|
|
137
|
+
levels.push(Object.hasOwn(opts, 'level') ? opts.level : 'inherited')
|
|
138
|
+
return logger.child(bindings, opts)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
fastify.get('/', (req, reply) => {
|
|
142
|
+
reply.send(req.log.level)
|
|
143
|
+
})
|
|
144
|
+
fastify.get('/warn', { logLevel: 'warn' }, (req, reply) => {
|
|
145
|
+
reply.send(req.log.level)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
t.after(() => fastify.close())
|
|
149
|
+
|
|
150
|
+
t.assert.strictEqual((await fastify.inject('/')).body, 'info')
|
|
151
|
+
t.assert.strictEqual((await fastify.inject('/warn')).body, 'warn')
|
|
152
|
+
t.assert.deepStrictEqual(levels, ['inherited', 'warn'])
|
|
153
|
+
})
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { test } = require('node:test')
|
|
4
4
|
const { spyWarning } = require('process-warning')
|
|
5
|
+
const { Readable } = require('node:stream')
|
|
5
6
|
const Fastify = require('..')
|
|
6
7
|
const keys = require('../lib/symbols')
|
|
7
8
|
const { FST_ERR_CTP_ALREADY_PRESENT, FST_ERR_CTP_INVALID_TYPE, FST_ERR_CTP_INVALID_MEDIA_TYPE } = require('../lib/errors')
|
|
@@ -767,3 +768,62 @@ test('content-type fail when not a valid type', async t => {
|
|
|
767
768
|
t.assert.equal(error.message, 'The content type should be a string or a RegExp')
|
|
768
769
|
}
|
|
769
770
|
})
|
|
771
|
+
|
|
772
|
+
test('string body keeps multi-byte characters split across chunks', async t => {
|
|
773
|
+
t.plan(2)
|
|
774
|
+
|
|
775
|
+
const fastify = Fastify()
|
|
776
|
+
const encoded = Buffer.from(JSON.stringify({ hello: 'wörld ✓ 😀' }))
|
|
777
|
+
|
|
778
|
+
fastify.addHook('preParsing', async () => {
|
|
779
|
+
// Emit one byte per chunk so every multi-byte character is split
|
|
780
|
+
return Readable.from(Array.from(encoded, byte => Buffer.from([byte])), { objectMode: false })
|
|
781
|
+
})
|
|
782
|
+
fastify.post('/', async (request) => request.body)
|
|
783
|
+
|
|
784
|
+
const res = await fastify.inject({
|
|
785
|
+
method: 'POST',
|
|
786
|
+
url: '/',
|
|
787
|
+
headers: { 'content-type': 'application/json' },
|
|
788
|
+
payload: encoded
|
|
789
|
+
})
|
|
790
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
791
|
+
t.assert.deepStrictEqual(res.json(), { hello: 'wörld ✓ 😀' })
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
test('string body accepts string chunks from a preParsing stream', async t => {
|
|
795
|
+
t.plan(2)
|
|
796
|
+
|
|
797
|
+
const fastify = Fastify()
|
|
798
|
+
|
|
799
|
+
fastify.addHook('preParsing', async (request, reply, payload) => {
|
|
800
|
+
payload.setEncoding('utf8')
|
|
801
|
+
return payload
|
|
802
|
+
})
|
|
803
|
+
fastify.post('/', async (request) => request.body)
|
|
804
|
+
|
|
805
|
+
const res = await fastify.inject({
|
|
806
|
+
method: 'POST',
|
|
807
|
+
url: '/',
|
|
808
|
+
headers: { 'content-type': 'text/plain' },
|
|
809
|
+
payload: 'hellö'
|
|
810
|
+
})
|
|
811
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
812
|
+
t.assert.strictEqual(res.body, 'hellö')
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
test('string body with invalid UTF-8 is not treated as a content-length mismatch', async t => {
|
|
816
|
+
t.plan(2)
|
|
817
|
+
|
|
818
|
+
const fastify = Fastify()
|
|
819
|
+
fastify.post('/', async (request) => request.body)
|
|
820
|
+
|
|
821
|
+
const res = await fastify.inject({
|
|
822
|
+
method: 'POST',
|
|
823
|
+
url: '/',
|
|
824
|
+
headers: { 'content-type': 'text/plain' },
|
|
825
|
+
payload: Buffer.from([0x61, 0xff, 0x62])
|
|
826
|
+
})
|
|
827
|
+
t.assert.strictEqual(res.statusCode, 200)
|
|
828
|
+
t.assert.strictEqual(res.body, 'a�b')
|
|
829
|
+
})
|
|
@@ -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
|
|
|
@@ -5,7 +5,7 @@ const errors = require('../../lib/errors')
|
|
|
5
5
|
const { readFileSync } = require('node:fs')
|
|
6
6
|
const { resolve } = require('node:path')
|
|
7
7
|
|
|
8
|
-
const expectedErrors =
|
|
8
|
+
const expectedErrors = 94
|
|
9
9
|
|
|
10
10
|
test(`should expose ${expectedErrors} errors`, t => {
|
|
11
11
|
t.plan(1)
|
|
@@ -790,16 +790,6 @@ test('FST_ERR_ROUTE_MISSING_CONTENT_TYPE', t => {
|
|
|
790
790
|
t.assert.ok(error instanceof Error)
|
|
791
791
|
})
|
|
792
792
|
|
|
793
|
-
test('FST_ERR_ROUTE_MISSING_CONTENT', t => {
|
|
794
|
-
t.plan(5)
|
|
795
|
-
const error = new errors.FST_ERR_ROUTE_MISSING_CONTENT()
|
|
796
|
-
t.assert.strictEqual(error.name, 'FastifyError')
|
|
797
|
-
t.assert.strictEqual(error.code, 'FST_ERR_ROUTE_MISSING_CONTENT')
|
|
798
|
-
t.assert.strictEqual(error.message, "Method '%s' must provide a request body.")
|
|
799
|
-
t.assert.strictEqual(error.statusCode, 400)
|
|
800
|
-
t.assert.ok(error instanceof Error)
|
|
801
|
-
})
|
|
802
|
-
|
|
803
793
|
test('FST_ERR_REOPENED_CLOSE_SERVER', t => {
|
|
804
794
|
t.plan(5)
|
|
805
795
|
const error = new errors.FST_ERR_REOPENED_CLOSE_SERVER()
|
|
@@ -712,3 +712,23 @@ test('Reply#serializeInput', async t => {
|
|
|
712
712
|
})
|
|
713
713
|
})
|
|
714
714
|
})
|
|
715
|
+
|
|
716
|
+
test('Reply#compileSerializationSchema omits absent metadata instead of passing null', async t => {
|
|
717
|
+
t.plan(2)
|
|
718
|
+
|
|
719
|
+
const fastify = Fastify()
|
|
720
|
+
|
|
721
|
+
fastify.setSerializerCompiler(({ httpStatus, contentType }) => {
|
|
722
|
+
t.assert.strictEqual(httpStatus, undefined)
|
|
723
|
+
t.assert.strictEqual(contentType, undefined)
|
|
724
|
+
|
|
725
|
+
return input => JSON.stringify(input)
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
fastify.get('/', (req, reply) => {
|
|
729
|
+
reply.compileSerializationSchema(getDefaultSchema())
|
|
730
|
+
reply.send({ hello: 'world' })
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
await fastify.inject({ path: '/', method: 'GET' })
|
|
734
|
+
})
|
|
@@ -35,7 +35,7 @@ const doGet = async function (url) {
|
|
|
35
35
|
|
|
36
36
|
test('Once called, Reply should return an object with methods', t => {
|
|
37
37
|
t.plan(16)
|
|
38
|
-
const response = { res: 'res' }
|
|
38
|
+
const response = { res: 'res', getHeader: () => undefined }
|
|
39
39
|
const context = {
|
|
40
40
|
config: { onSend: [] },
|
|
41
41
|
schema: {},
|
|
@@ -1003,6 +1003,35 @@ test('reply can set multiple instances of same header', async t => {
|
|
|
1003
1003
|
t.assert.deepStrictEqual(result.headers.getSetCookie(), ['one', 'two'])
|
|
1004
1004
|
})
|
|
1005
1005
|
|
|
1006
|
+
test('reply.header does not mutate a set-cookie array passed by the caller', async t => {
|
|
1007
|
+
t.plan(5)
|
|
1008
|
+
|
|
1009
|
+
const fastify = require('../../')()
|
|
1010
|
+
const defaults = ['a=1']
|
|
1011
|
+
|
|
1012
|
+
fastify.get('/headers', function (req, reply) {
|
|
1013
|
+
reply
|
|
1014
|
+
.header('set-cookie', defaults)
|
|
1015
|
+
.header('set-cookie', 'b=2')
|
|
1016
|
+
.send({})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
const fastifyServer = await fastify.listen({ port: 0 })
|
|
1020
|
+
t.after(() => fastify.close())
|
|
1021
|
+
|
|
1022
|
+
const first = await fetch(`${fastifyServer}/headers`)
|
|
1023
|
+
t.assert.deepStrictEqual(first.headers.getSetCookie(), ['a=1', 'b=2'])
|
|
1024
|
+
const second = await fetch(`${fastifyServer}/headers`)
|
|
1025
|
+
t.assert.deepStrictEqual(second.headers.getSetCookie(), ['a=1', 'b=2'])
|
|
1026
|
+
t.assert.deepStrictEqual(defaults, ['a=1'])
|
|
1027
|
+
|
|
1028
|
+
const reply = new Reply({ setHeader () {}, removeHeader () {} }, {}, {})
|
|
1029
|
+
const cookies = ['one']
|
|
1030
|
+
reply.header('set-cookie', cookies).header('set-cookie', 'two')
|
|
1031
|
+
t.assert.deepStrictEqual(reply.getHeader('set-cookie'), ['one', 'two'])
|
|
1032
|
+
t.assert.deepStrictEqual(cookies, ['one'])
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1006
1035
|
test('reply.hasHeader returns correct values', async t => {
|
|
1007
1036
|
t.plan(2)
|
|
1008
1037
|
|