UNPKG

serverless-offline

Version:

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

1,335 lines (1,103 loc) 39.4 kB
import { Buffer } from "node:buffer" import { readFile } from "node:fs/promises" import { createRequire } from "node:module" import { join, resolve } from "node:path" import { exit } from "node:process" import h2o2 from "@hapi/h2o2" import { Server } from "@hapi/hapi" import { log } from "../../utils/log.js" import authFunctionNameExtractor from "../authFunctionNameExtractor.js" import authJWTSettingsExtractor from "./authJWTSettingsExtractor.js" import createAuthScheme from "./createAuthScheme.js" import createJWTAuthScheme from "./createJWTAuthScheme.js" import Endpoint from "./Endpoint.js" import { LambdaIntegrationEvent, LambdaProxyIntegrationEvent, renderVelocityTemplateObject, VelocityContext, } from "./lambda-events/index.js" import LambdaProxyIntegrationEventV2 from "./lambda-events/LambdaProxyIntegrationEventV2.js" import parseResources from "./parseResources.js" import payloadSchemaValidator from "./payloadSchemaValidator.js" import logRoutes from "../../utils/logRoutes.js" import { createApiKey, detectEncoding, generateHapiPath, getApiKeysValues, getHttpApiCorsConfig, jsonPath, splitHandlerPathAndName, } from "../../utils/index.js" const { parse, stringify } = JSON const { assign, entries, keys } = Object export default class HttpServer { #apiKeysValues = null #hasPrivateHttpEvent = false #lambda = null #options = null #server = null #serverless = null #terminalInfo = [] constructor(serverless, options, lambda) { this.#lambda = lambda this.#options = options this.#serverless = serverless } async #loadCerts(httpsProtocol) { const [cert, key] = await Promise.all([ readFile(resolve(httpsProtocol, "cert.pem"), "utf8"), readFile(resolve(httpsProtocol, "key.pem"), "utf8"), ]) return { cert, key, } } async createServer() { const { enforceSecureCookies, host, httpPort, httpsProtocol } = this.#options const serverOptions = { host, port: httpPort, router: { stripTrailingSlash: true, }, state: enforceSecureCookies ? { isHttpOnly: true, isSameSite: false, isSecure: true, } : { isHttpOnly: false, isSameSite: false, isSecure: false, }, // https support ...(httpsProtocol != null && { tls: await this.#loadCerts(httpsProtocol), }), } // Hapijs server creation this.#server = new Server(serverOptions) try { await this.#server.register([h2o2]) } catch (err) { log.error(err) } // Enable CORS preflight response this.#server.ext("onPreResponse", (request, h) => { if (request.headers.origin) { const response = request.response.isBoom ? request.response.output : request.response const explicitlySetHeaders = { ...response.headers, } if ( this.#serverless.service.provider.httpApi && this.#serverless.service.provider.httpApi.cors ) { const httpApiCors = getHttpApiCorsConfig( this.#serverless.service.provider.httpApi.cors, this, ) if (request.method === "options") { response.statusCode = 204 const allowAllOrigins = httpApiCors.allowedOrigins.length === 1 && httpApiCors.allowedOrigins[0] === "*" if ( !allowAllOrigins && !httpApiCors.allowedOrigins.includes(request.headers.origin) ) { return h.continue } } response.headers["access-control-allow-origin"] = request.headers.origin if (httpApiCors.allowCredentials) { response.headers["access-control-allow-credentials"] = "true" } if (httpApiCors.maxAge) { response.headers["access-control-max-age"] = httpApiCors.maxAge } if (httpApiCors.exposedResponseHeaders) { response.headers["access-control-expose-headers"] = httpApiCors.exposedResponseHeaders.join(",") } if (httpApiCors.allowedMethods) { response.headers["access-control-allow-methods"] = httpApiCors.allowedMethods.join(",") } if (httpApiCors.allowedHeaders) { response.headers["access-control-allow-headers"] = httpApiCors.allowedHeaders.join(",") } } else { response.headers["access-control-allow-origin"] = request.headers.origin response.headers["access-control-allow-credentials"] = "true" if (request.method === "options") { response.statusCode = 200 response.headers["access-control-expose-headers"] = request.headers["access-control-expose-headers"] || "content-type, content-length, etag" response.headers["access-control-max-age"] = 60 * 10 if (request.headers["access-control-request-headers"]) { response.headers["access-control-allow-headers"] = request.headers["access-control-request-headers"] } if (request.headers["access-control-request-method"]) { response.headers["access-control-allow-methods"] = request.headers["access-control-request-method"] } } // Override default headers with headers that have been explicitly set entries(explicitlySetHeaders).forEach(([key, value]) => { if (value) { response.headers[key] = value } }) } } return h.continue }) } async start() { const { host, httpPort, httpsProtocol } = this.#options try { await this.#server.start() } catch (err) { log.error( `Unexpected error while starting serverless-offline server on port ${httpPort}:`, err, ) exit(1) } // TODO move the following block const server = `${httpsProtocol ? "https" : "http"}://${host}:${httpPort}` log.notice(`Server ready: ${server} 🚀`) } // stops the server stop(timeout) { return this.#server.stop({ timeout, }) } #logPluginIssue() { log.notice( "If you think this is an issue with the plugin please submit it, thanks!\nhttps://github.com/dherault/serverless-offline/issues", ) log.notice() } #extractJWTAuthSettings(endpoint) { const result = authJWTSettingsExtractor( endpoint, this.#serverless.service.provider, this.#options.ignoreJWTSignature, ) return result.unsupportedAuth ? null : result } #configureJWTAuthorization(endpoint, functionKey, method, path) { if (!endpoint.authorizer) { return null } // right now _configureJWTAuthorization only handles AWS HttpAPI Gateway JWT // authorizers that are defined in the serverless file if ( this.#serverless.service.provider.name !== "aws" || !endpoint.isHttpApi ) { return null } if ( (endpoint.authorizer.name && this.#serverless.service.provider?.httpApi?.authorizers?.[ endpoint.authorizer.name ]?.type === "request") || endpoint.authorizer.type === "request" ) { return null } const jwtSettings = this.#extractJWTAuthSettings(endpoint) if (!jwtSettings) { return null } log.notice(`Configuring JWT Authorization: ${method} ${path}`) // Create a unique scheme per endpoint // This allows the methodArn on the event property to be set appropriately const authKey = `${functionKey}-${jwtSettings.authorizerName}-${method}-${path}` const authSchemeName = `scheme-${authKey}` const authStrategyName = `strategy-${authKey}` // set strategy name for the route config log.debug(`Creating Authorization scheme for ${authKey}`) // Create the Auth Scheme for the endpoint const scheme = createJWTAuthScheme(jwtSettings) // Set the auth scheme and strategy on the server this.#server.auth.scheme(authSchemeName, scheme) this.#server.auth.strategy(authStrategyName, authSchemeName) return authStrategyName } #extractAuthFunctionName(endpoint) { const result = authFunctionNameExtractor(endpoint) return result.unsupportedAuth ? null : result.authorizerName } #configureAuthorization(endpoint, functionKey, method, path) { if (!endpoint.authorizer) { return null } let authFunctionName = this.#extractAuthFunctionName(endpoint) if (!authFunctionName) { return null } log.notice(`Configuring Authorization: ${path} ${authFunctionName}`) const standardFunctionExists = this.#serverless.service.functions && this.#serverless.service.functions[authFunctionName] const serverlessAuthorizerOptions = this.#serverless.service.provider.httpApi && this.#serverless.service.provider.httpApi.authorizers && this.#serverless.service.provider.httpApi.authorizers[authFunctionName] if ( !standardFunctionExists && endpoint.isHttpApi && serverlessAuthorizerOptions && serverlessAuthorizerOptions.functionName ) { log.notice( `Redirecting authorizer function: ${authFunctionName} to ${serverlessAuthorizerOptions.functionName}`, ) authFunctionName = serverlessAuthorizerOptions.functionName } const authFunction = this.#serverless.service.getFunction(authFunctionName) if (!authFunction) { log.error(`Authorization function ${authFunctionName} does not exist`) return null } const authorizerOptions = { enableSimpleResponses: (endpoint.isHttpApi && serverlessAuthorizerOptions?.enableSimpleResponses) || false, identitySource: serverlessAuthorizerOptions?.identitySource, identityValidationExpression: serverlessAuthorizerOptions?.identityValidationExpression || "(.*)", payloadVersion: endpoint.isHttpApi ? serverlessAuthorizerOptions?.payloadVersion || "2.0" : "1.0", resultTtlInSeconds: serverlessAuthorizerOptions?.resultTtlInSeconds ?? "300", type: endpoint.isHttpApi ? serverlessAuthorizerOptions?.type : undefined, } if ( authorizerOptions.enableSimpleResponses && authorizerOptions.payloadVersion === "1.0" ) { log.error( `Cannot create Authorization function '${authFunctionName}' if payloadVersion is '1.0' and enableSimpleResponses is true`, ) return null } if (typeof endpoint.authorizer !== "string") { assign(authorizerOptions, endpoint.authorizer) } authorizerOptions.name = authFunctionName if ( !authorizerOptions.identitySource && !( authorizerOptions.type === "request" && authorizerOptions.resultTtlInSeconds === 0 ) ) { authorizerOptions.identitySource = "method.request.header.Authorization" } // Create a unique scheme per endpoint // This allows the methodArn on the event property to be set appropriately const authKey = `${functionKey}-${authFunctionName}-${method}-${path}` const authSchemeName = `scheme-${authKey}` const authStrategyName = `strategy-${authKey}` // set strategy name for the route config log.debug(`Creating Authorization scheme for ${authKey}`) // Create the Auth Scheme for the endpoint const scheme = createAuthScheme( authorizerOptions, this.#serverless.service.provider, this.#lambda, ) // Set the auth scheme and strategy on the server this.#server.auth.scheme(authSchemeName, scheme) this.#server.auth.strategy(authStrategyName, authSchemeName) return authStrategyName } #setAuthorizationStrategy(endpoint, functionKey, method, path) { /* * The authentication strategy can be provided outside of this project * by injecting the provider through a custom variable in the serverless.yml. * * see the example in the tests for more details * /tests/integration/custom-authentication */ const customizations = this.#serverless.service.custom if ( customizations && customizations.offline?.customAuthenticationProvider ) { const root = resolve(this.#serverless.serviceDir, "require-resolver") const customRequire = createRequire(root) const provider = customRequire( customizations.offline.customAuthenticationProvider, ) const strategy = provider(endpoint, functionKey, method, path) this.#server.auth.scheme( strategy.scheme, strategy.getAuthenticateFunction, ) this.#server.auth.strategy(strategy.name, strategy.scheme) return strategy.name } // If the endpoint has an authorization function, create an authStrategy for the route const authStrategyName = this.#options.noAuth ? null : this.#configureJWTAuthorization(endpoint, functionKey, method, path) || this.#configureAuthorization(endpoint, functionKey, method, path) return authStrategyName } #createHapiHandler(params) { const { additionalRequestContext, endpoint, functionKey, hapiMethod, hapiPath, method, protectedRoute, stage, } = params return async (request, h) => { const requestPath = endpoint.isHttpApi || this.#options.noPrependStageInUrl ? request.path : request.path.substr(`/${stage}`.length) // payload processing const encoding = detectEncoding(request) request.raw.req.payload = request.payload request.payload = request.payload && request.payload.toString(encoding) request.rawPayload = request.payload // incoming request message log.notice() log.notice() log.notice(`${method} ${request.path} (λ: ${functionKey})`) // check for APIKey if ( (protectedRoute === `${hapiMethod}#${hapiPath}` || protectedRoute === `ANY#${hapiPath}`) && !this.#options.noAuth ) { const errorResponse = () => h .response({ message: "Forbidden", }) .code(403) .header("x-amzn-ErrorType", "ForbiddenException") .type("application/json") const apiKey = request.headers["x-api-key"] if (apiKey) { if (!this.#apiKeysValues.has(apiKey)) { log.debug( `Method '${method}' of function '${functionKey}' token '${apiKey}' not valid.`, ) return errorResponse() } } else if ( request.auth && request.auth.credentials && request.auth.credentials.usageIdentifierKey ) { const { usageIdentifierKey } = request.auth.credentials if (!this.#apiKeysValues.has(usageIdentifierKey)) { log.debug( `Method '${method}' of function '${functionKey}' token '${usageIdentifierKey}' not valid.`, ) return errorResponse() } } else { log.debug(`Missing 'x-api-key' on private function '${functionKey}'.`) return errorResponse() } } const response = h.response() const contentType = request.mime || "application/json" // default content type const { integration, requestTemplates } = endpoint // default request template to '' if we don't have a definition pushed in from serverless or endpoint const requestTemplate = requestTemplates !== undefined && integration === "AWS" ? requestTemplates[contentType] : "" const schemas = endpoint?.request?.schemas === undefined ? "" : endpoint.request.schemas[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.includes(contentType) && request.payload && request.payload.length > 1 ) { try { if (!request.payload || request.payload.length === 0) { request.payload = "{}" } request.payload = parse(request.payload) } catch (err) { log.debug("error in converting request.payload to JSON:", err) } } log.debug("contentType:", contentType) log.debug("requestTemplate:", requestTemplate) log.debug("payload:", request.payload) /* REQUEST PAYLOAD SCHEMA VALIDATION */ if (schemas) { log.debug("schemas:", schemas) try { payloadSchemaValidator(schemas, request.payload) } catch (err) { return this.#reply400(response, err.message, err) } } /* REQUEST TEMPLATE PROCESSING (event population) */ let event = {} if (integration === "AWS") { if (requestTemplate) { try { log.debug("_____ REQUEST TEMPLATE PROCESSING _____") event = new LambdaIntegrationEvent( request, stage, requestTemplate, requestPath, ).create() } catch (err) { return this.#reply502( response, `Error while parsing template "${contentType}" for ${functionKey}`, err, ) } } else if (typeof request.payload === "object") { event = request.payload || {} } } else if (integration === "AWS_PROXY") { const lambdaProxyIntegrationEvent = endpoint.isHttpApi && endpoint.payload === "2.0" ? new LambdaProxyIntegrationEventV2( request, stage, endpoint.routeKey, additionalRequestContext, ) : new LambdaProxyIntegrationEvent( request, stage, requestPath, endpoint.isHttpApi ? endpoint.routeKey : null, additionalRequestContext, ) event = lambdaProxyIntegrationEvent.create() const customizations = this.#serverless.service.custom const hasCustomAuthProvider = customizations?.offline?.customAuthenticationProvider if (!endpoint.authorizer && !hasCustomAuthProvider) { log.debug("no authorizer configured, deleting authorizer payload") delete event.requestContext.authorizer } } log.debug("event:", event) const lambdaFunction = this.#lambda.get(functionKey) lambdaFunction.setEvent(event) let result let err try { result = await lambdaFunction.runHandler() } catch (_err) { err = _err } // const processResponse = (err, data) => { // Everything in this block happens once the lambda function has resolved log.debug("_____ HANDLER RESOLVED _____") let responseName = "default" const { contentHandling, responseContentType } = endpoint /* RESPONSE SELECTION (among endpoint's possible responses) */ // Failure handling let errorStatusCode = "502" if (err) { const errorMessage = (err.message || err).toString() const found = errorMessage.match(/\[(\d{3})]/) if (found && found.length > 1) { ;[, errorStatusCode] = found } else { errorStatusCode = "502" } // Mocks Lambda errors result = { errorMessage, errorType: err.constructor.name, stackTrace: this.#getArrayStackTrace(err.stack), } log.error(errorMessage) for (const [key, value] of entries(endpoint.responses)) { if ( key !== "default" && `^${value.selectionPattern || key}$`.test(errorMessage) ) { responseName = key break } } } log.debug(`Using response '${responseName}'`) const chosenResponse = endpoint.responses?.[responseName] ?? {} /* RESPONSE PARAMETERS PROCCESSING */ const { responseParameters } = chosenResponse if (responseParameters) { log.debug("_____ RESPONSE PARAMETERS PROCCESSING _____") log.debug( `Found ${ keys(responseParameters).length } responseParameters for '${responseName}' response`, ) // responseParameters use the following shape: "key": "value" entries(responseParameters).forEach(([key, value]) => { const keyArray = key.split(".") // eg: "method.response.header.location" const valueArray = value.split(".") // eg: "integration.response.body.redirect.url" log.debug(`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 log.debug("Found header in left-hand:", headerName) if (value.startsWith("integration.response")) { if (valueArray[2] === "body") { log.debug("Found body in right-hand") headerValue = valueArray[3] ? jsonPath(result, valueArray.slice(3).join(".")) : result headerValue = headerValue == null ? "" : String(headerValue) } else { log.notice() log.warning() log.warning( `Offline plugin only supports "integration.response.body[.JSON_path]" right-hand responseParameter. Found "${value}" (for "${key}"") instead. Skipping.`, ) this.#logPluginIssue() log.notice() } } else { headerValue = /^'.*'$/.test(value) ? value.slice(1, -1) : value // See #34 } // Applies the header; if (headerValue === "") { log.warning( `Empty value for responseParameter "${key}": "${value}", it won't be set`, ) } else { log.debug( `Will assign "${headerValue}" to header "${headerName}"`, ) response.header(headerName, headerValue) } } else { log.notice() log.warning() log.warning( `Offline plugin only supports "method.response.header.PARAM_NAME" left-hand responseParameter. Found "${key}" instead. Skipping.`, ) this.#logPluginIssue() log.notice() } }) } let statusCode = 200 if (integration === "AWS") { const endpointResponseHeaders = (endpoint.response && endpoint.response.headers) || {} entries(endpointResponseHeaders) .filter( ([, value]) => typeof value === "string" && /^'.*?'$/.test(value), ) .forEach(([key, value]) => response.header(key, value.slice(1, -1))) /* LAMBDA INTEGRATION RESPONSE TEMPLATE PROCCESSING */ // If there is a responseTemplate, we apply it to the result const { responseTemplates } = chosenResponse if ( typeof responseTemplates === "object" && keys(responseTemplates).length > 0 ) { // BAD IMPLEMENTATION: first key in responseTemplates const responseTemplate = responseTemplates[responseContentType] if (responseTemplate && responseTemplate !== "\n") { log.debug("_____ RESPONSE TEMPLATE PROCCESSING _____") log.debug(`Using responseTemplate '${responseContentType}'`) try { const reponseContext = new VelocityContext( request, stage, result, ).getContext() result = renderVelocityTemplateObject( { root: responseTemplate, }, reponseContext, ).root } catch (error) { log.error( `Error while parsing responseTemplate '${responseContentType}' for lambda ${functionKey}:\n${error.stack}`, ) } } } /* LAMBDA INTEGRATION HAPIJS RESPONSE CONFIGURATION */ statusCode = chosenResponse.statusCode || 200 if (err) { statusCode = errorStatusCode } if (!chosenResponse.statusCode) { log.notice() log.warning() log.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 if (typeof result === "string") { response.source = stringify(result) } else { response.source = result } } else if (integration === "AWS_PROXY") { /* LAMBDA PROXY INTEGRATION HAPIJS RESPONSE CONFIGURATION */ if ( endpoint.isHttpApi && endpoint.payload === "2.0" && (typeof result === "string" || !result.statusCode) ) { const body = typeof result === "string" ? result : stringify(result) result = { body, headers: { "Content-Type": "application/json", }, isBase64Encoded: false, statusCode: 200, } } if (result && !result.errorType) { statusCode = result.statusCode || 200 } else if (err) { statusCode = errorStatusCode || 502 } else { statusCode = 502 } response.statusCode = statusCode const headers = {} if (result && result.headers) { entries(result.headers).forEach(([headerKey, headerValue]) => { headers[headerKey] = (headers[headerKey] || []).concat(headerValue) }) } if (result && result.multiValueHeaders) { entries(result.multiValueHeaders).forEach( ([headerKey, headerValue]) => { headers[headerKey] = (headers[headerKey] || []).concat( headerValue, ) }, ) } log.debug("headers", headers) const parseCookies = (headerValue) => { const cookieName = headerValue.slice(0, headerValue.indexOf("=")) const cookieValue = headerValue.slice(headerValue.indexOf("=") + 1) h.state(cookieName, cookieValue, { encoding: "none", strictHeader: false, }) } entries(headers).forEach(([headerKey, headerValue]) => { if (headerKey.toLowerCase() === "set-cookie") { headerValue.forEach(parseCookies) } else { headerValue.forEach((value) => { // it looks like Hapi doesn't support multiple headers with the same name, // appending values is the closest we can come to the AWS behavior. response.header(headerKey, value, { append: true, }) }) } }) if ( endpoint.isHttpApi && endpoint.payload === "2.0" && result.cookies ) { result.cookies.forEach(parseCookies) } response.header("Content-Type", "application/json", { duplicate: false, override: false, }) if (typeof result === "string") { response.source = stringify(result) } else if (result && result.body !== undefined) { if (result.isBase64Encoded) { response.encoding = "binary" response.source = Buffer.from(result.body, "base64") response.variety = "buffer" } else { if (result && result.body && typeof result.body !== "string") { // FIXME TODO we should probably just write to console instead of returning a payload return this.#reply502( response, "According to the API Gateway specs, the body content must be stringified. Check your Lambda response and make sure you are invoking JSON.stringify(YOUR_CONTENT) on your body object", {}, ) } response.source = result.body } } } return response } } createRoutes(functionKey, httpEvent, handler) { if (!this.#hasPrivateHttpEvent && httpEvent.private) { this.#hasPrivateHttpEvent = true if (this.#options.noAuth) { log.notice( `Authorizers are turned off. You do not need to use 'x-api-key' header.`, ) } else { log.notice(`Remember to use 'x-api-key' on the request headers.`) } if (this.#apiKeysValues == null) { this.#apiKeysValues = getApiKeysValues( this.#serverless.service.provider.apiGateway?.apiKeys ?? [], ) if (this.#apiKeysValues.size === 0) { const apiKey = createApiKey() this.#apiKeysValues.add(apiKey) log.notice(`Key with token: '${apiKey}'`) } } } let method let path let hapiPath if (httpEvent.isHttpApi) { if (httpEvent.routeKey === "$default") { method = "ANY" path = httpEvent.routeKey hapiPath = "/{default*}" } else { ;[method, path] = httpEvent.routeKey.split(" ") hapiPath = generateHapiPath( path, { ...this.#options, noPrependStageInUrl: true, // Serverless always uses the $default stage }, this.#serverless, ) } } else { method = httpEvent.method.toUpperCase() ;({ path } = httpEvent) hapiPath = generateHapiPath(path, this.#options, this.#serverless) } const [handlerPath] = splitHandlerPathAndName(handler) const endpoint = new Endpoint( join(this.#serverless.config.servicePath, handlerPath), httpEvent, ).generate() const stage = endpoint.isHttpApi ? "$default" : this.#options.stage || this.#serverless.service.provider.stage const protectedRoute = httpEvent.private ? `${method}#${hapiPath}` : undefined const { host, httpPort, httpsProtocol } = this.#options const server = `${httpsProtocol ? "https" : "http"}://${host}:${httpPort}` this.#terminalInfo.push({ invokePath: `/2015-03-31/functions/${functionKey}/invocations`, method, path: hapiPath, server, stage: endpoint.isHttpApi || this.#options.noPrependStageInUrl ? null : stage, }) const authStrategyName = this.#setAuthorizationStrategy( endpoint, functionKey, method, path, ) let cors = null if (endpoint.cors) { cors = { credentials: endpoint.cors.credentials || this.#options.corsConfig.credentials, exposedHeaders: this.#options.corsConfig.exposedHeaders, headers: endpoint.cors.headers || this.#options.corsConfig.headers, origin: endpoint.cors.origins || this.#options.corsConfig.origin, } } else if ( this.#serverless.service.provider.httpApi && this.#serverless.service.provider.httpApi.cors ) { const httpApiCors = getHttpApiCorsConfig( this.#serverless.service.provider.httpApi.cors, this, ) cors = { credentials: httpApiCors.allowCredentials, exposedHeaders: httpApiCors.exposedResponseHeaders || [], headers: httpApiCors.allowedHeaders || [], maxAge: httpApiCors.maxAge, origin: httpApiCors.allowedOrigins || [], } } const hapiMethod = method === "ANY" ? "*" : method const state = this.#options.disableCookieValidation ? { failAction: "ignore", parse: false, } : { failAction: "error", parse: true, } const hapiOptions = { auth: authStrategyName, cors, response: { emptyStatusCode: 200, }, state, timeout: { socket: false, }, } // skip HEAD routes as hapi will fail with 'Method name not allowed: HEAD ...' // for more details, check https://github.com/dherault/serverless-offline/issues/204 if (hapiMethod === "HEAD") { log.notice( "HEAD method event detected. Skipping HAPI server route mapping", ) return } if (hapiMethod !== "HEAD" && hapiMethod !== "GET") { // maxBytes: Increase request size from 1MB default limit to 10MB. // Cf AWS API GW payload limits. hapiOptions.payload = { maxBytes: 1024 * 1024 * 10, parse: false, } } const additionalRequestContext = {} if (httpEvent.operationId) { additionalRequestContext.operationName = httpEvent.operationId } hapiOptions.tags = ["api"] const hapiHandler = this.#createHapiHandler({ additionalRequestContext, endpoint, functionKey, hapiMethod, hapiPath, method, protectedRoute, stage, }) this.#server.route({ handler: hapiHandler, method: hapiMethod, options: hapiOptions, path: hapiPath, }) } #replyError(statusCode, response, message, error) { log.notice(message) log.error(error) response.header("Content-Type", "application/json") response.statusCode = statusCode response.source = { errorMessage: message, errorType: error.constructor.name, offlineInfo: "If you believe this is an issue with serverless-offline please submit it, thanks. https://github.com/dherault/serverless-offline/issues", stackTrace: this.#getArrayStackTrace(error.stack), } return response } // Bad news #reply502(response, message, error) { // APIG replies 502 by default on failures; return this.#replyError(502, response, message, error) } #reply400(response, message, error) { return this.#replyError(400, response, message, error) } createResourceRoutes() { const resourceRoutesOptions = this.#options.resourceRoutes if (!resourceRoutesOptions) { return } const resourceRoutes = parseResources(this.#serverless.service.resources) if (!resourceRoutes || keys(resourceRoutes).length === 0) { return } log.notice() log.notice() log.notice("Routes defined in resources:") entries(resourceRoutes).forEach(([methodId, resourceRoutesObj]) => { const { isProxy, method, pathResource, proxyUri } = resourceRoutesObj if (!isProxy) { log.warning( `Only HTTP_PROXY is supported. Path '${pathResource}' is ignored.`, ) return } if (!pathResource) { log.warning(`Could not resolve path for '${methodId}'.`) return } const hapiPath = generateHapiPath( pathResource, this.#options, this.#serverless, ) const proxyUriOverwrite = resourceRoutesOptions[methodId] || {} const proxyUriInUse = proxyUriOverwrite.Uri || proxyUri if (!proxyUriInUse) { log.warning(`Could not load Proxy Uri for '${methodId}'`) return } const hapiMethod = method === "ANY" ? "*" : method const state = this.#options.disableCookieValidation ? { failAction: "ignore", parse: false, } : { failAction: "error", parse: true, } const hapiOptions = { cors: this.#options.corsConfig, state, } // skip HEAD routes as hapi will fail with 'Method name not allowed: HEAD ...' // for more details, check https://github.com/dherault/serverless-offline/issues/204 if (hapiMethod === "HEAD") { log.notice( "HEAD method event detected. Skipping HAPI server route mapping", ) return } if (hapiMethod !== "GET" && hapiMethod !== "HEAD") { hapiOptions.payload = { parse: false, } } log.notice(`${method} ${hapiPath} -> ${proxyUriInUse}`) // hapiOptions.tags = ['api'] const route = { handler(request, h) { const { params } = request let resultUri = proxyUriInUse entries(params).forEach(([key, value]) => { resultUri = resultUri.replace(`{${key}}`, value) }) if (request.url.search !== null) { resultUri += request.url.search // search is empty string by default } log.notice( `PROXY ${request.method} ${request.url.pathname} -> ${resultUri}`, ) return h.proxy({ passThrough: true, uri: resultUri, }) }, method: hapiMethod, options: hapiOptions, path: hapiPath, } this.#server.route(route) }) } create404Route() { // If a {proxy+} or $default route exists, don't conflict with it if (this.#server.match("*", "/{p*}")) { return } const existingRoutes = this.#server .table() // Exclude this (404) route .filter((route) => route.path !== "/{p*}") // Sort by path .sort((a, b) => (a.path <= b.path ? -1 : 1)) // Human-friendly result .map((route) => `${route.method} - ${route.path}`) const route = { handler(request, h) { const response = h.response({ currentRoute: `${request.method} - ${request.path}`, error: "Serverless-offline: route not found.", existingRoutes, statusCode: 404, }) response.statusCode = 404 return response }, method: "*", options: { cors: this.#options.corsConfig, }, path: "/{p*}", } this.#server.route(route) } #getArrayStackTrace(stack) { if (!stack) return null const splittedStack = stack.split("\n") return splittedStack .slice( 0, splittedStack.findIndex((item) => item.match(/server.route.handler.LambdaContext/), ), ) .map((line) => line.trim()) } writeRoutesTerminal() { logRoutes(this.#terminalInfo) } // TEMP FIXME quick fix to expose gateway server for testing, look for better solution getServer() { return this.#server } }