UNPKG

fastify

Version:

Fast and low overhead web framework, for Node.js

629 lines (547 loc) 21.4 kB
'use strict' const FindMyWay = require('find-my-way') const Context = require('./context') const handleRequest = require('./handleRequest') const { onRequestAbortHookRunner, lifecycleHooks, preParsingHookRunner, onTimeoutHookRunner, onRequestHookRunner } = require('./hooks') const { supportedMethods } = require('./httpMethods') const { normalizeSchema } = require('./schemas') const { parseHeadOnSendHandlers } = require('./headRoute') const { FSTDEP007, FSTDEP008, FSTDEP014 } = require('./warnings') const { compileSchemasForValidation, compileSchemasForSerialization } = require('./validation') const { FST_ERR_SCH_VALIDATION_BUILD, FST_ERR_SCH_SERIALIZATION_BUILD, FST_ERR_DEFAULT_ROUTE_INVALID_TYPE, FST_ERR_DUPLICATED_ROUTE, FST_ERR_INVALID_URL, FST_ERR_HOOK_INVALID_HANDLER, FST_ERR_ROUTE_OPTIONS_NOT_OBJ, FST_ERR_ROUTE_DUPLICATED_HANDLER, FST_ERR_ROUTE_HANDLER_NOT_FN, FST_ERR_ROUTE_MISSING_HANDLER, FST_ERR_ROUTE_METHOD_NOT_SUPPORTED, FST_ERR_ROUTE_METHOD_INVALID, FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED, FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT, FST_ERR_HOOK_INVALID_ASYNC_HANDLER } = require('./errors') const { kRoutePrefix, kLogLevel, kLogSerializers, kHooks, kSchemaController, kOptions, kReplySerializerDefault, kReplyIsError, kRequestPayloadStream, kDisableRequestLogging, kSchemaErrorFormatter, kErrorHandler, kHasBeenDecorated, kRequestAcceptVersion, kRouteByFastify, kRouteContext } = require('./symbols.js') const { buildErrorHandler } = require('./error-handler') const { createChildLogger } = require('./logger') const { getGenReqId } = require('./reqIdGenFactory.js') function buildRouting (options) { const router = FindMyWay(options.config) let avvio let fourOhFour let logger let hasLogger let setupResponseListeners let throwIfAlreadyStarted let disableRequestLogging let ignoreTrailingSlash let ignoreDuplicateSlashes let return503OnClosing let globalExposeHeadRoutes let validateHTTPVersion let keepAliveConnections let closing = false return { /** * @param {import('../fastify').FastifyServerOptions} options * @param {*} fastifyArgs */ setup (options, fastifyArgs) { avvio = fastifyArgs.avvio fourOhFour = fastifyArgs.fourOhFour logger = fastifyArgs.logger hasLogger = fastifyArgs.hasLogger setupResponseListeners = fastifyArgs.setupResponseListeners throwIfAlreadyStarted = fastifyArgs.throwIfAlreadyStarted validateHTTPVersion = fastifyArgs.validateHTTPVersion globalExposeHeadRoutes = options.exposeHeadRoutes disableRequestLogging = options.disableRequestLogging ignoreTrailingSlash = options.ignoreTrailingSlash ignoreDuplicateSlashes = options.ignoreDuplicateSlashes return503OnClosing = Object.prototype.hasOwnProperty.call(options, 'return503OnClosing') ? options.return503OnClosing : true keepAliveConnections = fastifyArgs.keepAliveConnections }, routing: router.lookup.bind(router), // router func to find the right handler to call route, // configure a route in the fastify instance hasRoute, prepareRoute, getDefaultRoute: function () { FSTDEP014() return router.defaultRoute }, setDefaultRoute: function (defaultRoute) { FSTDEP014() if (typeof defaultRoute !== 'function') { throw new FST_ERR_DEFAULT_ROUTE_INVALID_TYPE() } router.defaultRoute = defaultRoute }, routeHandler, closeRoutes: () => { closing = true }, printRoutes: router.prettyPrint.bind(router), addConstraintStrategy, hasConstraintStrategy, isAsyncConstraint, findRoute } function addConstraintStrategy (strategy) { throwIfAlreadyStarted('Cannot add constraint strategy!') return router.addConstraintStrategy(strategy) } function hasConstraintStrategy (strategyName) { return router.hasConstraintStrategy(strategyName) } function isAsyncConstraint () { return router.constrainer.asyncStrategiesInUse.size > 0 } // Convert shorthand to extended route declaration function prepareRoute ({ method, url, options, handler, isFastify }) { 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 FST_ERR_ROUTE_OPTIONS_NOT_OBJ(method, url) } else if (options.handler) { if (typeof options.handler === 'function') { throw new FST_ERR_ROUTE_DUPLICATED_HANDLER(method, url) } else { throw new FST_ERR_ROUTE_HANDLER_NOT_FN(method, url) } } } options = Object.assign({}, options, { method, url, path: url, handler: handler || (options && options.handler) }) return route.call(this, { options, isFastify }) } function hasRoute ({ options }) { const normalizedMethod = options.method?.toUpperCase() ?? '' return findRoute({ ...options, method: normalizedMethod }) !== null } function findRoute (options) { const route = router.find( options.method, options.url || '', options.constraints ) if (route) { // we must reduce the expose surface, otherwise // we provide the ability for the user to modify // all the route and server information in runtime return { handler: route.handler, params: route.params, searchParams: route.searchParams } } else { return null } } /** * Route management * @param {{ options: import('../fastify').RouteOptions, isFastify: boolean }} */ function route ({ options, isFastify }) { // Since we are mutating/assigning only top level props, it is fine to have a shallow copy using the spread operator const opts = { ...options } const { exposeHeadRoute } = opts const hasRouteExposeHeadRouteFlag = exposeHeadRoute != null const shouldExposeHead = hasRouteExposeHeadRouteFlag ? exposeHeadRoute : globalExposeHeadRoutes const isGetRoute = opts.method === 'GET' || (Array.isArray(opts.method) && opts.method.includes('GET')) const isHeadRoute = opts.method === 'HEAD' || (Array.isArray(opts.method) && opts.method.includes('HEAD')) // we need to clone a set of initial options for HEAD route const headOpts = shouldExposeHead && isGetRoute ? { ...options } : null throwIfAlreadyStarted('Cannot add route!') const path = opts.url || opts.path || '' if (Array.isArray(opts.method)) { // eslint-disable-next-line no-var for (var i = 0; i < opts.method.length; ++i) { opts.method[i] = normalizeAndValidateMethod(opts.method[i]) validateSchemaBodyOption(opts.method[i], path, opts.schema) } } else { opts.method = normalizeAndValidateMethod(opts.method) validateSchemaBodyOption(opts.method, path, opts.schema) } if (!opts.handler) { throw new FST_ERR_ROUTE_MISSING_HANDLER(opts.method, path) } if (opts.errorHandler !== undefined && typeof opts.errorHandler !== 'function') { throw new FST_ERR_ROUTE_HANDLER_NOT_FN(opts.method, path) } validateBodyLimitOption(opts.bodyLimit) const prefix = this[kRoutePrefix] if (path === '/' && prefix.length > 0 && opts.method !== 'HEAD') { switch (opts.prefixTrailingSlash) { case 'slash': addNewRoute.call(this, { path, isFastify }) break case 'no-slash': addNewRoute.call(this, { path: '', isFastify }) break case 'both': default: addNewRoute.call(this, { path: '', isFastify }) // If ignoreTrailingSlash is set to true we need to add only the '' route to prevent adding an incomplete one. if (ignoreTrailingSlash !== true && (ignoreDuplicateSlashes !== true || !prefix.endsWith('/'))) { addNewRoute.call(this, { path, prefixing: true, isFastify }) } } } else if (path[0] === '/' && prefix.endsWith('/')) { // Ensure that '/prefix/' + '/route' gets registered as '/prefix/route' addNewRoute.call(this, { path: path.slice(1), isFastify }) } else { addNewRoute.call(this, { path, isFastify }) } // chainable api return this function addNewRoute ({ path, prefixing = false, isFastify = false }) { 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) { hook.call(this, opts) } } for (const hook of lifecycleHooks) { if (opts && hook in opts) { if (Array.isArray(opts[hook])) { for (const func of opts[hook]) { if (typeof func !== 'function') { throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(func)) } if (hook === 'onSend' || hook === 'preSerialization' || hook === 'onError' || hook === 'preParsing') { if (func.constructor.name === 'AsyncFunction' && func.length === 4) { throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER() } } else if (hook === 'onRequestAbort') { if (func.constructor.name === 'AsyncFunction' && func.length !== 1) { throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER() } } else { if (func.constructor.name === 'AsyncFunction' && func.length === 3) { throw new FST_ERR_HOOK_INVALID_ASYNC_HANDLER() } } } } else if (opts[hook] !== undefined && typeof opts[hook] !== 'function') { throw new FST_ERR_HOOK_INVALID_HANDLER(hook, Object.prototype.toString.call(opts[hook])) } } } const constraints = opts.constraints || {} const config = { ...opts.config, url, method: opts.method } const context = new Context({ schema: opts.schema, handler: opts.handler.bind(this), config, errorHandler: opts.errorHandler, childLoggerFactory: opts.childLoggerFactory, bodyLimit: opts.bodyLimit, logLevel: opts.logLevel, logSerializers: opts.logSerializers, attachValidation: opts.attachValidation, schemaErrorFormatter: opts.schemaErrorFormatter, replySerializer: this[kReplySerializerDefault], validatorCompiler: opts.validatorCompiler, serializerCompiler: opts.serializerCompiler, exposeHeadRoute: shouldExposeHead, prefixTrailingSlash: (opts.prefixTrailingSlash || 'both'), server: this, isFastify }) if (opts.version) { FSTDEP008() constraints.version = opts.version } const headHandler = router.findRoute('HEAD', opts.url, constraints) const hasHEADHandler = headHandler !== null // remove the head route created by fastify if (isHeadRoute && hasHEADHandler && !context[kRouteByFastify] && headHandler.store[kRouteByFastify]) { router.off('HEAD', opts.url, constraints) } try { router.on(opts.method, opts.url, { constraints }, routeHandler, context) } catch (error) { // any route insertion error created by fastify can be safely ignore // because it only duplicate route for head if (!context[kRouteByFastify]) { const isDuplicatedRoute = error.message.includes(`Method '${opts.method}' already declared for route '${opts.url}'`) if (isDuplicatedRoute) { throw new FST_ERR_DUPLICATED_ROUTE(opts.method, opts.url) } throw error } } this.after((notHandledErr, done) => { // Send context async context.errorHandler = opts.errorHandler ? buildErrorHandler(this[kErrorHandler], opts.errorHandler) : this[kErrorHandler] context._parserOptions.limit = opts.bodyLimit || null context.logLevel = opts.logLevel context.logSerializers = opts.logSerializers context.attachValidation = opts.attachValidation context[kReplySerializerDefault] = this[kReplySerializerDefault] context.schemaErrorFormatter = opts.schemaErrorFormatter || this[kSchemaErrorFormatter] || context.schemaErrorFormatter // Run hooks and more avvio.once('preReady', () => { for (const hook of lifecycleHooks) { const toSet = this[kHooks][hook] .concat(opts[hook] || []) .map(h => h.bind(this)) context[hook] = toSet.length ? toSet : null } // Optimization: avoid encapsulation if no decoration has been done. while (!context.Request[kHasBeenDecorated] && context.Request.parent) { context.Request = context.Request.parent } while (!context.Reply[kHasBeenDecorated] && context.Reply.parent) { context.Reply = context.Reply.parent } // 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 { const isCustom = typeof opts?.validatorCompiler === 'function' || schemaController.isCustomValidatorCompiler compileSchemasForValidation(context, opts.validatorCompiler || schemaController.validatorCompiler, isCustom) } 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) }) // register head route in sync // we must place it after the `this.after` if (shouldExposeHead && isGetRoute && !isHeadRoute && !hasHEADHandler) { const onSendHandlers = parseHeadOnSendHandlers(headOpts.onSend) prepareRoute.call(this, { method: 'HEAD', url: path, options: { ...headOpts, onSend: onSendHandlers }, isFastify: true }) } else if (hasHEADHandler && exposeHeadRoute) { FSTDEP007() } } } // HTTP request entry point, the routing has already been executed function routeHandler (req, res, params, context, query) { const id = getGenReqId(context.server, req) const loggerOpts = { level: context.logLevel } if (context.logSerializers) { loggerOpts.serializers = context.logSerializers } const childLogger = createChildLogger(context, logger, req, id, loggerOpts) childLogger[kDisableRequestLogging] = disableRequestLogging // TODO: The check here should be removed once https://github.com/nodejs/node/issues/43115 resolve in core. if (!validateHTTPVersion(req.httpVersion)) { childLogger.info({ res: { statusCode: 505 } }, 'request aborted - invalid HTTP version') const message = '{"error":"HTTP Version Not Supported","message":"HTTP Version Not Supported","statusCode":505}' const headers = { 'Content-Type': 'application/json', 'Content-Length': message.length } res.writeHead(505, headers) res.end(message) return } if (closing === true) { /* istanbul ignore next mac, windows */ if (req.httpVersionMajor !== 2) { res.setHeader('Connection', 'close') } // TODO remove return503OnClosing after Node v18 goes EOL /* istanbul ignore else */ if (return503OnClosing) { // On Node v19 we cannot test this behavior as it won't be necessary // anymore. It will close all the idle connections before they reach this // stage. const headers = { 'Content-Type': 'application/json', 'Content-Length': '80' } res.writeHead(503, headers) res.end('{"error":"Service Unavailable","message":"Service Unavailable","statusCode":503}') childLogger.info({ res: { statusCode: 503 } }, 'request aborted - refusing to accept new requests as server is closing') 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 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) { onRequestHookRunner( context.onRequest, request, reply, runPreParsing ) } else { runPreParsing(null, request, reply) } if (context.onRequestAbort !== null) { req.on('close', () => { /* istanbul ignore else */ if (req.aborted) { onRequestAbortHookRunner( context.onRequestAbort, request, handleOnRequestAbortHooksErrors.bind(null, reply) ) } }) } if (context.onTimeout !== null) { if (!request.raw.socket._meta) { request.raw.socket.on('timeout', handleTimeout) } request.raw.socket._meta = { context, request, reply } } } } function handleOnRequestAbortHooksErrors (reply, err) { if (err) { reply.log.error({ err }, 'onRequestAborted hook failed') } } function handleTimeout () { const { context, request, reply } = this._meta onTimeoutHookRunner( context.onTimeout, request, reply, noop ) } function normalizeAndValidateMethod (method) { if (typeof method !== 'string') { throw new FST_ERR_ROUTE_METHOD_INVALID() } method = method.toUpperCase() if (supportedMethods.indexOf(method) === -1) { throw new FST_ERR_ROUTE_METHOD_NOT_SUPPORTED(method) } return method } function validateSchemaBodyOption (method, path, schema) { if ((method === 'GET' || method === 'HEAD') && schema && schema.body) { throw new FST_ERR_ROUTE_BODY_VALIDATION_SCHEMA_NOT_SUPPORTED(method, path) } } function validateBodyLimitOption (bodyLimit) { if (bodyLimit === undefined) return if (!Number.isInteger(bodyLimit) || bodyLimit <= 0) { throw new FST_ERR_ROUTE_BODY_LIMIT_OPTION_NOT_INT(bodyLimit) } } function runPreParsing (err, request, reply) { if (reply.sent === true) return if (err != null) { reply[kReplyIsError] = true reply.send(err) return } request[kRequestPayloadStream] = request.raw if (request[kRouteContext].preParsing !== null) { preParsingHookRunner(request[kRouteContext].preParsing, request, reply, handleRequest) } else { handleRequest(null, request, reply) } } /** * 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 }