UNPKG

@nestjs/platform-fastify

Version:

Nest - modern, fast, powerful node.js web framework (@platform-fastify)

630 lines (629 loc) 24.6 kB
import { HttpException, HttpStatus, Logger, StreamableFile, VERSION_NEUTRAL, VersioningType, } from '@nestjs/common'; import { fastify, } from 'fastify'; import Reply from 'fastify/lib/reply.js'; import fastifySymbols from 'fastify/lib/symbols.js'; import { pathToRegexp } from 'path-to-regexp'; import middie from '@fastify/middie'; import { loadPackage, isNil, isString, isUndefined, } from '@nestjs/common/internal'; import { AbstractHttpAdapter } from '@nestjs/core'; import { LegacyRouteConverter } from '@nestjs/core/internal'; const { kRouteContext } = fastifySymbols; // Fastify uses `fast-querystring` internally to quickly parse URL query strings. import { parse as querystringParse } from 'fast-querystring'; import urlSanitizer from 'find-my-way/lib/url-sanitizer.js'; import { FASTIFY_ROUTE_CONFIG_METADATA, FASTIFY_ROUTE_CONSTRAINTS_METADATA, FASTIFY_ROUTE_SCHEMA_METADATA, } from '../constants.js'; const { safeDecodeURI } = urlSanitizer; /** * @publicApi */ export class FastifyAdapter extends AbstractHttpAdapter { logger = new Logger(FastifyAdapter.name); _pathPrefix; _isParserRegistered; onRequestHook; onResponseHook; isMiddieRegistered; pendingMiddlewares = []; versioningOptions; versionConstraint = { name: 'version', validate(value) { if (!isString(value) && !Array.isArray(value)) { throw new Error('Version constraint should be a string or an array of strings.'); } }, storage() { const versions = new Map(); return { get(version) { if (Array.isArray(version)) { return versions.get(version.find(v => versions.has(v))) || null; } return versions.get(version) || null; }, set(versionOrVersions, store) { const storeVersionConstraint = (version) => versions.set(version, store); if (Array.isArray(versionOrVersions)) versionOrVersions.forEach(storeVersionConstraint); else storeVersionConstraint(versionOrVersions); }, del(version) { if (Array.isArray(version)) { version.forEach(v => versions.delete(v)); } else { versions.delete(version); } }, empty() { versions.clear(); }, }; }, deriveConstraint: (req) => { // Media Type (Accept Header) Versioning Handler if (this.versioningOptions?.type === VersioningType.MEDIA_TYPE) { const MEDIA_TYPE_HEADER = 'Accept'; const acceptHeaderValue = (req.headers?.[MEDIA_TYPE_HEADER] || req.headers?.[MEDIA_TYPE_HEADER.toLowerCase()]); const acceptHeaderVersionParameter = acceptHeaderValue ? acceptHeaderValue.split(';')[1] : undefined; return isUndefined(acceptHeaderVersionParameter) ? VERSION_NEUTRAL // No version was supplied : acceptHeaderVersionParameter.split(this.versioningOptions.key)[1]; } // Header Versioning Handler else if (this.versioningOptions?.type === VersioningType.HEADER) { const customHeaderVersionParameter = req.headers?.[this.versioningOptions.header] || req.headers?.[this.versioningOptions.header.toLowerCase()]; return isUndefined(customHeaderVersionParameter) ? VERSION_NEUTRAL // No version was supplied : customHeaderVersionParameter; } // Custom Versioning Handler else if (this.versioningOptions?.type === VersioningType.CUSTOM) { return this.versioningOptions.extractor(req); } return undefined; }, mustMatchWhenDerived: false, }; get isParserRegistered() { return !!this._isParserRegistered; } constructor(instanceOrOptions) { super(); const instance = instanceOrOptions && instanceOrOptions.server ? instanceOrOptions : fastify({ ...instanceOrOptions, routerOptions: { ...this.getTopLevelRouterOptions(instanceOrOptions), ...instanceOrOptions?.routerOptions, constraints: { version: this.versionConstraint, }, }, }); this.setInstance(instance); if (instanceOrOptions?.skipMiddie) { this.isMiddieRegistered = true; } this.instance.addHook('onRequest', (request, reply, done) => { if (this.onRequestHook) { this.onRequestHook(request, reply, done); } else { done(); } }); this.instance.addHook('onResponse', (request, reply, done) => { if (this.onResponseHook) { this.onResponseHook(request, reply, done); } else { done(); } }); } setOnRequestHook(hook) { this.onRequestHook = hook; } setOnResponseHook(hook) { this.onResponseHook = hook; } async init() { if (this.isMiddieRegistered) { return; } await this.registerMiddie(); // Register any pending middlewares that were added before init if (this.pendingMiddlewares.length > 0) { for (const { args } of this.pendingMiddlewares) { this.instance.use(...args); } this.pendingMiddlewares = []; } } listen(listenOptions, ...args) { const isFirstArgTypeofFunction = typeof args[0] === 'function'; const callback = isFirstArgTypeofFunction ? args[0] : args[1]; let options; if (typeof listenOptions === 'object' && (listenOptions.host !== undefined || listenOptions.port !== undefined || listenOptions.path !== undefined)) { // First parameter is an object with a path, port and/or host attributes options = listenOptions; } else { options = { port: +listenOptions, }; } if (!isFirstArgTypeofFunction) { options.host = args[0]; } return this.instance.listen(options, callback); } get(...args) { return this.injectRouteOptions('GET', ...args); } post(...args) { return this.injectRouteOptions('POST', ...args); } head(...args) { return this.injectRouteOptions('HEAD', ...args); } delete(...args) { return this.injectRouteOptions('DELETE', ...args); } put(...args) { return this.injectRouteOptions('PUT', ...args); } patch(...args) { return this.injectRouteOptions('PATCH', ...args); } options(...args) { return this.injectRouteOptions('OPTIONS', ...args); } search(...args) { return this.injectRouteOptions('SEARCH', ...args); } query(...args) { return this.injectRouteOptions('QUERY', ...args); } propfind(...args) { return this.injectRouteOptions('PROPFIND', ...args); } proppatch(...args) { return this.injectRouteOptions('PROPPATCH', ...args); } mkcol(...args) { return this.injectRouteOptions('MKCOL', ...args); } copy(...args) { return this.injectRouteOptions('COPY', ...args); } move(...args) { return this.injectRouteOptions('MOVE', ...args); } lock(...args) { return this.injectRouteOptions('LOCK', ...args); } unlock(...args) { return this.injectRouteOptions('UNLOCK', ...args); } applyVersionFilter(handler, version, versioningOptions) { if (!this.versioningOptions) { this.versioningOptions = versioningOptions; } const versionedRoute = handler; versionedRoute.version = version; return versionedRoute; } reply(response, body, statusCode) { const fastifyReply = this.isNativeResponse(response) ? new Reply(response, { [kRouteContext]: { preSerialization: null, preValidation: [], preHandler: [], onSend: [], onError: [], }, }, {}) : response; if (!isNil(statusCode)) { fastifyReply.status(statusCode); } if (body instanceof StreamableFile) { const streamHeaders = body.getHeaders(); if (fastifyReply.getHeader('Content-Type') === undefined && streamHeaders.type !== undefined) { fastifyReply.header('Content-Type', streamHeaders.type); } if (fastifyReply.getHeader('Content-Disposition') === undefined && streamHeaders.disposition !== undefined) { fastifyReply.header('Content-Disposition', streamHeaders.disposition); } if (fastifyReply.getHeader('Content-Length') === undefined && streamHeaders.length !== undefined) { fastifyReply.header('Content-Length', streamHeaders.length); } body = body.getStream(); } if (fastifyReply.getHeader('Content-Type') !== undefined && fastifyReply.getHeader('Content-Type') !== 'application/json' && body?.statusCode >= HttpStatus.BAD_REQUEST) { Logger.warn("Content-Type doesn't match Reply body, you might need a custom ExceptionFilter for non-JSON responses", FastifyAdapter.name); fastifyReply.header('Content-Type', 'application/json'); } return fastifyReply.send(body); } status(response, statusCode) { if (this.isNativeResponse(response)) { response.statusCode = statusCode; return response; } return response.code(statusCode); } end(response, message) { response.raw.end(message); } render(response, view, options) { return response && response.view(view, options); } redirect(response, statusCode, url) { const code = statusCode ?? HttpStatus.FOUND; return response.status(code).redirect(url); } setErrorHandler(handler) { return this.instance.setErrorHandler(handler); } setNotFoundHandler(handler) { return this.instance.setNotFoundHandler(handler); } getHttpServer() { return this.instance.server; } getInstance() { return this.instance; } register(plugin, opts) { return this.instance.register(plugin, opts); } inject(opts) { return this.instance.inject(opts); } async close() { try { return await this.instance.close(); } catch (err) { // Check if server is still running if (err.code !== 'ERR_SERVER_NOT_RUNNING') { throw err; } return; } } initHttpServer() { this.httpServer = this.instance.server; } async useStaticAssets(options) { return this.register(await loadPackage('@fastify/static', 'FastifyAdapter.useStaticAssets()', () => import('@fastify/static')), options); } async setViewEngine(options) { if (isString(options)) { new Logger('FastifyAdapter').error("setViewEngine() doesn't support a string argument."); process.exit(1); } return this.register(await loadPackage('@fastify/view', 'FastifyAdapter.setViewEngine()', () => import('@fastify/view')), options); } isHeadersSent(response) { return response.sent; } getHeader(response, name) { return response.getHeader(name); } setHeader(response, name, value) { return response.header(name, value); } appendHeader(response, name, value) { const current = response.getHeader(name); if (current === undefined) { return response.header(name, value); } // Fastify Reply.header() already concatenates set-cookie. // Re-passing the accumulated list would duplicate previous cookies. if (String(name).toLowerCase() === 'set-cookie') { return response.header(name, value); } const values = Array.isArray(current) ? current : [current]; return response.header(name, values.concat(value)); } getRequestHostname(request) { return request.hostname; } getRequestMethod(request) { return request.raw ? request.raw.method : request.method; } getRequestUrl(request) { return this.getRequestOriginalUrl(request.raw || request); } enableCors(options) { this.register(import('@fastify/cors'), options); } registerParserMiddleware(prefix, rawBody) { if (this._isParserRegistered) { return; } this.registerUrlencodedContentParser(rawBody); this.registerJsonContentParser(rawBody); this._isParserRegistered = true; this._pathPrefix = prefix ? !prefix.startsWith('/') ? `/${prefix}` : prefix : undefined; } useBodyParser(type, rawBody, options, parser) { const parserOptions = { ...(options || {}), parseAs: 'buffer', }; this.getInstance().addContentTypeParser(type, parserOptions, (req, body, done) => { if (rawBody === true && Buffer.isBuffer(body)) { req.rawBody = body; } if (parser) { parser(req, body, done); return; } done(null, body); }); // To avoid the Nest application init to override our custom // body parser, we mark the parsers as registered. this._isParserRegistered = true; } async createMiddlewareFactory(requestMethod) { if (!this.isMiddieRegistered) { await this.registerMiddie(); } return (path, callback) => { const hasEndOfStringCharacter = path.endsWith('$'); path = hasEndOfStringCharacter ? path.slice(0, -1) : path; let normalizedPath = LegacyRouteConverter.tryConvert(path); // Fallback to "*path" to support plugins like GraphQL normalizedPath = normalizedPath === '/*path' ? '*path' : normalizedPath; // Normalize the path to support the prefix if it set in application if (this._pathPrefix && !normalizedPath.startsWith(this._pathPrefix) && (normalizedPath === '/' || normalizedPath === '')) { normalizedPath = `${this._pathPrefix}${normalizedPath}`; if (normalizedPath.endsWith('/')) { normalizedPath = `${normalizedPath}{*path}`; } } try { let { regexp: re } = pathToRegexp(normalizedPath); re = hasEndOfStringCharacter ? new RegExp(re.source + '$', re.flags) : re; // The following type assertion is valid as we use import('@fastify/middie') rather than require('@fastify/middie') // ref https://github.com/fastify/middie/pull/55 this.instance.use(normalizedPath, (req, res, next) => { const queryParamsIndex = req.originalUrl.indexOf('?'); let pathname = queryParamsIndex >= 0 ? req.originalUrl.slice(0, queryParamsIndex) : req.originalUrl; pathname = this.sanitizeUrl(pathname); if (normalizedPath) { const pathToCheck = pathname.endsWith('/') ? pathname : `${pathname}/`; if (!re.exec(pathToCheck)) { return next(); } } return callback(req, res, next); }); } catch (e) { if (e instanceof TypeError) { LegacyRouteConverter.printError(path); } throw e; } }; } getType() { return 'fastify'; } isRouteOrderSensitive() { return false; } use(...args) { // Fastify requires @fastify/middie plugin to be registered before middleware can be used. // If middie is not registered yet, we queue the middleware and register it later during init. if (!this.isMiddieRegistered) { this.pendingMiddlewares.push({ args }); return this; } return this.instance.use(...args); } mapException(error) { if (this.isHttpFastifyError(error)) { return new HttpException(error.message, error.statusCode); } return error; } isHttpFastifyError(error) { // condition based on this code - https://github.com/fastify/fastify-error/blob/d669b150a82968322f9f7be992b2f6b463272de3/index.js#L22 return (error.statusCode !== undefined && error instanceof Error && error.name === 'FastifyError'); } registerWithPrefix(factory, prefix = '/') { return this.instance.register(factory, { prefix }); } isNativeResponse(response) { return !('status' in response); } registerJsonContentParser(rawBody) { const contentType = 'application/json'; const withRawBody = !!rawBody; const { bodyLimit } = this.getInstance().initialConfig; this.useBodyParser(contentType, withRawBody, { bodyLimit }, (req, body, done) => { const { onProtoPoisoning, onConstructorPoisoning } = this.instance.initialConfig; const defaultJsonParser = this.instance.getDefaultJsonParser(onProtoPoisoning || 'error', onConstructorPoisoning || 'error'); defaultJsonParser(req, body, done); }); } registerUrlencodedContentParser(rawBody) { const contentType = 'application/x-www-form-urlencoded'; const withRawBody = !!rawBody; const { bodyLimit } = this.getInstance().initialConfig; this.useBodyParser(contentType, withRawBody, { bodyLimit }, (_req, body, done) => { done(null, querystringParse(body.toString())); }); } async registerMiddie() { this.isMiddieRegistered = true; await this.register(middie); } getRequestOriginalUrl(rawRequest) { return rawRequest.originalUrl || rawRequest.url; } injectRouteOptions(routerMethodKey, ...args) { const handlerRef = args[args.length - 1]; const isVersioned = !isUndefined(handlerRef.version) && handlerRef.version !== VERSION_NEUTRAL; const routeConfig = Reflect.getMetadata(FASTIFY_ROUTE_CONFIG_METADATA, handlerRef); const routeConstraints = Reflect.getMetadata(FASTIFY_ROUTE_CONSTRAINTS_METADATA, handlerRef); const routeSchema = Reflect.getMetadata(FASTIFY_ROUTE_SCHEMA_METADATA, handlerRef); const hasConfig = !isUndefined(routeConfig); const hasConstraints = !isUndefined(routeConstraints); const hasSchema = !isUndefined(routeSchema); const routeToInject = { method: routerMethodKey, url: args[0], handler: handlerRef, }; if (!this.instance.supportedMethods.includes(routerMethodKey)) { this.instance.addHttpMethod(routerMethodKey, { hasBody: true }); } if (isVersioned || hasConstraints || hasConfig || hasSchema) { const isPathAndRouteTuple = args.length === 2; if (isPathAndRouteTuple) { const constraints = { ...(hasConstraints && routeConstraints), ...(isVersioned && { version: handlerRef.version, }), }; const options = { constraints, ...(hasConfig && { config: { ...routeConfig, }, }), ...(hasSchema && { schema: routeSchema, }), }; const routeToInjectWithOptions = { ...routeToInject, ...options }; return this.instance.route(routeToInjectWithOptions); } } return this.instance.route(routeToInject); } /** * Fastify still accepts the router options ("ignoreTrailingSlash", * "caseSensitive", ...) at the top level, but "initialConfig.routerOptions" * only reflects them when they are passed through "routerOptions". As the * adapter always passes "routerOptions" (for the version constraint), the * top-level values are folded in so that plugins relying on * "initialConfig.routerOptions" (like @fastify/middie) normalize request * paths exactly like the router does. */ getTopLevelRouterOptions(options) { const routerOptions = {}; const routerOptionKeys = [ 'ignoreTrailingSlash', 'ignoreDuplicateSlashes', 'caseSensitive', 'useSemicolonDelimiter', 'maxParamLength', 'allowUnsafeRegex', ]; for (const key of routerOptionKeys) { if (options?.[key] !== undefined) { routerOptions[key] = options[key]; } } return routerOptions; } sanitizeUrl(url) { const initialConfig = this.instance.initialConfig; const routerOptions = initialConfig.routerOptions; // Absolute-form request targets ("GET http://host/path HTTP/1.1") must be // resolved to their path before any other normalization, as the Fastify // router does, so that middleware and routes always match the same path. url = this.getPathFromRequestTarget(url); if (routerOptions.ignoreDuplicateSlashes || initialConfig.ignoreDuplicateSlashes) { url = this.removeDuplicateSlashes(url); } if (routerOptions.ignoreTrailingSlash || initialConfig.ignoreTrailingSlash) { url = this.trimLastSlash(url); } if (routerOptions.caseSensitive === false || initialConfig.caseSensitive === false) { url = url.toLowerCase(); } return safeDecodeURI(url, routerOptions.useSemicolonDelimiter || initialConfig.useSemicolonDelimiter).path; } removeDuplicateSlashes(path) { const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g; return path.indexOf('//') !== -1 ? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/') : path; } trimLastSlash(path) { if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) { return path.slice(0, -1); } return path; } /** * Mirrors the absolute-form request target handling of "find-my-way". * Returns the path of an absolute-form target ("http://host/path" -> "/path") * and leaves any other request target untouched. */ getPathFromRequestTarget(url) { if (url.charCodeAt(0) === 47 /* '/' */) { return url; } const schemeEnd = url.indexOf('://'); if (schemeEnd === -1) { return url; } const scheme = url.slice(0, schemeEnd).toLowerCase(); if (scheme !== 'http' && scheme !== 'https') { return url; } const authorityStart = schemeEnd + 3; const pathStart = url.indexOf('/', authorityStart); if (pathStart === authorityStart || !URL.canParse(url)) { // Malformed target: the router rejects it before any middleware runs return url; } return pathStart === -1 ? '/' : url.slice(pathStart); } }