scripts/lua/routing.lua (220 lines of code) (raw):

-- -- Licensed to the Apache Software Foundation (ASF) under one or more -- contributor license agreements. See the NOTICE file distributed with -- this work for additional information regarding copyright ownership. -- The ASF licenses this file to You under the Apache License, Version 2.0 -- (the "License"); you may not use this file except in compliance with -- the License. You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. -- --- @module Routing -- Used to dynamically handle nginx routing based on an object containing implementation details local cjson = require "cjson" if (cjson.decode_array_with_array_mt ~= nil) then cjson.decode_array_with_array_mt(true) end local url = require "url" local utils = require "lib/utils" local request = require "lib/request" -- load policies local security = require "policies/security" local mapping = require "policies/mapping" local rateLimit = require "policies/rateLimit" local backendRouting = require "policies/backendRouting" local cors = require "cors" local OPTIMIZE = os.getenv("OPTIMIZE") if OPTIMIZE ~= nil then OPTIMIZE = tonumber(OPTIMIZE) else OPTIMIZE = 0 end local SNAPSHOTTING = os.getenv('SNAPSHOTTING') local _M = {} local function setRequestLogs() local requestHeaders = ngx.req.get_headers() for k in pairs(requestHeaders) do if k == 'authorization' or k == ngx.ctx.clientSecretName then requestHeaders[k] = '[redacted]' end end ngx.var.requestHeaders = cjson.encode(requestHeaders) ngx.req.read_body() ngx.var.requestBody = ngx.req.get_body_data() end --- Function to read the list of policies and send implementation to the correct backend -- @param red redis client instance -- @param obj List of policies containing a type and value field. This function reads the type field and routes it appropriately. -- @param apiKey optional subscription api key local function parsePolicies(dataStore, obj, apiKey) for _, v in pairs (obj) do if v.type == 'reqMapping' then mapping.processMap(v.value) elseif v.type == 'rateLimit' then rateLimit.limit(dataStore, v.value, apiKey) elseif v.type == 'backendRouting' then backendRouting.setDynamicRoute(v.value) end end end --- Given a verb, transforms the backend request to use that method -- @param v Verb to set on the backend request local function setVerb(v) local allowedVerbs = {'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'} local verb = string.upper(v) if utils.tableContains(allowedVerbs, verb) then ngx.req.set_method(ngx[utils.concatStrings({"HTTP_", verb})]) else ngx.req.set_method(ngx.HTTP_GET) end end --- Main function that handles parsing of invocation details and carries out implementation function _M.processCall(dataStore) -- Get request headers local requestHeaders = ngx.req.get_headers() -- Get resource object from redis local tenantId = ngx.var.tenant if SNAPSHOTTING == 'true' then dataStore:setSnapshotId(tenantId) end local gatewayPath = ngx.var.gatewayPath local _ , j = ngx.var.request_uri:find("/api/([^/]+)") ngx.var.analyticsUri = ngx.var.request_uri:sub(j+1) if ngx.req.get_headers()["x-debug-mode"] == "true" then setRequestLogs() end local redisKey = _M.findResource(dataStore, tenantId, gatewayPath) if redisKey == nil then request.err(404, 'Not found.') end local resource = dataStore:getResource(redisKey, "resources") if resource == nil then request.err(404, 'Snapshot not found.') end local obj = cjson.decode(resource) cors.processCall(obj) ngx.var.tenantNamespace = obj.tenantNamespace ngx.var.tenantInstance = obj.tenantInstance ngx.var.apiId = obj.apiId for verb, opFields in pairs(obj.operations) do if string.upper(verb) == ngx.req.get_method() then -- Check if auth is required local key if (opFields.security) then for _, sec in ipairs(opFields.security) do local result = security.process(dataStore, sec) if key == nil and sec.type ~= "oauth2" then key = result -- use key from either apiKey or clientSecret security policy end end end -- Set backend method if opFields.backendMethod ~= nil then setVerb(opFields.backendMethod) end -- Set backend upstream and uri backendRouting.setRoute(opFields.backendUrl, gatewayPath) -- Set gateway url as request header local requestScheme = requestHeaders["X-Forwarded-Proto"] if requestScheme == nil or requestScheme == "" then requestScheme = ngx.var.scheme end local requestHost = requestHeaders["X-Forwarded-Host"] if requestHost == nil or requestHost == "" then requestHost = ngx.var.host end local requestUrl = utils.concatStrings({requestScheme, "://", requestHost}) local prefix = requestHeaders["X-Forwarded-Prefix"] if prefix ~= nil and prefix ~= "" then requestUrl = utils.concatStrings({requestUrl, prefix}) end local requestUri = requestHeaders["X-Forwarded-Uri"] if requestUri == nil or requestUri == "" then requestUri = ngx.var.request_uri end ngx.req.set_header("X-Forwarded-Url", utils.concatStrings({requestUrl, requestUri})) -- Parse policies if opFields.policies ~= nil then parsePolicies(dataStore, opFields.policies, key) end -- Log updated request headers/body info to access logs if requestHeaders["x-debug-mode"] == "true" then setRequestLogs() end dataStore:close() return nil end end request.err(404, 'Whoops. Verb not supported.') end --- Find the correct redis key based on the path that's passed in -- @param dataStore the datastore object -- @param tenant tenantId -- @param path path to look for function _M.findResource(dataStore, tenant, path) -- Check for exact match local redisKey = utils.concatStrings({"resources:", tenant, ":", path}) local cfRedisKey local cfUrl = ngx.req.get_headers()["x-cf-forwarded-url"] if cfUrl ~= nil and cfUrl ~= "" then local u = url.parse(cfUrl) cfRedisKey = utils.concatStrings({"resources:", tenant, ":", path, u.path}) ngx.var.analyticsUri = (u.path == "") and "/" or u.path if next(u.query) ~= nil then ngx.var.analyticsUri = utils.concatStrings({ngx.var.analyticsUri, '?', u.query}) end end local result if OPTIMIZE > 0 then result = dataStore:optimizedLookup(tenant, path) end if result ~= nil then ngx.var.gatewayPath = result:gsub(utils.concatStrings({'resources:', tenant, ':'}), '') return result end local resourceKeys = dataStore:getAllResources(tenant) result = _M.slowLookup(resourceKeys, tenant, path, redisKey, cfRedisKey) if OPTIMIZE > 0 and result ~= nil then dataStore:optimizeLookup(tenant, result, path) end return result end --- Perform a linear lookup of the api based on the apis in a tenant -- @param resourceKeys all of the resources under a given tenant -- @param tenant the tenant we are looking up for -- @param path the path used to call the api gateway -- @param redisKey a guess for a redis key based on the tenant id and path -- @param cfRedisKey a redis key that will exist if cloud foundry routing logic is used function _M.slowLookup(resourceKeys, tenant, path, redisKey, cfRedisKey) for _, key in pairs(resourceKeys) do if key == redisKey or key == cfRedisKey then local res = {string.match(key, "([^:]+):([^:]+):([^:]+)")} ngx.var.gatewayPath = res[3] return key end end local cfUrl = ngx.req.get_headers()["x-cf-forwarded-url"] if cfUrl ~= nil and cfUrl ~= "" then return nil end -- Construct a table of redisKeys based on number of slashes in the path local keyTable = {} for _, key in pairs(resourceKeys) do local _, count = string.gsub(key, "/", "") -- handle cases where resource path is "/" if count == 1 and string.sub(key, -1) == "/" then count = count - 1 end count = tostring(count) if keyTable[count] == nil then keyTable[count] = {} end table.insert(keyTable[count], key) end -- Check for proxy or path parameter matching local _, count = string.gsub(redisKey, "/", "") for i = count, 0, -1 do local countString = tostring(i) if keyTable[countString] ~= nil then for _, key in pairs(keyTable[countString]) do -- Check for exact match or path parameter match if key == redisKey or key == utils.concatStrings({redisKey, "/"}) or _M.pathParamMatch(key, redisKey) == true then local res = {string.match(key, "([^:]+):([^:]+):([^:]+)")} ngx.var.gatewayPath = res[3] return key end end end -- substring redisKey upto last "/" local index = redisKey:match("^.*()/") if index == nil then return nil end redisKey = string.sub(redisKey, 1, index - 1) end return nil end --- Check redis if resourceKey matches path parameters -- @param key key that may have path parameter variables -- @param resourceKey redis resourceKey to check if it matches path parameter function _M.pathParamMatch(key, resourceKey) local pathParamVars = {} for w in string.gmatch(key, "({%w+})") do w = string.sub(w, 2, string.len(w) - 1) pathParamVars[#pathParamVars + 1] = w end if next(pathParamVars) ~= nil then local pathPattern, count = string.gsub(key, "%{(%w*)%}", "([^:]+)") pathPattern = string.gsub(pathPattern, "%-", "%%-") local obj = {string.match(resourceKey, pathPattern)} if (#obj == count) then for i, v in pairs(obj) do ngx.ctx[pathParamVars[i]] = v end return true end end return false end function _M.setResponseLogs() ngx.var.responseHeaders = cjson.encode(ngx.resp.get_headers()) local resp_body = ngx.arg[1] ngx.ctx.buffered = (ngx.ctx.buffered or '') .. resp_body if ngx.arg[2] then ngx.var.responseBody = ngx.ctx.buffered end end return _M