UNPKG

serverless-offline-python

Version:

Emulate AWS λ and API Gateway locally when developing your Serverless project

1,048 lines (868 loc) 39.7 kB
'use strict'; // Node dependencies const fs = require('fs'); const path = require('path'); const exec = require('child_process').exec; // External dependencies const Hapi = require('hapi'); const corsHeaders = require('hapi-cors-headers'); const _ = require('lodash'); const crypto = require('crypto'); // Internal lib require('./javaHelper'); const debugLog = require('./debugLog'); const jsonPath = require('./jsonPath'); const createLambdaContext = require('./createLambdaContext'); const createVelocityContext = require('./createVelocityContext'); const createLambdaProxyContext = require('./createLambdaProxyContext'); const renderVelocityTemplateObject = require('./renderVelocityTemplateObject'); const createAuthScheme = require('./createAuthScheme'); const functionHelper = require('./functionHelper'); const Endpoint = require('./Endpoint'); const parseResources = require('./parseResources'); const utils = require('./utils'); /* I'm against monolithic code like this file, but splitting it induces unneeded complexity. */ class Offline { constructor(serverless, options) { this.serverless = serverless; this.service = serverless.service; this.serverlessLog = serverless.cli.log.bind(serverless.cli); this.options = options; this.exitCode = 0; this.provider = 'aws'; this.start = this.start.bind(this); this.commands = { offline: { usage: 'Simulates API Gateway to call your lambda functions offline.', lifecycleEvents: ['start'], // add start nested options commands: { start: { usage: 'Simulates API Gateway to call your lambda functions offline using backward compatible initialization.', lifecycleEvents: [ 'init', 'end', ], }, }, options: { prefix: { usage: 'Adds a prefix to every path, to send your requests to http://localhost:3000/prefix/[your_path] instead.', shortcut: 'p', }, host: { usage: 'The host name to listen on. Default: localhost', shortcut: 'o', }, port: { usage: 'Port to listen on. Default: 3000', shortcut: 'P', }, stage: { usage: 'The stage used to populate your templates.', shortcut: 's', }, region: { usage: 'The region used to populate your templates.', shortcut: 'r', }, skipCacheInvalidation: { usage: 'Tells the plugin to skip require cache invalidation. A script reloading tool like Nodemon might then be needed', shortcut: 'c', }, httpsProtocol: { usage: 'To enable HTTPS, specify directory (relative to your cwd, typically your project dir) for both cert.pem and key.pem files.', shortcut: 'H', }, location: { usage: 'The root location of the handlers\' files.', shortcut: 'l', }, noTimeout: { usage: 'Disable the timeout feature.', shortcut: 't', }, noEnvironment: { usage: 'Turns off loading of your environment variables from serverless.yml. Allows the usage of tools such as PM2 or docker-compose.', }, resourceRoutes: { usage: 'Turns on loading of your HTTP proxy settings from serverless.yml.', }, dontPrintOutput: { usage: 'Turns off logging of your lambda outputs in the terminal.', }, corsAllowOrigin: { usage: 'Used to build the Access-Control-Allow-Origin header for CORS support.', }, corsAllowHeaders: { usage: 'Used to build the Access-Control-Allow-Headers header for CORS support.', }, corsDisallowCredentials: { usage: 'Used to override the Access-Control-Allow-Credentials default (which is true) to false.', }, apiKey: { usage: 'Defines the api key value to be used for endpoints marked as private. Defaults to a random hash.', }, exec: { usage: 'When provided, a shell script is executed when the server starts up, and the server will shut down after handling this command.', }, noAuth: { usage: 'Turns off all authorizers', }, useSeparateProcesses: { usage: 'Uses separate node processes for handlers', }, preserveTrailingSlash: { usage: 'Used to keep trailing slashes on the request path' } }, }, }; this.hooks = { 'offline:start:init': this.start.bind(this), 'offline:start': this.start.bind(this), 'offline:start:end': this.end.bind(this), }; } printBlankLine() { console.log(); } logPluginIssue() { this.serverlessLog('If you think this is an issue with the plugin please submit it, thanks!'); this.serverlessLog('https://github.com/dherault/serverless-offline/issues'); } // Entry point for the plugin (sls offline) start() { this._checkVersion(); // Some users would like to know their environment outside of the handler process.env.IS_OFFLINE = true; return Promise.resolve(this._buildServer()) .then(() => this._listen()) .then(() => this.options.exec ? this._executeShellScript() : this._listenForSigInt()) .then(() => this.end()); } _checkVersion() { const version = this.serverless.version; if (!version.startsWith('1.')) { this.serverlessLog(`Offline requires Serverless v1.x.x but found ${version}. Exiting.`); process.exit(0); } } _listenForSigInt() { // Listen for ctrl+c to stop the server return new Promise(resolve => { process.on('SIGINT', () => { this.serverlessLog('Offline Halting...'); resolve(); }); }); } _executeShellScript() { const command = this.options.exec; this.serverlessLog(`Offline executing script [${command}]`); return new Promise(resolve => { exec(command, (error, stdout, stderr) => { this.serverlessLog(`exec stdout: [${stdout}]`); this.serverlessLog(`exec stderr: [${stderr}]`); if (error) { // Use the failed command's exit code, proceed as normal so that shutdown can occur gracefully this.serverlessLog(`Offline error executing script [${error}]`); this.exitCode = error.code || 1; } resolve(); }); }); } _buildServer() { // Maps a request id to the request's state (done: bool, timeout: timer) this.requests = {}; // Methods this._setOptions(); // Will create meaningful options from cli options this._storeOriginalEnvironment(); // stores the original process.env for assigning upon invoking the handlers this._registerBabel(); // Support for ES6 this._createServer(); // Hapijs boot this._createRoutes(); // API Gateway emulation this._createResourceRoutes(); // HTTP Proxy defined in Resource this._create404Route(); // Not found handling return this.server; } _storeOriginalEnvironment() { this.originalEnvironment = _.extend({}, process.env); } _setOptions() { // Merge the different sources of values for this.options // Precedence is: command line options, YAML options, defaults. const defaultOpts = { host: 'localhost', location: '.', port: 3000, prefix: '/', stage: this.service.provider.stage, region: this.service.provider.region, noTimeout: false, noEnvironment: false, resourceRoutes: false, dontPrintOutput: false, httpsProtocol: '', skipCacheInvalidation: false, noAuth: false, corsAllowOrigin: '*', corsAllowHeaders: 'accept,content-type,x-api-key,authorization', corsAllowCredentials: true, apiKey: crypto.createHash('md5').digest('hex'), useSeparateProcesses: false, preserveTrailingSlash: false }; this.options = _.merge({}, defaultOpts, (this.service.custom || {})['serverless-offline'], this.options); // Prefix must start and end with '/' if (!this.options.prefix.startsWith('/')) this.options.prefix = `/${this.options.prefix}`; if (!this.options.prefix.endsWith('/')) this.options.prefix += '/'; this.globalBabelOptions = ((this.service.custom || {})['serverless-offline'] || {}).babelOptions; this.velocityContextOptions = { stageVariables: {}, // this.service.environment.stages[this.options.stage].vars, stage: this.options.stage, }; // Parse CORS options this.options.corsAllowOrigin = this.options.corsAllowOrigin.replace(/\s/g, '').split(','); this.options.corsAllowHeaders = this.options.corsAllowHeaders.replace(/\s/g, '').split(','); if (this.options.corsDisallowCredentials) this.options.corsAllowCredentials = false; this.options.corsConfig = { origin: this.options.corsAllowOrigin, headers: this.options.corsAllowHeaders, credentials: this.options.corsAllowCredentials, }; this.serverlessLog(`Starting Offline: ${this.options.stage}/${this.options.region}.`); debugLog('options:', this.options); debugLog('globalBabelOptions:', this.globalBabelOptions); } _registerBabel(isBabelRuntime, babelRuntimeOptions) { const options = isBabelRuntime ? babelRuntimeOptions || { presets: ['es2015'] } : this.globalBabelOptions; if (options) { debugLog('Setting babel register:', options); // We invoke babel-register only once if (!this.babelRegister) { debugLog('For the first time'); this.babelRegister = require('babel-register')(options); } } } _createServer() { // Hapijs server creation this.server = new Hapi.Server({ connections: { router: { stripTrailingSlash: !this.options.preserveTrailingSlash, // removes trailing slashes on incoming paths. }, }, }); this.server.register(require('h2o2'), err => err && this.serverlessLog(err)); const connectionOptions = { host: this.options.host, port: this.options.port, }; const httpsDir = this.options.httpsProtocol; // HTTPS support if (typeof httpsDir === 'string' && httpsDir.length > 0) { connectionOptions.tls = { key: fs.readFileSync(path.resolve(httpsDir, 'key.pem'), 'ascii'), cert: fs.readFileSync(path.resolve(httpsDir, 'cert.pem'), 'ascii'), }; } // Passes the configuration object to the server this.server.connection(connectionOptions); // Enable CORS preflight response this.server.ext('onPreResponse', corsHeaders); } _createRoutes() { const defaultContentType = 'application/json'; const serviceRuntime = this.service.provider.runtime; const apiKeys = this.service.provider.apiKeys; const protectedRoutes = []; if (utils.supportedRuntimes.indexOf(serviceRuntime) === -1) { this.printBlankLine(); this.serverlessLog(`Warning: found unsupported runtime '${serviceRuntime}'`); this.serverlessLog(`Supported runtimes: ${utils.supportedRuntimes}`); return; } // for simple API Key authentication model if (!_.isEmpty(apiKeys)) { this.serverlessLog(`Key with token: ${this.options.apiKey}`); this.serverlessLog('Remember to use x-api-key on the request headers'); } Object.keys(this.service.functions).forEach(key => { const fun = this.service.getFunction(key); const funName = key; const servicePath = path.join(this.serverless.config.servicePath, this.options.location); const funOptions = functionHelper.getFunctionOptions(fun, key, servicePath,serviceRuntime); debugLog(`funOptions ${JSON.stringify(funOptions, null, 2)} `); this.printBlankLine(); debugLog(funName, 'runtime', serviceRuntime, funOptions.babelOptions || ''); this.serverlessLog(`Routes for ${funName}:`); // Adds a route for each http endpoint (fun.events && fun.events.length || this.serverlessLog('(none)')) && fun.events.forEach(event => { if (!event.http) return this.serverlessLog('(none)'); // Handle Simple http setup, ex. - http: GET users/index if (typeof event.http === 'string') { const split = event.http.split(' '); event.http = { path: split[1], method: split[0], }; } if (_.eq(event.http.private, true)) { protectedRoutes.push(`${event.http.method.toUpperCase()}#/${event.http.path}`); } // generate an enpoint via the endpoint class const endpoint = new Endpoint(event.http, funOptions).generate(); let firstCall = true; const integration = endpoint.integration || 'lambda-proxy'; const epath = endpoint.path; const method = endpoint.method.toUpperCase(); const requestTemplates = endpoint.requestTemplates; // Prefix must start and end with '/' BUT path must not end with '/' let fullPath = this.options.prefix + (epath.startsWith('/') ? epath.slice(1) : epath); if (fullPath !== '/' && fullPath.endsWith('/')) fullPath = fullPath.slice(0, -1); fullPath = fullPath.replace(/\+}/g, '*}'); this.serverlessLog(`${method} ${fullPath}`); // If the endpoint has an authorization function, create an authStrategy for the route const authStrategyName = this.options.noAuth ? null : this._configureAuthorization(endpoint, funName, method, epath, servicePath, serviceRuntime); let cors = null; if (endpoint.cors) { cors = { origin: endpoint.cors.origins || this.options.corsConfig.origin, headers: endpoint.cors.headers || this.options.corsConfig.headers, credentials: endpoint.cors.credentials || this.options.corsConfig.credentials, }; } // Route creation const routeMethod = method === 'ANY' ? '*' : method; const routeConfig = { cors, auth: authStrategyName, timeout: { socket: false }, }; if (routeMethod !== 'HEAD' && routeMethod !== 'GET') { // maxBytes: Increase request size from 1MB default limit to 10MB. // Cf AWS API GW payload limits. routeConfig.payload = { parse: false, maxBytes: 1024 * 1024 * 10 }; } this.server.route({ method: routeMethod, path: fullPath, config: routeConfig, handler: (request, reply) => { // Here we go // Payload processing const encoding = utils.detectEncoding(request); request.payload = request.payload && request.payload.toString(encoding); request.rawPayload = request.payload; // Headers processing // Hapi lowercases the headers whereas AWS does not // so we recreate a custom headers object from the raw request const headersArray = request.raw.req.rawHeaders; // During tests, `server.inject` uses *shot*, a package // for performing injections that does not entirely mimick // Hapi's usual request object. rawHeaders are then missing // Hence the fallback for testing // Normal usage if (headersArray) { const unprocessedHeaders = {}; for (let i = 0; i < headersArray.length; i += 2) { unprocessedHeaders[headersArray[i]] = headersArray[i + 1]; } request.unprocessedHeaders = unprocessedHeaders; } // Lib testing else { request.unprocessedHeaders = request.headers; // console.log('request.unprocessedHeaders:', request.unprocessedHeaders); } // Incomming request message this.printBlankLine(); this.serverlessLog(`${method} ${request.path} (λ: ${funName})`); if (firstCall) { this.serverlessLog('The first request might take a few extra seconds'); firstCall = false; } // this.serverlessLog(protectedRoutes); // Check for APIKey if (_.includes(protectedRoutes, `${routeMethod}#${fullPath}`) || _.includes(protectedRoutes, `ANY#${fullPath}`)) { const errorResponse = response => response({ message: 'Forbidden' }).code(403).type('application/json').header('x-amzn-ErrorType', 'ForbiddenException'); if ('x-api-key' in request.headers) { const requestToken = request.headers['x-api-key']; if (requestToken !== this.options.apiKey) { debugLog(`Method ${method} of function ${funName} token ${requestToken} not valid`); return errorResponse(reply); } } else { debugLog(`Missing x-api-key on private function ${funName}`); return errorResponse(reply); } } // Shared mutable state is the root of all evil they say const requestId = Math.random().toString().slice(2); this.requests[requestId] = { done: false }; this.currentRequestId = requestId; // Holds the response to do async op const response = reply.response().hold(); const contentType = request.mime || defaultContentType; // default request template to '' if we don't have a definition pushed in from serverless or endpoint const requestTemplate = typeof requestTemplates !== 'undefined' && integration === 'lambda' ? requestTemplates[contentType] : ''; // https://hapijs.com/api#route-configuration doesn't seem to support selectively parsing // so we have to do it ourselves const contentTypesThatRequirePayloadParsing = ['application/json', 'application/vnd.api+json']; if (contentTypesThatRequirePayloadParsing.indexOf(contentType) !== -1) { try { request.payload = JSON.parse(request.payload); } catch (err) { debugLog('error in converting request.payload to JSON:', err); } } debugLog('requestId:', requestId); debugLog('contentType:', contentType); debugLog('requestTemplate:', requestTemplate); debugLog('payload:', request.payload); /* HANDLER LAZY LOADING */ let handler; // The lambda function try { if (this.options.noEnvironment) { // This evict errors in server when we use aws services like ssm const baseEnvironment = { AWS_ACCESS_KEY_ID: 'dev', AWS_SECRET_ACCESS_KEY: 'dev', AWS_REGION: 'dev' } process.env = _.extend({}, baseEnvironment); } else { Object.assign(process.env, this.service.provider.environment, this.service.functions[key].environment); } Object.assign(process.env, this.originalEnvironment); process.env._HANDLER = fun.handler; handler = functionHelper.createHandler(funOptions, this.options); } catch (err) { return this._reply500(response, `Error while loading ${funName}`, err, requestId); } /* REQUEST TEMPLATE PROCESSING (event population) */ let event = {}; if (integration === 'lambda') { if (requestTemplate) { try { debugLog('_____ REQUEST TEMPLATE PROCESSING _____'); // Velocity templating language parsing const velocityContext = createVelocityContext(request, this.velocityContextOptions, request.payload || {}); event = renderVelocityTemplateObject(requestTemplate, velocityContext); } catch (err) { return this._reply500(response, `Error while parsing template "${contentType}" for ${funName}`, err, requestId); } } else if (typeof request.payload === 'object') { event = request.payload || {}; } } else if (integration === 'lambda-proxy') { event = createLambdaProxyContext(request, this.options, this.velocityContextOptions.stageVariables); } event.isOffline = true; if (this.serverless.service.custom && this.serverless.service.custom.stageVariables) { event.stageVariables = this.serverless.service.custom.stageVariables; } else if (integration !== 'lambda-proxy') { event.stageVariables = {}; } debugLog('event:', event); // We create the context, its callback (context.done/succeed/fail) will send the HTTP response const lambdaContext = createLambdaContext(fun, (err, data) => { // Everything in this block happens once the lambda function has resolved debugLog('_____ HANDLER RESOLVED _____'); // Timeout clearing if needed if (this._clearTimeout(requestId)) return; // User should not call context.done twice if (this.requests[requestId].done) { this.printBlankLine(); this.serverlessLog(`Warning: context.done called twice within handler '${funName}'!`); debugLog('requestId:', requestId); return; } this.requests[requestId].done = true; let result = data; let responseName = 'default'; const responseContentType = endpoint.responseContentType; const contentHandling = endpoint.contentHandling; /* RESPONSE SELECTION (among endpoint's possible responses) */ // Failure handling let errorStatusCode = 0; if (err) { const errorMessage = (err.message || err).toString(); const re = /\[(\d{3})]/; const found = errorMessage.match(re); if (found && found.length > 1) { errorStatusCode = found[1]; } else { errorStatusCode = '500'; } // Mocks Lambda errors result = { errorMessage, errorType: err.constructor.name, stackTrace: this._getArrayStackTrace(err.stack), }; this.serverlessLog(`Failure: ${errorMessage}`); if (result.stackTrace) { debugLog(result.stackTrace.join('\n ')); } for (const key in endpoint.responses) { if (key !== 'default' && errorMessage.match(`^${endpoint.responses[key].selectionPattern || key}$`)) { responseName = key; break; } } } debugLog(`Using response '${responseName}'`); const chosenResponse = endpoint.responses[responseName]; /* RESPONSE PARAMETERS PROCCESSING */ const responseParameters = chosenResponse.responseParameters; if (_.isPlainObject(responseParameters)) { const responseParametersKeys = Object.keys(responseParameters); debugLog('_____ RESPONSE PARAMETERS PROCCESSING _____'); debugLog(`Found ${responseParametersKeys.length} responseParameters for '${responseName}' response`); responseParametersKeys.forEach(key => { // responseParameters use the following shape: "key": "value" const value = responseParameters[key]; const keyArray = key.split('.'); // eg: "method.response.header.location" const valueArray = value.split('.'); // eg: "integration.response.body.redirect.url" debugLog(`Processing responseParameter "${key}": "${value}"`); // For now the plugin only supports modifying headers if (key.startsWith('method.response.header') && keyArray[3]) { const headerName = keyArray.slice(3).join('.'); let headerValue; debugLog('Found header in left-hand:', headerName); if (value.startsWith('integration.response')) { if (valueArray[2] === 'body') { debugLog('Found body in right-hand'); headerValue = (valueArray[3] ? jsonPath(result, valueArray.slice(3).join('.')) : result).toString(); } else { this.printBlankLine(); this.serverlessLog(`Warning: while processing responseParameter "${key}": "${value}"`); this.serverlessLog(`Offline plugin only supports "integration.response.body[.JSON_path]" right-hand responseParameter. Found "${value}" instead. Skipping.`); this.logPluginIssue(); this.printBlankLine(); } } else { headerValue = value.match(/^'.*'$/) ? value.slice(1, -1) : value; // See #34 } // Applies the header; debugLog(`Will assign "${headerValue}" to header "${headerName}"`); response.header(headerName, headerValue); } else { this.printBlankLine(); this.serverlessLog(`Warning: while processing responseParameter "${key}": "${value}"`); this.serverlessLog(`Offline plugin only supports "method.response.header.PARAM_NAME" left-hand responseParameter. Found "${key}" instead. Skipping.`); this.logPluginIssue(); this.printBlankLine(); } }); } let statusCode = 200; if (integration === 'lambda') { /* RESPONSE TEMPLATE PROCCESSING */ // If there is a responseTemplate, we apply it to the result const responseTemplates = chosenResponse.responseTemplates; if (_.isPlainObject(responseTemplates)) { const responseTemplatesKeys = Object.keys(responseTemplates); if (responseTemplatesKeys.length) { // BAD IMPLEMENTATION: first key in responseTemplates const responseTemplate = responseTemplates[responseContentType]; if (responseTemplate && responseTemplate !== '\n') { debugLog('_____ RESPONSE TEMPLATE PROCCESSING _____'); debugLog(`Using responseTemplate '${responseContentType}'`); try { const reponseContext = createVelocityContext(request, this.velocityContextOptions, result); result = renderVelocityTemplateObject({ root: responseTemplate }, reponseContext).root; } catch (error) { this.serverlessLog(`Error while parsing responseTemplate '${responseContentType}' for lambda ${funName}:`); console.log(error.stack); } } } } /* HAPIJS RESPONSE CONFIGURATION */ statusCode = errorStatusCode !== 0 ? errorStatusCode : (chosenResponse.statusCode || 200); if (!chosenResponse.statusCode) { this.printBlankLine(); this.serverlessLog(`Warning: No statusCode found for response "${responseName}".`); } response.header('Content-Type', responseContentType, { override: false, // Maybe a responseParameter set it already. See #34 }); response.statusCode = statusCode; if (contentHandling === 'CONVERT_TO_BINARY') { response.encoding = 'binary'; response.source = Buffer.from(result, 'base64'); response.variety = 'buffer'; } else { response.source = result; } } else if (integration === 'lambda-proxy') { response.statusCode = statusCode = result.statusCode || 200; const defaultHeaders = { 'Content-Type': 'application/json' }; Object.assign(response.headers, defaultHeaders, result.headers); if (!_.isUndefined(result.body)) { if (result.isBase64Encoded) { response.encoding = 'binary'; response.source = Buffer.from(result.body, 'base64'); response.variety = 'buffer'; } else { response.source = result.body; } } } // Log response let whatToLog = result; try { whatToLog = JSON.stringify(result); } catch (error) { // nothing } finally { if (!this.options.dontPrintOutput) this.serverlessLog(err ? `Replying ${statusCode}` : `[${statusCode}] ${whatToLog}`); debugLog('requestId:', requestId); } // Bon voyage! response.send(); }); // Now we are outside of createLambdaContext, so this happens before the handler gets called: // We cannot use Hapijs's timeout feature because the logic above can take a significant time, so we implement it ourselves this.requests[requestId].timeout = this.options.noTimeout ? null : setTimeout( this._replyTimeout.bind(this, response, funName, funOptions.funTimeout, requestId), funOptions.funTimeout ); // Finally we call the handler debugLog('_____ CALLING HANDLER _____'); try { const x = handler(event, lambdaContext, lambdaContext.done); // Promise support if ((serviceRuntime === 'nodejs8.10' || serviceRuntime === 'babel') && !this.requests[requestId].done) { if (x && typeof x.then === 'function' && typeof x.catch === 'function') x.then(lambdaContext.succeed).catch(lambdaContext.fail); else if (x instanceof Error) lambdaContext.fail(x); } } catch (error) { return this._reply500(response, `Uncaught error in your '${funName}' handler`, error, requestId); } }, }); }); }); } _configureAuthorization(endpoint, funName, method, epath, servicePath, serviceRuntime) { let authStrategyName = null; if (endpoint.authorizer) { let authFunctionName = endpoint.authorizer; if (typeof authFunctionName === 'string' && authFunctionName.toUpperCase() === 'AWS_IAM') { this.serverlessLog('WARNING: Serverless Offline does not support the AWS_IAM authorization type'); return null; } if (typeof endpoint.authorizer === 'object') { if (endpoint.authorizer.type && endpoint.authorizer.type.toUpperCase() === 'AWS_IAM') { this.serverlessLog('WARNING: Serverless Offline does not support the AWS_IAM authorization type'); return null; } if (endpoint.authorizer.arn) { this.serverlessLog(`WARNING: Serverless Offline does not support non local authorizers: ${endpoint.authorizer.arn}`); return authStrategyName; } authFunctionName = endpoint.authorizer.name; } this.serverlessLog(`Configuring Authorization: ${endpoint.path} ${authFunctionName}`); const authFunction = this.service.getFunction(authFunctionName); if (!authFunction) return this.serverlessLog(`WARNING: Authorization function ${authFunctionName} does not exist`); const authorizerOptions = { resultTtlInSeconds: '300', identitySource: 'method.request.header.Authorization', }; if (typeof endpoint.authorizer === 'string') { authorizerOptions.name = authFunctionName; } else { Object.assign(authorizerOptions, endpoint.authorizer); } // Create a unique scheme per endpoint // This allows the methodArn on the event property to be set appropriately const authKey = `${funName}-${authFunctionName}-${method}-${epath}`; const authSchemeName = `scheme-${authKey}`; authStrategyName = `strategy-${authKey}`; // set strategy name for the route config debugLog(`Creating Authorization scheme for ${authKey}`); // Create the Auth Scheme for the endpoint const scheme = createAuthScheme( authFunction, authorizerOptions, funName, epath, this.options, this.serverlessLog, servicePath, this.serverless, serviceRuntime ); // Set the auth scheme and strategy on the server this.server.auth.scheme(authSchemeName, scheme); this.server.auth.strategy(authStrategyName, authSchemeName); } return authStrategyName; } // All done, we can listen to incomming requests _listen() { return new Promise((resolve, reject) => { this.server.start(err => { if (err) return reject(err); this.printBlankLine(); this.serverlessLog(`Offline listening on http${this.options.httpsProtocol ? 's' : ''}://${this.options.host}:${this.options.port}`); resolve(this.server); }); }); } end() { this.serverlessLog('Halting offline server'); this.server.stop({ timeout: 5000 }) .then(() => process.exit(this.exitCode)); } // Bad news _reply500(response, message, err, requestId) { if (this._clearTimeout(requestId)) return; this.requests[requestId].done = true; const stackTrace = this._getArrayStackTrace(err.stack); this.serverlessLog(message); if (stackTrace && stackTrace.length > 0) { console.log(stackTrace); } else { console.log(err); } /* eslint-disable no-param-reassign */ response.statusCode = 200; // APIG replies 200 by default on failures response.source = { errorMessage: message, errorType: err.constructor.name, stackTrace, offlineInfo: 'If you believe this is an issue with the plugin please submit it, thanks. https://github.com/dherault/serverless-offline/issues', }; /* eslint-enable no-param-reassign */ this.serverlessLog('Replying error in handler'); response.send(); } _replyTimeout(response, funName, funTimeout, requestId) { if (this.currentRequestId !== requestId) return; this.requests[requestId].done = true; this.serverlessLog(`Replying timeout after ${funTimeout}ms`); /* eslint-disable no-param-reassign */ response.statusCode = 503; response.source = `[Serverless-Offline] Your λ handler '${funName}' timed out after ${funTimeout}ms.`; /* eslint-enable no-param-reassign */ response.send(); } _clearTimeout(requestId) { const timeout = this.requests[requestId].timeout; if (timeout && timeout._called) return true; clearTimeout(timeout); } _createResourceRoutes() { if (!this.options.resourceRoutes) return true; const resourceRoutesOptions = this.options.resourceRoutes; const resourceRoutes = parseResources(this.service.resources); if (_.isEmpty(resourceRoutes)) return true; this.printBlankLine(); this.serverlessLog('Routes defined in resources:'); Object.keys(resourceRoutes).forEach(methodId => { const resourceRoutesObj = resourceRoutes[methodId]; const path = resourceRoutesObj.path; const method = resourceRoutesObj.method; const isProxy = resourceRoutesObj.isProxy; const proxyUri = resourceRoutesObj.proxyUri; const pathResource = resourceRoutesObj.pathResource; if (!isProxy) { return this.serverlessLog(`WARNING: Only HTTP_PROXY is supported. Path '${pathResource}' is ignored.`); } if (!path) { return this.serverlessLog(`WARNING: Could not resolve path for '${methodId}'.`); } let fullPath = this.options.prefix + (pathResource.startsWith('/') ? pathResource.slice(1) : pathResource); if (fullPath !== '/' && fullPath.endsWith('/')) fullPath = fullPath.slice(0, -1); fullPath = fullPath.replace(/\+}/g, '*}'); const proxyUriOverwrite = resourceRoutesOptions[methodId] || {}; const proxyUriInUse = proxyUriOverwrite.Uri || proxyUri; if (!proxyUriInUse) { return this.serverlessLog(`WARNING: Could not load Proxy Uri for '${methodId}'`); } const routeMethod = method === 'ANY' ? '*' : method; const routeConfig = { cors: this.options.corsConfig } if (routeMethod !== 'HEAD' && routeMethod !== 'GET') { routeConfig.payload = { parse: false }; } this.serverlessLog(`${method} ${fullPath} -> ${proxyUriInUse}`); this.server.route({ method: routeMethod, path: fullPath, config: routeConfig, handler: (request, reply) => { const params = request.params; let resultUri = proxyUriInUse; Object.keys(params).forEach(key => { resultUri = resultUri.replace(`{${key}}`, params[key]); }); this.serverlessLog(`PROXY ${request.method} ${request.url.path} -> ${resultUri}`); reply.proxy({ uri: resultUri, passThrough: true }); }, }); }); } _create404Route() { // If a {proxy+} route exists, don't conflict with it if (this.server.match('*', '/{p*}')) return; this.server.route({ method: '*', path: '/{p*}', config: { cors: this.options.corsConfig }, handler: (request, reply) => { const response = reply({ statusCode: 404, error: 'Serverless-offline: route not found.', currentRoute: `${request.method} - ${request.path}`, existingRoutes: this.server.table()[0].table .filter(route => route.path !== '/{p*}') // Exclude this (404) route .sort((a, b) => a.path <= b.path ? -1 : 1) // Sort by path .map(route => `${route.method} - ${route.path}`), // Human-friendly result }); response.statusCode = 404; }, }); } _getArrayStackTrace(stack) { if (!stack) return null; const splittedStack = stack.split('\n'); return splittedStack.slice(0, splittedStack.findIndex(item => item.match(/server.route.handler.createLambdaContext/))).map(line => line.trim()); } _logAndExit() { console.log.apply(null, arguments); process.exit(0); } } module.exports = Offline;