UNPKG

21.4 kBJavaScriptView Raw
1'use strict'
2
3const FindMyWay = require('find-my-way')
4const Context = require('./context')
5const handleRequest = require('./handleRequest')
6const { onRequestAbortHookRunner, lifecycleHooks, preParsingHookRunner, onTimeoutHookRunner, onRequestHookRunner } = require('./hooks')
7const { supportedMethods } = require('./httpMethods')
8const { normalizeSchema } = require('./schemas')
9const { parseHeadOnSendHandlers } = require('./headRoute')
10const {
11 FSTDEP007,
12 FSTDEP008,
13 FSTDEP014
14} = require('./warnings')
15
16const {
17 compileSchemasForValidation,
18 compileSchemasForSerialization
19} = require('./validation')
20
21const {
22 FST_ERR_SCH_VALIDATION_BUILD,
23 FST_ERR_SCH_SERIALIZATION_BUILD,
24 FST_ERR_DEFAULT_ROUTE_INVALID_TYPE,
25 FST_ERR_DUPLICATED_ROUTE,
26 FST_ERR_INVALID_URL,
27 FST_ERR_HOOK_INVALID_HANDLER,
28 FST_ERR_ROUTE_OPTIONS_NOT_OBJ,
29 FST_ERR_ROUTE_DUPLICATED_HANDLER,
30 FST_ERR_ROUTE_HANDLER_NOT_FN,
31 FST_ERR_ROUTE_MISSING_HANDLER,
32 FST_ERR_ROUTE_METHOD_NOT_SUPPORTED,
33 FST_ERR_ROUTE_METHOD_INVALID,
34 FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED,
35 FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT,
36 FST_ERR_HOOK_INVALID_ASYNC_HANDLER
37} = require('./errors')
38
39const {
40 kRoutePrefix,
41 kLogLevel,
42 kLogSerializers,
43 kHooks,
44 kSchemaController,
45 kOptions,
46 kReplySerializerDefault,
47 kReplyIsError,
48 kRequestPayloadStream,
49 kDisableRequestLogging,
50 kSchemaErrorFormatter,
51 kErrorHandler,
52 kHasBeenDecorated,
53 kRequestAcceptVersion,
54 kRouteByFastify,
55 kRouteContext
56} = require('./symbols.js')
57const { buildErrorHandler } = require('./error-handler')
58const { createChildLogger } = require('./logger')
59const { getGenReqId } = require('./reqIdGenFactory.js')
60
61function buildRouting (options) {
62 const router = FindMyWay(options.config)
63
64 let avvio
65 let fourOhFour
66 let logger
67 let hasLogger
68 let setupResponseListeners
69 let throwIfAlreadyStarted
70 let disableRequestLogging
71 let ignoreTrailingSlash
72 let ignoreDuplicateSlashes
73 let return503OnClosing
74 let globalExposeHeadRoutes
75 let validateHTTPVersion
76 let keepAliveConnections
77
78 let closing = false
79
80 return {
81 /**
82 * @param {import('../fastify').FastifyServerOptions} options
83 * @param {*} fastifyArgs
84 */
85 setup (options, fastifyArgs) {
86 avvio = fastifyArgs.avvio
87 fourOhFour = fastifyArgs.fourOhFour
88 logger = fastifyArgs.logger
89 hasLogger = fastifyArgs.hasLogger
90 setupResponseListeners = fastifyArgs.setupResponseListeners
91 throwIfAlreadyStarted = fastifyArgs.throwIfAlreadyStarted
92 validateHTTPVersion = fastifyArgs.validateHTTPVersion
93
94 globalExposeHeadRoutes = options.exposeHeadRoutes
95 disableRequestLogging = options.disableRequestLogging
96 ignoreTrailingSlash = options.ignoreTrailingSlash
97 ignoreDuplicateSlashes = options.ignoreDuplicateSlashes
98 return503OnClosing = Object.prototype.hasOwnProperty.call(options, 'return503OnClosing') ? options.return503OnClosing : true
99 keepAliveConnections = fastifyArgs.keepAliveConnections
100 },
101 routing: router.lookup.bind(router), // router func to find the right handler to call
102 route, // configure a route in the fastify instance
103 hasRoute,
104 prepareRoute,
105 getDefaultRoute: function () {
106 FSTDEP014()
107 return router.defaultRoute
108 },
109 setDefaultRoute: function (defaultRoute) {
110 FSTDEP014()
111 if (typeof defaultRoute !== 'function') {
112 throw new FST_ERR_DEFAULT_ROUTE_INVALID_TYPE()
113 }
114
115 router.defaultRoute = defaultRoute
116 },
117 routeHandler,
118 closeRoutes: () => { closing = true },
119 printRoutes: router.prettyPrint.bind(router),
120 addConstraintStrategy,
121 hasConstraintStrategy,
122 isAsyncConstraint,
123 findRoute
124 }
125
126 function addConstraintStrategy (strategy) {
127 throwIfAlreadyStarted('Cannot add constraint strategy!')
128 return router.addConstraintStrategy(strategy)
129 }
130
131 function hasConstraintStrategy (strategyName) {
132 return router.hasConstraintStrategy(strategyName)
133 }
134
135 function isAsyncConstraint () {
136 return router.constrainer.asyncStrategiesInUse.size > 0
137 }
138
139 // Convert shorthand to extended route declaration
140 function prepareRoute ({ method, url, options, handler, isFastify }) {
141 if (typeof url !== 'string') {
142 throw new FST_ERR_INVALID_URL(typeof url)
143 }
144
145 if (!handler && typeof options === 'function') {
146 handler = options // for support over direct function calls such as fastify.get() options are reused as the handler
147 options = {}
148 } else if (handler && typeof handler === 'function') {
149 if (Object.prototype.toString.call(options) !== '[object Object]') {
150 throw new FST_ERR_ROUTE_OPTIONS_NOT_OBJ(method, url)
151 } else if (options.handler) {
152 if (typeof options.handler === 'function') {
153 throw new FST_ERR_ROUTE_DUPLICATED_HANDLER(method, url)
154 } else {
155 throw new FST_ERR_ROUTE_HANDLER_NOT_FN(method, url)
156 }
157 }
158 }
159
160 options = Object.assign({}, options, {
161 method,
162 url,
163 path: url,
164 handler: handler || (options && options.handler)
165 })
166
167 return route.call(this, { options, isFastify })
168 }
169
170 function hasRoute ({ options }) {
171 const normalizedMethod = options.method?.toUpperCase() ?? ''
172 return findRoute({
173 ...options,
174 method: normalizedMethod
175 }) !== null
176 }
177
178 function findRoute (options) {
179 const route = router.find(
180 options.method,
181 options.url || '',
182 options.constraints
183 )
184 if (route) {
185 // we must reduce the expose surface, otherwise
186 // we provide the ability for the user to modify
187 // all the route and server information in runtime
188 return {
189 handler: route.handler,
190 params: route.params,
191 searchParams: route.searchParams
192 }
193 } else {
194 return null
195 }
196 }
197
198 /**
199 * Route management
200 * @param {{ options: import('../fastify').RouteOptions, isFastify: boolean }}
201 */
202 function route ({ options, isFastify }) {
203 // Since we are mutating/assigning only top level props, it is fine to have a shallow copy using the spread operator
204 const opts = { ...options }
205
206 const { exposeHeadRoute } = opts
207 const hasRouteExposeHeadRouteFlag = exposeHeadRoute != null
208 const shouldExposeHead = hasRouteExposeHeadRouteFlag ? exposeHeadRoute : globalExposeHeadRoutes
209
210 const isGetRoute = opts.method === 'GET' ||
211 (Array.isArray(opts.method) && opts.method.includes('GET'))
212 const isHeadRoute = opts.method === 'HEAD' ||
213 (Array.isArray(opts.method) && opts.method.includes('HEAD'))
214
215 // we need to clone a set of initial options for HEAD route
216 const headOpts = shouldExposeHead && isGetRoute ? { ...options } : null
217
218 throwIfAlreadyStarted('Cannot add route!')
219
220 const path = opts.url || opts.path || ''
221
222 if (Array.isArray(opts.method)) {
223 // eslint-disable-next-line no-var
224 for (var i = 0; i < opts.method.length; ++i) {
225 opts.method[i] = normalizeAndValidateMethod(opts.method[i])
226 validateSchemaBodyOption(opts.method[i], path, opts.schema)
227 }
228 } else {
229 opts.method = normalizeAndValidateMethod(opts.method)
230 validateSchemaBodyOption(opts.method, path, opts.schema)
231 }
232
233 if (!opts.handler) {
234 throw new FST_ERR_ROUTE_MISSING_HANDLER(opts.method, path)
235 }
236
237 if (opts.errorHandler !== undefined && typeof opts.errorHandler !== 'function') {
238 throw new FST_ERR_ROUTE_HANDLER_NOT_FN(opts.method, path)
239 }
240
241 validateBodyLimitOption(opts.bodyLimit)
242
243 const prefix = this[kRoutePrefix]
244
245 if (path === '/' && prefix.length > 0 && opts.method !== 'HEAD') {
246 switch (opts.prefixTrailingSlash) {
247 case 'slash':
248 addNewRoute.call(this, { path, isFastify })
249 break
250 case 'no-slash':
251 addNewRoute.call(this, { path: '', isFastify })
252 break
253 case 'both':
254 default:
255 addNewRoute.call(this, { path: '', isFastify })
256 // If ignoreTrailingSlash is set to true we need to add only the '' route to prevent adding an incomplete one.
257 if (ignoreTrailingSlash !== true && (ignoreDuplicateSlashes !== true || !prefix.endsWith('/'))) {
258 addNewRoute.call(this, { path, prefixing: true, isFastify })
259 }
260 }
261 } else if (path[0] === '/' && prefix.endsWith('/')) {
262 // Ensure that '/prefix/' + '/route' gets registered as '/prefix/route'
263 addNewRoute.call(this, { path: path.slice(1), isFastify })
264 } else {
265 addNewRoute.call(this, { path, isFastify })
266 }
267
268 // chainable api
269 return this
270
271 function addNewRoute ({ path, prefixing = false, isFastify = false }) {
272 const url = prefix + path
273
274 opts.url = url
275 opts.path = url
276 opts.routePath = path
277 opts.prefix = prefix
278 opts.logLevel = opts.logLevel || this[kLogLevel]
279
280 if (this[kLogSerializers] || opts.logSerializers) {
281 opts.logSerializers = Object.assign(Object.create(this[kLogSerializers]), opts.logSerializers)
282 }
283
284 if (opts.attachValidation == null) {
285 opts.attachValidation = false
286 }
287
288 if (prefixing === false) {
289 // run 'onRoute' hooks
290 for (const hook of this[kHooks].onRoute) {
291 hook.call(this, opts)
292 }
293 }
294
295 for (const hook of lifecycleHooks) {
296 if (opts && hook in opts) {
297 if (Array.isArray(opts[hook])) {
298 for (const func of opts[hook]) {
299 if (typeof func !== 'function') {
300 throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(func))
301 }
302
303 if (hook === 'onSend' || hook === 'preSerialization' || hook === 'onError' || hook === 'preParsing') {
304 if (func.constructor.name === 'AsyncFunction' && func.length === 4) {
305 throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER()
306 }
307 } else if (hook === 'onRequestAbort') {
308 if (func.constructor.name === 'AsyncFunction' && func.length !== 1) {
309 throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER()
310 }
311 } else {
312 if (func.constructor.name === 'AsyncFunction' && func.length === 3) {
313 throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER()
314 }
315 }
316 }
317 } else if (opts[hook] !== undefined && typeof opts[hook] !== 'function') {
318 throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(opts[hook]))
319 }
320 }
321 }
322
323 const constraints = opts.constraints || {}
324 const config = {
325 ...opts.config,
326 url,
327 method: opts.method
328 }
329
330 const context = new Context({
331 schema: opts.schema,
332 handler: opts.handler.bind(this),
333 config,
334 errorHandler: opts.errorHandler,
335 childLoggerFactory: opts.childLoggerFactory,
336 bodyLimit: opts.bodyLimit,
337 logLevel: opts.logLevel,
338 logSerializers: opts.logSerializers,
339 attachValidation: opts.attachValidation,
340 schemaErrorFormatter: opts.schemaErrorFormatter,
341 replySerializer: this[kReplySerializerDefault],
342 validatorCompiler: opts.validatorCompiler,
343 serializerCompiler: opts.serializerCompiler,
344 exposeHeadRoute: shouldExposeHead,
345 prefixTrailingSlash: (opts.prefixTrailingSlash || 'both'),
346 server: this,
347 isFastify
348 })
349
350 if (opts.version) {
351 FSTDEP008()
352 constraints.version = opts.version
353 }
354
355 const headHandler = router.findRoute('HEAD', opts.url, constraints)
356 const hasHEADHandler = headHandler !== null
357
358 // remove the head route created by fastify
359 if (isHeadRoute && hasHEADHandler && !context[kRouteByFastify] && headHandler.store[kRouteByFastify]) {
360 router.off('HEAD', opts.url, constraints)
361 }
362
363 try {
364 router.on(opts.method, opts.url, { constraints }, routeHandler, context)
365 } catch (error) {
366 // any route insertion error created by fastify can be safely ignore
367 // because it only duplicate route for head
368 if (!context[kRouteByFastify]) {
369 const isDuplicatedRoute = error.message.includes(`Method '${opts.method}' already declared for route '${opts.url}'`)
370 if (isDuplicatedRoute) {
371 throw new FST_ERR_DUPLICATED_ROUTE(opts.method, opts.url)
372 }
373
374 throw error
375 }
376 }
377
378 this.after((notHandledErr, done) => {
379 // Send context async
380 context.errorHandler = opts.errorHandler ? buildErrorHandler(this[kErrorHandler], opts.errorHandler) : this[kErrorHandler]
381 context._parserOptions.limit = opts.bodyLimit || null
382 context.logLevel = opts.logLevel
383 context.logSerializers = opts.logSerializers
384 context.attachValidation = opts.attachValidation
385 context[kReplySerializerDefault] = this[kReplySerializerDefault]
386 context.schemaErrorFormatter = opts.schemaErrorFormatter || this[kSchemaErrorFormatter] || context.schemaErrorFormatter
387
388 // Run hooks and more
389 avvio.once('preReady', () => {
390 for (const hook of lifecycleHooks) {
391 const toSet = this[kHooks][hook]
392 .concat(opts[hook] || [])
393 .map(h => h.bind(this))
394 context[hook] = toSet.length ? toSet : null
395 }
396
397 // Optimization: avoid encapsulation if no decoration has been done.
398 while (!context.Request[kHasBeenDecorated] && context.Request.parent) {
399 context.Request = context.Request.parent
400 }
401 while (!context.Reply[kHasBeenDecorated] && context.Reply.parent) {
402 context.Reply = context.Reply.parent
403 }
404
405 // Must store the 404 Context in 'preReady' because it is only guaranteed to
406 // be available after all of the plugins and routes have been loaded.
407 fourOhFour.setContext(this, context)
408
409 if (opts.schema) {
410 context.schema = normalizeSchema(context.schema, this.initialConfig)
411
412 const schemaController = this[kSchemaController]
413 if (!opts.validatorCompiler && (opts.schema.body || opts.schema.headers || opts.schema.querystring || opts.schema.params)) {
414 schemaController.setupValidator(this[kOptions])
415 }
416 try {
417 const isCustom = typeof opts?.validatorCompiler === 'function' || schemaController.isCustomValidatorCompiler
418 compileSchemasForValidation(context, opts.validatorCompiler || schemaController.validatorCompiler, isCustom)
419 } catch (error) {
420 throw new FST_ERR_SCH_VALIDATION_BUILD(opts.method, url, error.message)
421 }
422
423 if (opts.schema.response && !opts.serializerCompiler) {
424 schemaController.setupSerializer(this[kOptions])
425 }
426 try {
427 compileSchemasForSerialization(context, opts.serializerCompiler || schemaController.serializerCompiler)
428 } catch (error) {
429 throw new FST_ERR_SCH_SERIALIZATION_BUILD(opts.method, url, error.message)
430 }
431 }
432 })
433
434 done(notHandledErr)
435 })
436
437 // register head route in sync
438 // we must place it after the `this.after`
439
440 if (shouldExposeHead && isGetRoute && !isHeadRoute && !hasHEADHandler) {
441 const onSendHandlers = parseHeadOnSendHandlers(headOpts.onSend)
442 prepareRoute.call(this, { method: 'HEAD', url: path, options: { ...headOpts, onSend: onSendHandlers }, isFastify: true })
443 } else if (hasHEADHandler && exposeHeadRoute) {
444 FSTDEP007()
445 }
446 }
447 }
448
449 // HTTP request entry point, the routing has already been executed
450 function routeHandler (req, res, params, context, query) {
451 const id = getGenReqId(context.server, req)
452
453 const loggerOpts = {
454 level: context.logLevel
455 }
456
457 if (context.logSerializers) {
458 loggerOpts.serializers = context.logSerializers
459 }
460 const childLogger = createChildLogger(context, logger, req, id, loggerOpts)
461 childLogger[kDisableRequestLogging] = disableRequestLogging
462
463 // TODO: The check here should be removed once https://github.com/nodejs/node/issues/43115 resolve in core.
464 if (!validateHTTPVersion(req.httpVersion)) {
465 childLogger.info({ res: { statusCode: 505 } }, 'request aborted - invalid HTTP version')
466 const message = '{"error":"HTTP Version Not Supported","message":"HTTP Version Not Supported","statusCode":505}'
467 const headers = {
468 'Content-Type': 'application/json',
469 'Content-Length': message.length
470 }
471 res.writeHead(505, headers)
472 res.end(message)
473 return
474 }
475
476 if (closing === true) {
477 /* istanbul ignore next mac, windows */
478 if (req.httpVersionMajor !== 2) {
479 res.setHeader('Connection', 'close')
480 }
481
482 // TODO remove return503OnClosing after Node v18 goes EOL
483 /* istanbul ignore else */
484 if (return503OnClosing) {
485 // On Node v19 we cannot test this behavior as it won't be necessary
486 // anymore. It will close all the idle connections before they reach this
487 // stage.
488 const headers = {
489 'Content-Type': 'application/json',
490 'Content-Length': '80'
491 }
492 res.writeHead(503, headers)
493 res.end('{"error":"Service Unavailable","message":"Service Unavailable","statusCode":503}')
494 childLogger.info({ res: { statusCode: 503 } }, 'request aborted - refusing to accept new requests as server is closing')
495 return
496 }
497 }
498
499 // When server.forceCloseConnections is true, we will collect any requests
500 // that have indicated they want persistence so that they can be reaped
501 // on server close. Otherwise, the container is a noop container.
502 const connHeader = String.prototype.toLowerCase.call(req.headers.connection || '')
503 if (connHeader === 'keep-alive') {
504 if (keepAliveConnections.has(req.socket) === false) {
505 keepAliveConnections.add(req.socket)
506 req.socket.on('close', removeTrackedSocket.bind({ keepAliveConnections, socket: req.socket }))
507 }
508 }
509
510 // we revert the changes in defaultRoute
511 if (req.headers[kRequestAcceptVersion] !== undefined) {
512 req.headers['accept-version'] = req.headers[kRequestAcceptVersion]
513 req.headers[kRequestAcceptVersion] = undefined
514 }
515
516 const request = new context.Request(id, params, req, query, childLogger, context)
517 const reply = new context.Reply(res, request, childLogger)
518 if (disableRequestLogging === false) {
519 childLogger.info({ req: request }, 'incoming request')
520 }
521
522 if (hasLogger === true || context.onResponse !== null) {
523 setupResponseListeners(reply)
524 }
525
526 if (context.onRequest !== null) {
527 onRequestHookRunner(
528 context.onRequest,
529 request,
530 reply,
531 runPreParsing
532 )
533 } else {
534 runPreParsing(null, request, reply)
535 }
536
537 if (context.onRequestAbort !== null) {
538 req.on('close', () => {
539 /* istanbul ignore else */
540 if (req.aborted) {
541 onRequestAbortHookRunner(
542 context.onRequestAbort,
543 request,
544 handleOnRequestAbortHooksErrors.bind(null, reply)
545 )
546 }
547 })
548 }
549
550 if (context.onTimeout !== null) {
551 if (!request.raw.socket._meta) {
552 request.raw.socket.on('timeout', handleTimeout)
553 }
554 request.raw.socket._meta = { context, request, reply }
555 }
556 }
557}
558
559function handleOnRequestAbortHooksErrors (reply, err) {
560 if (err) {
561 reply.log.error({ err }, 'onRequestAborted hook failed')
562 }
563}
564
565function handleTimeout () {
566 const { context, request, reply } = this._meta
567 onTimeoutHookRunner(
568 context.onTimeout,
569 request,
570 reply,
571 noop
572 )
573}
574
575function normalizeAndValidateMethod (method) {
576 if (typeof method !== 'string') {
577 throw new FST_ERR_ROUTE_METHOD_INVALID()
578 }
579 method = method.toUpperCase()
580 if (supportedMethods.indexOf(method) === -1) {
581 throw new FST_ERR_ROUTE_METHOD_NOT_SUPPORTED(method)
582 }
583
584 return method
585}
586
587function validateSchemaBodyOption (method, path, schema) {
588 if ((method === 'GET' || method === 'HEAD') && schema && schema.body) {
589 throw new FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED(method, path)
590 }
591}
592
593function validateBodyLimitOption (bodyLimit) {
594 if (bodyLimit === undefined) return
595 if (!Number.isInteger(bodyLimit) || bodyLimit <= 0) {
596 throw new FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT(bodyLimit)
597 }
598}
599
600function runPreParsing (err, request, reply) {
601 if (reply.sent === true) return
602 if (err != null) {
603 reply[kReplyIsError] = true
604 reply.send(err)
605 return
606 }
607
608 request[kRequestPayloadStream] = request.raw
609
610 if (request[kRouteContext].preParsing !== null) {
611 preParsingHookRunner(request[kRouteContext].preParsing, request, reply, handleRequest)
612 } else {
613 handleRequest(null, request, reply)
614 }
615}
616
617/**
618 * Used within the route handler as a `net.Socket.close` event handler.
619 * The purpose is to remove a socket from the tracked sockets collection when
620 * the socket has naturally timed out.
621 */
622function removeTrackedSocket () {
623 this.keepAliveConnections.delete(this.socket)
624}
625
626function noop () { }
627
628module.exports = { buildRouting, validateBodyLimitOption }