strong-tie/inbound-calls
0
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 { normalizeSchema } = require('./schemas')8const { parseHeadOnSendHandlers } = require('./headRoute')9 10const {11 compileSchemasForValidation,12 compileSchemasForSerialization13} = require('./validation')14 15const {16 FST_ERR_SCH_VALIDATION_BUILD,17 FST_ERR_SCH_SERIALIZATION_BUILD,18 FST_ERR_DUPLICATED_ROUTE,19 FST_ERR_INVALID_URL,20 FST_ERR_HOOK_INVALID_HANDLER,21 FST_ERR_ROUTE_OPTIONS_NOT_OBJ,22 FST_ERR_ROUTE_DUPLICATED_HANDLER,23 FST_ERR_ROUTE_HANDLER_NOT_FN,24 FST_ERR_ROUTE_MISSING_HANDLER,25 FST_ERR_ROUTE_METHOD_NOT_SUPPORTED,26 FST_ERR_ROUTE_METHOD_INVALID,27 FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED,28 FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT,29 FST_ERR_HOOK_INVALID_ASYNC_HANDLER30} = require('./errors')31 32const {33 kRoutePrefix,34 kSupportedHTTPMethods,35 kLogLevel,36 kLogSerializers,37 kHooks,38 kSchemaController,39 kOptions,40 kReplySerializerDefault,41 kReplyIsError,42 kRequestPayloadStream,43 kDisableRequestLogging,44 kSchemaErrorFormatter,45 kErrorHandler,46 kHasBeenDecorated,47 kRequestAcceptVersion,48 kRouteByFastify,49 kRouteContext50} = require('./symbols.js')51const { buildErrorHandler } = require('./error-handler')52const { createChildLogger } = require('./logger-factory.js')53const { getGenReqId } = require('./reqIdGenFactory.js')54 55function buildRouting (options) {56 const router = FindMyWay(options.config)57 58 let avvio59 let fourOhFour60 let logger61 let hasLogger62 let setupResponseListeners63 let throwIfAlreadyStarted64 let disableRequestLogging65 let ignoreTrailingSlash66 let ignoreDuplicateSlashes67 let return503OnClosing68 let globalExposeHeadRoutes69 let keepAliveConnections70 71 let closing = false72 73 return {74 /**75 * @param {import('../fastify').FastifyServerOptions} options76 * @param {*} fastifyArgs77 */78 setup (options, fastifyArgs) {79 avvio = fastifyArgs.avvio80 fourOhFour = fastifyArgs.fourOhFour81 logger = fastifyArgs.logger82 hasLogger = fastifyArgs.hasLogger83 setupResponseListeners = fastifyArgs.setupResponseListeners84 throwIfAlreadyStarted = fastifyArgs.throwIfAlreadyStarted85 86 globalExposeHeadRoutes = options.exposeHeadRoutes87 disableRequestLogging = options.disableRequestLogging88 ignoreTrailingSlash = options.ignoreTrailingSlash89 ignoreDuplicateSlashes = options.ignoreDuplicateSlashes90 return503OnClosing = Object.hasOwn(options, 'return503OnClosing') ? options.return503OnClosing : true91 keepAliveConnections = fastifyArgs.keepAliveConnections92 },93 routing: router.lookup.bind(router), // router func to find the right handler to call94 route, // configure a route in the fastify instance95 hasRoute,96 prepareRoute,97 routeHandler,98 closeRoutes: () => { closing = true },99 printRoutes: router.prettyPrint.bind(router),100 addConstraintStrategy,101 hasConstraintStrategy,102 isAsyncConstraint,103 findRoute104 }105 106 function addConstraintStrategy (strategy) {107 throwIfAlreadyStarted('Cannot add constraint strategy!')108 return router.addConstraintStrategy(strategy)109 }110 111 function hasConstraintStrategy (strategyName) {112 return router.hasConstraintStrategy(strategyName)113 }114 115 function isAsyncConstraint () {116 return router.constrainer.asyncStrategiesInUse.size > 0117 }118 119 // Convert shorthand to extended route declaration120 function prepareRoute ({ method, url, options, handler, isFastify }) {121 if (typeof url !== 'string') {122 throw new FST_ERR_INVALID_URL(typeof url)123 }124 125 if (!handler && typeof options === 'function') {126 handler = options // for support over direct function calls such as fastify.get() options are reused as the handler127 options = {}128 } else if (handler && typeof handler === 'function') {129 if (Object.prototype.toString.call(options) !== '[object Object]') {130 throw new FST_ERR_ROUTE_OPTIONS_NOT_OBJ(method, url)131 } else if (options.handler) {132 if (typeof options.handler === 'function') {133 throw new FST_ERR_ROUTE_DUPLICATED_HANDLER(method, url)134 } else {135 throw new FST_ERR_ROUTE_HANDLER_NOT_FN(method, url)136 }137 }138 }139 140 options = Object.assign({}, options, {141 method,142 url,143 path: url,144 handler: handler || (options && options.handler)145 })146 147 return route.call(this, { options, isFastify })148 }149 150 function hasRoute ({ options }) {151 const normalizedMethod = options.method?.toUpperCase() ?? ''152 return router.hasRoute(153 normalizedMethod,154 options.url || '',155 options.constraints156 )157 }158 159 function findRoute (options) {160 const route = router.find(161 options.method,162 options.url || '',163 options.constraints164 )165 if (route) {166 // we must reduce the expose surface, otherwise167 // we provide the ability for the user to modify168 // all the route and server information in runtime169 return {170 handler: route.handler,171 params: route.params,172 searchParams: route.searchParams173 }174 } else {175 return null176 }177 }178 179 /**180 * Route management181 * @param {{ options: import('../fastify').RouteOptions, isFastify: boolean }}182 */183 function route ({ options, isFastify }) {184 throwIfAlreadyStarted('Cannot add route!')185 186 // Since we are mutating/assigning only top level props, it is fine to have a shallow copy using the spread operator187 const opts = { ...options }188 189 const path = opts.url || opts.path || ''190 191 if (!opts.handler) {192 throw new FST_ERR_ROUTE_MISSING_HANDLER(opts.method, path)193 }194 195 if (opts.errorHandler !== undefined && typeof opts.errorHandler !== 'function') {196 throw new FST_ERR_ROUTE_HANDLER_NOT_FN(opts.method, path)197 }198 199 validateBodyLimitOption(opts.bodyLimit)200 201 const shouldExposeHead = opts.exposeHeadRoute ?? globalExposeHeadRoutes202 203 let isGetRoute = false204 let isHeadRoute = false205 206 if (Array.isArray(opts.method)) {207 for (let i = 0; i < opts.method.length; ++i) {208 opts.method[i] = normalizeAndValidateMethod.call(this, opts.method[i])209 validateSchemaBodyOption.call(this, opts.method[i], path, opts.schema)210 211 isGetRoute = opts.method.includes('GET')212 isHeadRoute = opts.method.includes('HEAD')213 }214 } else {215 opts.method = normalizeAndValidateMethod.call(this, opts.method)216 validateSchemaBodyOption.call(this, opts.method, path, opts.schema)217 218 isGetRoute = opts.method === 'GET'219 isHeadRoute = opts.method === 'HEAD'220 }221 222 // we need to clone a set of initial options for HEAD route223 const headOpts = shouldExposeHead && isGetRoute ? { ...options } : null224 225 const prefix = this[kRoutePrefix]226 227 if (path === '/' && prefix.length > 0 && opts.method !== 'HEAD') {228 switch (opts.prefixTrailingSlash) {229 case 'slash':230 addNewRoute.call(this, { path, isFastify })231 break232 case 'no-slash':233 addNewRoute.call(this, { path: '', isFastify })234 break235 case 'both':236 default:237 addNewRoute.call(this, { path: '', isFastify })238 // If ignoreTrailingSlash is set to true we need to add only the '' route to prevent adding an incomplete one.239 if (ignoreTrailingSlash !== true && (ignoreDuplicateSlashes !== true || !prefix.endsWith('/'))) {240 addNewRoute.call(this, { path, prefixing: true, isFastify })241 }242 }243 } else if (path[0] === '/' && prefix.endsWith('/')) {244 // Ensure that '/prefix/' + '/route' gets registered as '/prefix/route'245 addNewRoute.call(this, { path: path.slice(1), isFastify })246 } else {247 addNewRoute.call(this, { path, isFastify })248 }249 250 // chainable api251 return this252 253 function addNewRoute ({ path, prefixing = false, isFastify = false }) {254 const url = prefix + path255 256 opts.url = url257 opts.path = url258 opts.routePath = path259 opts.prefix = prefix260 opts.logLevel = opts.logLevel || this[kLogLevel]261 262 if (this[kLogSerializers] || opts.logSerializers) {263 opts.logSerializers = Object.assign(Object.create(this[kLogSerializers]), opts.logSerializers)264 }265 266 if (opts.attachValidation == null) {267 opts.attachValidation = false268 }269 270 if (prefixing === false) {271 // run 'onRoute' hooks272 for (const hook of this[kHooks].onRoute) {273 hook.call(this, opts)274 }275 }276 277 for (const hook of lifecycleHooks) {278 if (opts && hook in opts) {279 if (Array.isArray(opts[hook])) {280 for (const func of opts[hook]) {281 if (typeof func !== 'function') {282 throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(func))283 }284 285 if (hook === 'onSend' || hook === 'preSerialization' || hook === 'onError' || hook === 'preParsing') {286 if (func.constructor.name === 'AsyncFunction' && func.length === 4) {287 throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER()288 }289 } else if (hook === 'onRequestAbort') {290 if (func.constructor.name === 'AsyncFunction' && func.length !== 1) {291 throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER()292 }293 } else {294 if (func.constructor.name === 'AsyncFunction' && func.length === 3) {295 throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER()296 }297 }298 }299 } else if (opts[hook] !== undefined && typeof opts[hook] !== 'function') {300 throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(opts[hook]))301 }302 }303 }304 305 const constraints = opts.constraints || {}306 const config = {307 ...opts.config,308 url,309 method: opts.method310 }311 312 const context = new Context({313 schema: opts.schema,314 handler: opts.handler.bind(this),315 config,316 errorHandler: opts.errorHandler,317 childLoggerFactory: opts.childLoggerFactory,318 bodyLimit: opts.bodyLimit,319 logLevel: opts.logLevel,320 logSerializers: opts.logSerializers,321 attachValidation: opts.attachValidation,322 schemaErrorFormatter: opts.schemaErrorFormatter,323 replySerializer: this[kReplySerializerDefault],324 validatorCompiler: opts.validatorCompiler,325 serializerCompiler: opts.serializerCompiler,326 exposeHeadRoute: shouldExposeHead,327 prefixTrailingSlash: (opts.prefixTrailingSlash || 'both'),328 server: this,329 isFastify330 })331 332 const headHandler = router.findRoute('HEAD', opts.url, constraints)333 const hasHEADHandler = headHandler !== null334 335 try {336 router.on(opts.method, opts.url, { constraints }, routeHandler, context)337 } catch (error) {338 // any route insertion error created by fastify can be safely ignore339 // because it only duplicate route for head340 if (!context[kRouteByFastify]) {341 const isDuplicatedRoute = error.message.includes(`Method '${opts.method}' already declared for route`)342 if (isDuplicatedRoute) {343 throw new FST_ERR_DUPLICATED_ROUTE(opts.method, opts.url)344 }345 346 throw error347 }348 }349 350 this.after((notHandledErr, done) => {351 // Send context async352 context.errorHandler = opts.errorHandler ? buildErrorHandler(this[kErrorHandler], opts.errorHandler) : this[kErrorHandler]353 context._parserOptions.limit = opts.bodyLimit || null354 context.logLevel = opts.logLevel355 context.logSerializers = opts.logSerializers356 context.attachValidation = opts.attachValidation357 context[kReplySerializerDefault] = this[kReplySerializerDefault]358 context.schemaErrorFormatter = opts.schemaErrorFormatter || this[kSchemaErrorFormatter] || context.schemaErrorFormatter359 360 // Run hooks and more361 avvio.once('preReady', () => {362 for (const hook of lifecycleHooks) {363 const toSet = this[kHooks][hook]364 .concat(opts[hook] || [])365 .map(h => h.bind(this))366 context[hook] = toSet.length ? toSet : null367 }368 369 // Optimization: avoid encapsulation if no decoration has been done.370 while (!context.Request[kHasBeenDecorated] && context.Request.parent) {371 context.Request = context.Request.parent372 }373 while (!context.Reply[kHasBeenDecorated] && context.Reply.parent) {374 context.Reply = context.Reply.parent375 }376 377 // Must store the 404 Context in 'preReady' because it is only guaranteed to378 // be available after all of the plugins and routes have been loaded.379 fourOhFour.setContext(this, context)380 381 if (opts.schema) {382 context.schema = normalizeSchema(context.schema, this.initialConfig)383 384 const schemaController = this[kSchemaController]385 if (!opts.validatorCompiler && (opts.schema.body || opts.schema.headers || opts.schema.querystring || opts.schema.params)) {386 schemaController.setupValidator(this[kOptions])387 }388 try {389 const isCustom = typeof opts?.validatorCompiler === 'function' || schemaController.isCustomValidatorCompiler390 compileSchemasForValidation(context, opts.validatorCompiler || schemaController.validatorCompiler, isCustom)391 } catch (error) {392 throw new FST_ERR_SCH_VALIDATION_BUILD(opts.method, url, error.message)393 }394 395 if (opts.schema.response && !opts.serializerCompiler) {396 schemaController.setupSerializer(this[kOptions])397 }398 try {399 compileSchemasForSerialization(context, opts.serializerCompiler || schemaController.serializerCompiler)400 } catch (error) {401 throw new FST_ERR_SCH_SERIALIZATION_BUILD(opts.method, url, error.message)402 }403 }404 })405 406 done(notHandledErr)407 })408 409 // register head route in sync410 // we must place it after the `this.after`411 412 if (shouldExposeHead && isGetRoute && !isHeadRoute && !hasHEADHandler) {413 const onSendHandlers = parseHeadOnSendHandlers(headOpts.onSend)414 prepareRoute.call(this, { method: 'HEAD', url: path, options: { ...headOpts, onSend: onSendHandlers }, isFastify: true })415 }416 }417 }418 419 // HTTP request entry point, the routing has already been executed420 function routeHandler (req, res, params, context, query) {421 const id = getGenReqId(context.server, req)422 423 const loggerOpts = {424 level: context.logLevel425 }426 427 if (context.logSerializers) {428 loggerOpts.serializers = context.logSerializers429 }430 const childLogger = createChildLogger(context, logger, req, id, loggerOpts)431 childLogger[kDisableRequestLogging] = disableRequestLogging432 433 if (closing === true) {434 /* istanbul ignore next mac, windows */435 if (req.httpVersionMajor !== 2) {436 res.setHeader('Connection', 'close')437 }438 439 // TODO remove return503OnClosing after Node v18 goes EOL440 /* istanbul ignore else */441 if (return503OnClosing) {442 // On Node v19 we cannot test this behavior as it won't be necessary443 // anymore. It will close all the idle connections before they reach this444 // stage.445 const headers = {446 'Content-Type': 'application/json',447 'Content-Length': '80'448 }449 res.writeHead(503, headers)450 res.end('{"error":"Service Unavailable","message":"Service Unavailable","statusCode":503}')451 childLogger.info({ res: { statusCode: 503 } }, 'request aborted - refusing to accept new requests as server is closing')452 return453 }454 }455 456 // When server.forceCloseConnections is true, we will collect any requests457 // that have indicated they want persistence so that they can be reaped458 // on server close. Otherwise, the container is a noop container.459 const connHeader = String.prototype.toLowerCase.call(req.headers.connection || '')460 if (connHeader === 'keep-alive') {461 if (keepAliveConnections.has(req.socket) === false) {462 keepAliveConnections.add(req.socket)463 req.socket.on('close', removeTrackedSocket.bind({ keepAliveConnections, socket: req.socket }))464 }465 }466 467 // we revert the changes in defaultRoute468 if (req.headers[kRequestAcceptVersion] !== undefined) {469 req.headers['accept-version'] = req.headers[kRequestAcceptVersion]470 req.headers[kRequestAcceptVersion] = undefined471 }472 473 const request = new context.Request(id, params, req, query, childLogger, context)474 const reply = new context.Reply(res, request, childLogger)475 if (disableRequestLogging === false) {476 childLogger.info({ req: request }, 'incoming request')477 }478 479 if (hasLogger === true || context.onResponse !== null) {480 setupResponseListeners(reply)481 }482 483 if (context.onRequest !== null) {484 onRequestHookRunner(485 context.onRequest,486 request,487 reply,488 runPreParsing489 )490 } else {491 runPreParsing(null, request, reply)492 }493 494 if (context.onRequestAbort !== null) {495 req.on('close', () => {496 /* istanbul ignore else */497 if (req.aborted) {498 onRequestAbortHookRunner(499 context.onRequestAbort,500 request,501 handleOnRequestAbortHooksErrors.bind(null, reply)502 )503 }504 })505 }506 507 if (context.onTimeout !== null) {508 if (!request.raw.socket._meta) {509 request.raw.socket.on('timeout', handleTimeout)510 }511 request.raw.socket._meta = { context, request, reply }512 }513 }514}515 516function handleOnRequestAbortHooksErrors (reply, err) {517 if (err) {518 reply.log.error({ err }, 'onRequestAborted hook failed')519 }520}521 522function handleTimeout () {523 const { context, request, reply } = this._meta524 onTimeoutHookRunner(525 context.onTimeout,526 request,527 reply,528 noop529 )530}531 532function normalizeAndValidateMethod (method) {533 if (typeof method !== 'string') {534 throw new FST_ERR_ROUTE_METHOD_INVALID()535 }536 method = method.toUpperCase()537 if (!this[kSupportedHTTPMethods].bodyless.has(method) &&538 !this[kSupportedHTTPMethods].bodywith.has(method)) {539 throw new FST_ERR_ROUTE_METHOD_NOT_SUPPORTED(method)540 }541 542 return method543}544 545function validateSchemaBodyOption (method, path, schema) {546 if (this[kSupportedHTTPMethods].bodyless.has(method) && schema?.body) {547 throw new FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED(method, path)548 }549}550 551function validateBodyLimitOption (bodyLimit) {552 if (bodyLimit === undefined) return553 if (!Number.isInteger(bodyLimit) || bodyLimit <= 0) {554 throw new FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT(bodyLimit)555 }556}557 558function runPreParsing (err, request, reply) {559 if (reply.sent === true) return560 if (err != null) {561 reply[kReplyIsError] = true562 reply.send(err)563 return564 }565 566 request[kRequestPayloadStream] = request.raw567 568 if (request[kRouteContext].preParsing !== null) {569 preParsingHookRunner(request[kRouteContext].preParsing, request, reply, handleRequest.bind(request.server))570 } else {571 handleRequest.call(request.server, null, request, reply)572 }573}574 575/**576 * Used within the route handler as a `net.Socket.close` event handler.577 * The purpose is to remove a socket from the tracked sockets collection when578 * the socket has naturally timed out.579 */580function removeTrackedSocket () {581 this.keepAliveConnections.delete(this.socket)582}583 584function noop () { }585 586module.exports = { buildRouting, validateBodyLimitOption }587 