You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
517 lines
16 KiB
517 lines
16 KiB
'use strict'
|
|
|
|
const FindMyWay = require('find-my-way')
|
|
const Context = require('./context')
|
|
const handleRequest = require('./handleRequest')
|
|
const { hookRunner, hookIterator, lifecycleHooks } = require('./hooks')
|
|
const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']
|
|
const { normalizeSchema } = require('./schemas')
|
|
const { parseHeadOnSendHandlers } = require('./headRoute')
|
|
const warning = require('./warnings')
|
|
const { kRequestAcceptVersion } = require('./symbols')
|
|
|
|
const {
|
|
compileSchemasForValidation,
|
|
compileSchemasForSerialization
|
|
} = require('./validation')
|
|
|
|
const {
|
|
FST_ERR_SCH_VALIDATION_BUILD,
|
|
FST_ERR_SCH_SERIALIZATION_BUILD,
|
|
FST_ERR_DEFAULT_ROUTE_INVALID_TYPE,
|
|
FST_ERR_INVALID_URL
|
|
} = require('./errors')
|
|
|
|
const {
|
|
kRoutePrefix,
|
|
kLogLevel,
|
|
kLogSerializers,
|
|
kHooks,
|
|
kHooksDeprecatedPreParsing,
|
|
kSchemaController,
|
|
kOptions,
|
|
kContentTypeParser,
|
|
kReply,
|
|
kReplySerializerDefault,
|
|
kReplyIsError,
|
|
kRequest,
|
|
kRequestPayloadStream,
|
|
kDisableRequestLogging,
|
|
kSchemaErrorFormatter,
|
|
kErrorHandler
|
|
} = require('./symbols.js')
|
|
|
|
function buildRouting (options) {
|
|
const { keepAliveConnections } = options
|
|
const router = FindMyWay(options.config)
|
|
|
|
let avvio
|
|
let fourOhFour
|
|
let requestIdHeader
|
|
let querystringParser
|
|
let requestIdLogLabel
|
|
let logger
|
|
let hasLogger
|
|
let setupResponseListeners
|
|
let throwIfAlreadyStarted
|
|
let genReqId
|
|
let disableRequestLogging
|
|
let ignoreTrailingSlash
|
|
let return503OnClosing
|
|
let globalExposeHeadRoutes
|
|
|
|
let closing = false
|
|
|
|
return {
|
|
setup (options, fastifyArgs) {
|
|
avvio = fastifyArgs.avvio
|
|
fourOhFour = fastifyArgs.fourOhFour
|
|
logger = fastifyArgs.logger
|
|
hasLogger = fastifyArgs.hasLogger
|
|
setupResponseListeners = fastifyArgs.setupResponseListeners
|
|
throwIfAlreadyStarted = fastifyArgs.throwIfAlreadyStarted
|
|
|
|
globalExposeHeadRoutes = options.exposeHeadRoutes
|
|
requestIdHeader = options.requestIdHeader
|
|
querystringParser = options.querystringParser
|
|
requestIdLogLabel = options.requestIdLogLabel
|
|
genReqId = options.genReqId
|
|
disableRequestLogging = options.disableRequestLogging
|
|
ignoreTrailingSlash = options.ignoreTrailingSlash
|
|
return503OnClosing = Object.prototype.hasOwnProperty.call(options, 'return503OnClosing') ? options.return503OnClosing : true
|
|
},
|
|
routing: router.lookup.bind(router), // router func to find the right handler to call
|
|
route, // configure a route in the fastify instance
|
|
prepareRoute,
|
|
getDefaultRoute: function () {
|
|
return router.defaultRoute
|
|
},
|
|
setDefaultRoute: function (defaultRoute) {
|
|
if (typeof defaultRoute !== 'function') {
|
|
throw new FST_ERR_DEFAULT_ROUTE_INVALID_TYPE()
|
|
}
|
|
|
|
router.defaultRoute = defaultRoute
|
|
},
|
|
routeHandler,
|
|
closeRoutes: () => { closing = true },
|
|
printRoutes: router.prettyPrint.bind(router)
|
|
}
|
|
|
|
// Convert shorthand to extended route declaration
|
|
function prepareRoute (method, url, options, handler) {
|
|
if (typeof url !== 'string') {
|
|
throw new FST_ERR_INVALID_URL(typeof url)
|
|
}
|
|
|
|
if (!handler && typeof options === 'function') {
|
|
handler = options // for support over direct function calls such as fastify.get() options are reused as the handler
|
|
options = {}
|
|
} else if (handler && typeof handler === 'function') {
|
|
if (Object.prototype.toString.call(options) !== '[object Object]') {
|
|
throw new Error(`Options for ${method}:${url} route must be an object`)
|
|
} else if (options.handler) {
|
|
if (typeof options.handler === 'function') {
|
|
throw new Error(`Duplicate handler for ${method}:${url} route is not allowed!`)
|
|
} else {
|
|
throw new Error(`Handler for ${method}:${url} route must be a function`)
|
|
}
|
|
}
|
|
}
|
|
|
|
options = Object.assign({}, options, {
|
|
method,
|
|
url,
|
|
path: url,
|
|
handler: handler || (options && options.handler)
|
|
})
|
|
|
|
return route.call(this, options)
|
|
}
|
|
|
|
// Route management
|
|
function route (options) {
|
|
// Since we are mutating/assigning only top level props, it is fine to have a shallow copy using the spread operator
|
|
const opts = { ...options }
|
|
|
|
throwIfAlreadyStarted('Cannot add route when fastify instance is already started!')
|
|
|
|
if (Array.isArray(opts.method)) {
|
|
// eslint-disable-next-line no-var
|
|
for (var i = 0; i < opts.method.length; ++i) {
|
|
const method = opts.method[i]
|
|
if (supportedMethods.indexOf(method) === -1) {
|
|
throw new Error(`${method} method is not supported!`)
|
|
}
|
|
}
|
|
} else {
|
|
if (supportedMethods.indexOf(opts.method) === -1) {
|
|
throw new Error(`${opts.method} method is not supported!`)
|
|
}
|
|
}
|
|
|
|
const path = opts.url || opts.path
|
|
|
|
if (!opts.handler) {
|
|
throw new Error(`Missing handler function for ${opts.method}:${path} route.`)
|
|
}
|
|
|
|
if (opts.errorHandler !== undefined && typeof opts.errorHandler !== 'function') {
|
|
throw new Error(`Error Handler for ${opts.method}:${path} route, if defined, must be a function`)
|
|
}
|
|
|
|
validateBodyLimitOption(opts.bodyLimit)
|
|
|
|
const prefix = this[kRoutePrefix]
|
|
|
|
this.after((notHandledErr, done) => {
|
|
if (path === '/' && prefix.length && opts.method !== 'HEAD') {
|
|
switch (opts.prefixTrailingSlash) {
|
|
case 'slash':
|
|
afterRouteAdded.call(this, { path }, notHandledErr, done)
|
|
break
|
|
case 'no-slash':
|
|
afterRouteAdded.call(this, { path: '' }, notHandledErr, done)
|
|
break
|
|
case 'both':
|
|
default:
|
|
afterRouteAdded.call(this, { path: '' }, notHandledErr, done)
|
|
// If ignoreTrailingSlash is set to true we need to add only the '' route to prevent adding an incomplete one.
|
|
if (ignoreTrailingSlash !== true) {
|
|
afterRouteAdded.call(this, { path, prefixing: true }, notHandledErr, done)
|
|
}
|
|
}
|
|
} else if (path && path[0] === '/' && prefix.endsWith('/')) {
|
|
// Ensure that '/prefix/' + '/route' gets registered as '/prefix/route'
|
|
afterRouteAdded.call(this, { path: path.slice(1) }, notHandledErr, done)
|
|
} else {
|
|
afterRouteAdded.call(this, { path }, notHandledErr, done)
|
|
}
|
|
})
|
|
|
|
// chainable api
|
|
return this
|
|
|
|
/**
|
|
* This function sets up a new route, its log serializers, and triggers route hooks.
|
|
*
|
|
* @param {object} opts contains route `path` and `prefixing` flag which indicates if this is an auto-prefixed route, e.g. `fastify.register(routes, { prefix: '/foo' })`
|
|
* @param {*} notHandledErr error object to be passed back to the original invoker
|
|
* @param {*} done callback
|
|
*/
|
|
function afterRouteAdded ({ path, prefixing = false }, notHandledErr, done) {
|
|
const url = prefix + path
|
|
|
|
opts.url = url
|
|
opts.path = url
|
|
opts.routePath = path
|
|
opts.prefix = prefix
|
|
opts.logLevel = opts.logLevel || this[kLogLevel]
|
|
|
|
if (this[kLogSerializers] || opts.logSerializers) {
|
|
opts.logSerializers = Object.assign(Object.create(this[kLogSerializers]), opts.logSerializers)
|
|
}
|
|
|
|
if (opts.attachValidation == null) {
|
|
opts.attachValidation = false
|
|
}
|
|
|
|
if (prefixing === false) {
|
|
// run 'onRoute' hooks
|
|
for (const hook of this[kHooks].onRoute) {
|
|
try {
|
|
hook.call(this, opts)
|
|
} catch (error) {
|
|
done(error)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
const config = {
|
|
...opts.config,
|
|
url,
|
|
method: opts.method
|
|
}
|
|
const constraints = opts.constraints || {}
|
|
if (opts.version) {
|
|
warning.emit('FSTDEP008')
|
|
constraints.version = opts.version
|
|
}
|
|
|
|
const context = new Context(
|
|
opts.schema,
|
|
opts.handler.bind(this),
|
|
this[kReply],
|
|
this[kRequest],
|
|
this[kContentTypeParser],
|
|
config,
|
|
opts.errorHandler || this[kErrorHandler],
|
|
opts.bodyLimit,
|
|
opts.logLevel,
|
|
opts.logSerializers,
|
|
opts.attachValidation,
|
|
this[kReplySerializerDefault],
|
|
opts.schemaErrorFormatter || this[kSchemaErrorFormatter]
|
|
)
|
|
|
|
const headRouteExists = router.find('HEAD', url, constraints) != null
|
|
|
|
try {
|
|
router.on(opts.method, opts.url, { constraints }, routeHandler, context)
|
|
} catch (err) {
|
|
done(err)
|
|
return
|
|
}
|
|
|
|
const { exposeHeadRoute } = opts
|
|
const hasRouteExposeHeadRouteFlag = exposeHeadRoute != null
|
|
const shouldExposeHead = hasRouteExposeHeadRouteFlag ? exposeHeadRoute : globalExposeHeadRoutes
|
|
|
|
if (shouldExposeHead && options.method === 'GET' && !headRouteExists) {
|
|
const onSendHandlers = parseHeadOnSendHandlers(opts.onSend)
|
|
prepareRoute.call(this, 'HEAD', path, { ...opts, onSend: onSendHandlers })
|
|
} else if (headRouteExists && exposeHeadRoute) {
|
|
warning.emit('FSTDEP007')
|
|
}
|
|
|
|
// It can happen that a user registers a plugin with some hooks *after*
|
|
// the route registration. To be sure to also load those hooks,
|
|
// we must listen for the avvio's preReady event, and update the context object accordingly.
|
|
avvio.once('preReady', () => {
|
|
for (const hook of lifecycleHooks) {
|
|
const toSet = this[kHooks][hook]
|
|
.concat(opts[hook] || [])
|
|
.map(h => {
|
|
const bound = h.bind(this)
|
|
|
|
// Track hooks deprecation markers
|
|
if (hook === 'preParsing') {
|
|
// Check for deprecation syntax
|
|
if (h.length === (h.constructor.name === 'AsyncFunction' ? 2 : 3)) {
|
|
warning.emit('FSTDEP004')
|
|
bound[kHooksDeprecatedPreParsing] = true
|
|
}
|
|
}
|
|
|
|
return bound
|
|
})
|
|
context[hook] = toSet.length ? toSet : null
|
|
}
|
|
|
|
// Must store the 404 Context in 'preReady' because it is only guaranteed to
|
|
// be available after all of the plugins and routes have been loaded.
|
|
fourOhFour.setContext(this, context)
|
|
|
|
if (opts.schema) {
|
|
context.schema = normalizeSchema(context.schema, this.initialConfig)
|
|
|
|
const schemaController = this[kSchemaController]
|
|
if (!opts.validatorCompiler && (opts.schema.body || opts.schema.headers || opts.schema.querystring || opts.schema.params)) {
|
|
schemaController.setupValidator(this[kOptions])
|
|
}
|
|
try {
|
|
compileSchemasForValidation(context, opts.validatorCompiler || schemaController.validatorCompiler)
|
|
} catch (error) {
|
|
throw new FST_ERR_SCH_VALIDATION_BUILD(opts.method, url, error.message)
|
|
}
|
|
|
|
if (opts.schema.response && !opts.serializerCompiler) {
|
|
schemaController.setupSerializer(this[kOptions])
|
|
}
|
|
try {
|
|
compileSchemasForSerialization(context, opts.serializerCompiler || schemaController.serializerCompiler)
|
|
} catch (error) {
|
|
throw new FST_ERR_SCH_SERIALIZATION_BUILD(opts.method, url, error.message)
|
|
}
|
|
}
|
|
})
|
|
|
|
done(notHandledErr)
|
|
}
|
|
}
|
|
|
|
// HTTP request entry point, the routing has already been executed
|
|
function routeHandler (req, res, params, context) {
|
|
if (closing === true) {
|
|
/* istanbul ignore next mac, windows */
|
|
if (req.httpVersionMajor !== 2) {
|
|
res.once('finish', () => req.destroy())
|
|
res.setHeader('Connection', 'close')
|
|
}
|
|
|
|
if (return503OnClosing) {
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': '80'
|
|
}
|
|
res.writeHead(503, headers)
|
|
res.end('{"error":"Service Unavailable","message":"Service Unavailable","statusCode":503}')
|
|
return
|
|
}
|
|
}
|
|
|
|
// When server.forceCloseConnections is true, we will collect any requests
|
|
// that have indicated they want persistence so that they can be reaped
|
|
// on server close. Otherwise, the container is a noop container.
|
|
const connHeader = String.prototype.toLowerCase.call(req.headers.connection || '')
|
|
if (connHeader === 'keep-alive') {
|
|
if (keepAliveConnections.has(req.socket) === false) {
|
|
keepAliveConnections.add(req.socket)
|
|
req.socket.on('close', removeTrackedSocket.bind({ keepAliveConnections, socket: req.socket }))
|
|
}
|
|
}
|
|
|
|
// we revert the changes in defaultRoute
|
|
if (req.headers[kRequestAcceptVersion] !== undefined) {
|
|
req.headers['accept-version'] = req.headers[kRequestAcceptVersion]
|
|
req.headers[kRequestAcceptVersion] = undefined
|
|
}
|
|
|
|
const id = req.headers[requestIdHeader] || genReqId(req)
|
|
|
|
const loggerBinding = {
|
|
[requestIdLogLabel]: id
|
|
}
|
|
|
|
const loggerOpts = {
|
|
level: context.logLevel
|
|
}
|
|
|
|
if (context.logSerializers) {
|
|
loggerOpts.serializers = context.logSerializers
|
|
}
|
|
const childLogger = logger.child(loggerBinding, loggerOpts)
|
|
childLogger[kDisableRequestLogging] = disableRequestLogging
|
|
|
|
const queryPrefix = req.url.indexOf('?')
|
|
const query = querystringParser(queryPrefix > -1 ? req.url.slice(queryPrefix + 1) : '')
|
|
const request = new context.Request(id, params, req, query, childLogger, context)
|
|
const reply = new context.Reply(res, request, childLogger)
|
|
|
|
if (disableRequestLogging === false) {
|
|
childLogger.info({ req: request }, 'incoming request')
|
|
}
|
|
|
|
if (hasLogger === true || context.onResponse !== null) {
|
|
setupResponseListeners(reply)
|
|
}
|
|
|
|
if (context.onRequest !== null) {
|
|
hookRunner(
|
|
context.onRequest,
|
|
hookIterator,
|
|
request,
|
|
reply,
|
|
runPreParsing
|
|
)
|
|
} else {
|
|
runPreParsing(null, request, reply)
|
|
}
|
|
|
|
if (context.onTimeout !== null) {
|
|
if (!request.raw.socket._meta) {
|
|
request.raw.socket.on('timeout', handleTimeout)
|
|
}
|
|
request.raw.socket._meta = { context, request, reply }
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleTimeout () {
|
|
const { context, request, reply } = this._meta
|
|
hookRunner(
|
|
context.onTimeout,
|
|
hookIterator,
|
|
request,
|
|
reply,
|
|
noop
|
|
)
|
|
}
|
|
|
|
function validateBodyLimitOption (bodyLimit) {
|
|
if (bodyLimit === undefined) return
|
|
if (!Number.isInteger(bodyLimit) || bodyLimit <= 0) {
|
|
throw new TypeError(`'bodyLimit' option must be an integer > 0. Got '${bodyLimit}'`)
|
|
}
|
|
}
|
|
|
|
function runPreParsing (err, request, reply) {
|
|
if (reply.sent === true) return
|
|
if (err != null) {
|
|
reply.send(err)
|
|
return
|
|
}
|
|
|
|
request[kRequestPayloadStream] = request.raw
|
|
|
|
if (reply.context.preParsing !== null) {
|
|
preParsingHookRunner(reply.context.preParsing, request, reply, handleRequest)
|
|
} else {
|
|
handleRequest(null, request, reply)
|
|
}
|
|
}
|
|
|
|
function preParsingHookRunner (functions, request, reply, cb) {
|
|
let i = 0
|
|
|
|
function next (err, stream) {
|
|
if (reply.sent) {
|
|
return
|
|
}
|
|
|
|
if (typeof stream !== 'undefined') {
|
|
request[kRequestPayloadStream] = stream
|
|
}
|
|
|
|
if (err || i === functions.length) {
|
|
if (err && !(err instanceof Error)) {
|
|
reply[kReplyIsError] = true
|
|
}
|
|
|
|
cb(err, request, reply)
|
|
return
|
|
}
|
|
|
|
const fn = functions[i++]
|
|
let result
|
|
try {
|
|
if (fn[kHooksDeprecatedPreParsing]) {
|
|
result = fn(request, reply, next)
|
|
} else {
|
|
result = fn(request, reply, request[kRequestPayloadStream], next)
|
|
}
|
|
} catch (error) {
|
|
next(error)
|
|
return
|
|
}
|
|
|
|
if (result && typeof result.then === 'function') {
|
|
result.then(handleResolve, handleReject)
|
|
}
|
|
}
|
|
|
|
function handleResolve (stream) {
|
|
next(null, stream)
|
|
}
|
|
|
|
function handleReject (err) {
|
|
next(err)
|
|
}
|
|
|
|
next(null, request[kRequestPayloadStream])
|
|
}
|
|
|
|
/**
|
|
* Used within the route handler as a `net.Socket.close` event handler.
|
|
* The purpose is to remove a socket from the tracked sockets collection when
|
|
* the socket has naturally timed out.
|
|
*/
|
|
function removeTrackedSocket () {
|
|
this.keepAliveConnections.delete(this.socket)
|
|
}
|
|
|
|
function noop () { }
|
|
|
|
module.exports = { buildRouting, validateBodyLimitOption }
|